fix(工作台-工时、周报、日志管理): 详细如下。
- 移除历史工作日分隔符兼容逻辑,仅使用显式分隔标记切分 - 扩展工时数据显示范围从5个工作日到7天完整周,并添加周末展示控制 - 在团队工时统计中新增加班工时字段并调整图表展示方式 - 移除团队工时分布中的分类矩阵,简化数据结构 - 为API访问日志和错误日志搜索组件添加用户选择功能 - 调整日志搜索界面布局,将用户编号输入改为用户选择下拉框 - 移除用户类型筛选条件以简化搜索界面 - 更新工时图表配置以支持周末数据显示和新的数据结构
This commit is contained in:
@@ -499,7 +499,7 @@ export function normalizeTeamLoad(response: TeamLoadResponse): Api.Project.TeamL
|
|||||||
export function normalizeMyWorklogWeek(response: MyWorklogWeekResponse): Api.Project.MyWorklogWeekResult {
|
export function normalizeMyWorklogWeek(response: MyWorklogWeekResponse): Api.Project.MyWorklogWeekResult {
|
||||||
return {
|
return {
|
||||||
weekStart: response.weekStart ?? '',
|
weekStart: response.weekStart ?? '',
|
||||||
dailyHours: response.dailyHours ?? [0, 0, 0, 0, 0],
|
dailyHours: response.dailyHours ?? [0, 0, 0, 0, 0, 0, 0],
|
||||||
distribution: (response.distribution ?? []).map(item => ({
|
distribution: (response.distribution ?? []).map(item => ({
|
||||||
...normalizeWorklogDistributionItem(item),
|
...normalizeWorklogDistributionItem(item),
|
||||||
hours: typeof item.hours === 'number' ? item.hours : 0
|
hours: typeof item.hours === 'number' ? item.hours : 0
|
||||||
@@ -513,6 +513,7 @@ export function normalizeTeamWorklogWeek(response: TeamWorklogWeekResponse): Api
|
|||||||
members: (response.members ?? []).map(member => ({
|
members: (response.members ?? []).map(member => ({
|
||||||
userId: normalizeStringId(member.userId),
|
userId: normalizeStringId(member.userId),
|
||||||
userNickname: member.userNickname ?? '',
|
userNickname: member.userNickname ?? '',
|
||||||
|
overtimeHours: typeof member.overtimeHours === 'number' ? member.overtimeHours : 0,
|
||||||
items: (member.items ?? []).map(item => ({
|
items: (member.items ?? []).map(item => ({
|
||||||
...normalizeWorklogDistributionItem(item),
|
...normalizeWorklogDistributionItem(item),
|
||||||
hours: typeof item.hours === 'number' ? item.hours : 0
|
hours: typeof item.hours === 'number' ? item.hours : 0
|
||||||
|
|||||||
3
src/typings/api/project.d.ts
vendored
3
src/typings/api/project.d.ts
vendored
@@ -491,7 +491,7 @@ declare namespace Api {
|
|||||||
interface MyWorklogWeekResult {
|
interface MyWorklogWeekResult {
|
||||||
/** 归一后的周一日期 YYYY-MM-DD */
|
/** 归一后的周一日期 YYYY-MM-DD */
|
||||||
weekStart: string;
|
weekStart: string;
|
||||||
/** 周一~周五逐日工时(固定 5 元素;均摊推算值,周末份额归周五) */
|
/** 周一~周日逐日工时(固定 7 元素;按周填报时仅均摊到工作日,周末单天工时保留在周末当天) */
|
||||||
dailyHours: number[];
|
dailyHours: number[];
|
||||||
/** 本周工时按归属分布,hours 降序 */
|
/** 本周工时按归属分布,hours 降序 */
|
||||||
distribution: WorklogDistributionItem[];
|
distribution: WorklogDistributionItem[];
|
||||||
@@ -502,6 +502,7 @@ declare namespace Api {
|
|||||||
userId: string;
|
userId: string;
|
||||||
userNickname: string;
|
userNickname: string;
|
||||||
items: WorklogDistributionItem[];
|
items: WorklogDistributionItem[];
|
||||||
|
overtimeHours: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工作台「团队工时周聚合」响应(GET /project/project/me/team-worklog-week;周标准工时后端不返回,前端落常量 35) */
|
/** 工作台「团队工时周聚合」响应(GET /project/project/me/team-worklog-week;周标准工时后端不返回,前端落常量 35) */
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script setup lang="tsx">
|
<script setup lang="tsx">
|
||||||
import { computed, reactive, ref } from 'vue';
|
import { computed, reactive, ref } from 'vue';
|
||||||
import { INFRA_OPERATE_TYPE_DICT_CODE } from '@/constants/dict';
|
import { INFRA_OPERATE_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
import { fetchExportApiAccessLog, fetchGetApiAccessLog, fetchGetApiAccessLogPage } from '@/service/api';
|
import {
|
||||||
|
fetchExportApiAccessLog,
|
||||||
|
fetchGetApiAccessLog,
|
||||||
|
fetchGetApiAccessLogPage,
|
||||||
|
fetchGetUserSimpleList
|
||||||
|
} from '@/service/api';
|
||||||
import { useAuth } from '@/hooks/business/auth';
|
import { useAuth } from '@/hooks/business/auth';
|
||||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||||
@@ -59,6 +64,7 @@ const searchParams = reactive(createSearchParams());
|
|||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const currentRow = ref<Api.SystemLog.ApiAccess.Log | null>(null);
|
const currentRow = ref<Api.SystemLog.ApiAccess.Log | null>(null);
|
||||||
const exporting = ref(false);
|
const exporting = ref(false);
|
||||||
|
const userOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||||
const canExport = computed(() => hasAuth(LogPermission.ApiAccessExport));
|
const canExport = computed(() => hasAuth(LogPermission.ApiAccessExport));
|
||||||
|
|
||||||
const detailSections: LogDetailSection[] = [
|
const detailSections: LogDetailSection[] = [
|
||||||
@@ -176,6 +182,22 @@ function handleSearch() {
|
|||||||
reloadTable(1);
|
reloadTable(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadUserOptions() {
|
||||||
|
const { error, data: userList } = await fetchGetUserSimpleList();
|
||||||
|
|
||||||
|
if (error || !userList) {
|
||||||
|
userOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
userOptions.value = userList.map((item: Api.SystemManage.UserSimple) => ({
|
||||||
|
label: item.username ? `${item.nickname}(${item.username})` : item.nickname,
|
||||||
|
value: item.id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUserOptions();
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
exporting.value = true;
|
exporting.value = true;
|
||||||
const { error, data: blob } = await fetchExportApiAccessLog(searchParams);
|
const { error, data: blob } = await fetchExportApiAccessLog(searchParams);
|
||||||
@@ -191,7 +213,12 @@ async function handleExport() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
|
<div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
|
||||||
<ApiAccessLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
<ApiAccessLogSearch
|
||||||
|
v-model:model="searchParams"
|
||||||
|
:user-options="userOptions"
|
||||||
|
@reset="resetSearchParams"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||||
<template #header>
|
<template #header>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { SYSTEM_USER_TYPE_DICT_CODE } from '@/constants/dict';
|
|
||||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'ApiAccessLogSearch' });
|
defineOptions({ name: 'ApiAccessLogSearch' });
|
||||||
@@ -12,7 +11,19 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const model = defineModel<Api.SystemLog.ApiAccess.SearchParams>('model', { required: true });
|
const model = defineModel<Api.SystemLog.ApiAccess.SearchParams>('model', { required: true });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
userOptions: Array<{ label: string; value: string }>;
|
||||||
|
}>();
|
||||||
|
|
||||||
const fields = computed<SearchField[]>(() => [
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'userId',
|
||||||
|
label: '用户名',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '请选择用户名',
|
||||||
|
options: props.userOptions,
|
||||||
|
filterable: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'applicationName',
|
key: 'applicationName',
|
||||||
label: '应用名',
|
label: '应用名',
|
||||||
@@ -51,20 +62,14 @@ const fields = computed<SearchField[]>(() => [
|
|||||||
},
|
},
|
||||||
resolveValue: value => (value === null || value === undefined ? '' : String(value))
|
resolveValue: value => (value === null || value === undefined ? '' : String(value))
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
key: 'userId',
|
// key: 'userType',
|
||||||
label: '用户编号',
|
// label: '用户类型',
|
||||||
type: 'input',
|
// type: 'dict',
|
||||||
placeholder: '请输入用户编号'
|
// placeholder: '请选择用户类型',
|
||||||
},
|
// dictCode: SYSTEM_USER_TYPE_DICT_CODE,
|
||||||
{
|
// filterable: true
|
||||||
key: 'userType',
|
// },
|
||||||
label: '用户类型',
|
|
||||||
type: 'dict',
|
|
||||||
placeholder: '请选择用户类型',
|
|
||||||
dictCode: SYSTEM_USER_TYPE_DICT_CODE,
|
|
||||||
filterable: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'beginTime',
|
key: 'beginTime',
|
||||||
label: '请求时间',
|
label: '请求时间',
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script setup lang="tsx">
|
<script setup lang="tsx">
|
||||||
import { computed, reactive, ref } from 'vue';
|
import { computed, reactive, ref } from 'vue';
|
||||||
import { INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE } from '@/constants/dict';
|
import { INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE } from '@/constants/dict';
|
||||||
import { fetchExportApiErrorLog, fetchGetApiErrorLog, fetchGetApiErrorLogPage } from '@/service/api';
|
import {
|
||||||
|
fetchExportApiErrorLog,
|
||||||
|
fetchGetApiErrorLog,
|
||||||
|
fetchGetApiErrorLogPage,
|
||||||
|
fetchGetUserSimpleList
|
||||||
|
} from '@/service/api';
|
||||||
import { useAuth } from '@/hooks/business/auth';
|
import { useAuth } from '@/hooks/business/auth';
|
||||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||||
@@ -51,6 +56,7 @@ const searchParams = reactive(createSearchParams());
|
|||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const currentRow = ref<Api.SystemLog.ApiError.Log | null>(null);
|
const currentRow = ref<Api.SystemLog.ApiError.Log | null>(null);
|
||||||
const exporting = ref(false);
|
const exporting = ref(false);
|
||||||
|
const userOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||||
const canExport = computed(() => hasAuth(LogPermission.ApiErrorExport));
|
const canExport = computed(() => hasAuth(LogPermission.ApiErrorExport));
|
||||||
|
|
||||||
const detailSections: LogDetailSection[] = [
|
const detailSections: LogDetailSection[] = [
|
||||||
@@ -168,6 +174,22 @@ function handleSearch() {
|
|||||||
reloadTable(1);
|
reloadTable(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadUserOptions() {
|
||||||
|
const { error, data: userList } = await fetchGetUserSimpleList();
|
||||||
|
|
||||||
|
if (error || !userList) {
|
||||||
|
userOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
userOptions.value = userList.map((item: Api.SystemManage.UserSimple) => ({
|
||||||
|
label: item.username ? `${item.nickname}(${item.username})` : item.nickname,
|
||||||
|
value: item.id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUserOptions();
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
exporting.value = true;
|
exporting.value = true;
|
||||||
const { error, data: blob } = await fetchExportApiErrorLog(searchParams);
|
const { error, data: blob } = await fetchExportApiErrorLog(searchParams);
|
||||||
@@ -183,7 +205,12 @@ async function handleExport() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
|
<div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
|
||||||
<ApiErrorLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
<ApiErrorLogSearch
|
||||||
|
v-model:model="searchParams"
|
||||||
|
:user-options="userOptions"
|
||||||
|
@reset="resetSearchParams"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||||
<template #header>
|
<template #header>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE, SYSTEM_USER_TYPE_DICT_CODE } from '@/constants/dict';
|
import { INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE } from '@/constants/dict';
|
||||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'ApiErrorLogSearch' });
|
defineOptions({ name: 'ApiErrorLogSearch' });
|
||||||
@@ -12,7 +12,19 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const model = defineModel<Api.SystemLog.ApiError.SearchParams>('model', { required: true });
|
const model = defineModel<Api.SystemLog.ApiError.SearchParams>('model', { required: true });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
userOptions: Array<{ label: string; value: string }>;
|
||||||
|
}>();
|
||||||
|
|
||||||
const fields = computed<SearchField[]>(() => [
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'userId',
|
||||||
|
label: '用户名',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '请选择用户名',
|
||||||
|
options: props.userOptions,
|
||||||
|
filterable: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'applicationName',
|
key: 'applicationName',
|
||||||
label: '应用名',
|
label: '应用名',
|
||||||
@@ -33,20 +45,14 @@ const fields = computed<SearchField[]>(() => [
|
|||||||
dictCode: INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE,
|
dictCode: INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE,
|
||||||
filterable: true
|
filterable: true
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
key: 'userId',
|
// key: 'userType',
|
||||||
label: '用户编号',
|
// label: '用户类型',
|
||||||
type: 'input',
|
// type: 'dict',
|
||||||
placeholder: '请输入用户编号'
|
// placeholder: '请选择用户类型',
|
||||||
},
|
// dictCode: SYSTEM_USER_TYPE_DICT_CODE,
|
||||||
{
|
// filterable: true
|
||||||
key: 'userType',
|
// },
|
||||||
label: '用户类型',
|
|
||||||
type: 'dict',
|
|
||||||
placeholder: '请选择用户类型',
|
|
||||||
dictCode: SYSTEM_USER_TYPE_DICT_CODE,
|
|
||||||
filterable: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'exceptionTime',
|
key: 'exceptionTime',
|
||||||
label: '异常时间',
|
label: '异常时间',
|
||||||
|
|||||||
@@ -360,7 +360,6 @@ function resolveTaskItemTypeLabel(value?: string | null) {
|
|||||||
|
|
||||||
const STRUCTURED_TASK_PREFIX_RE = /^(?:(?:\d+[..、])|(?:\d+\s+)|(?:[一二三四五六七八九十百千万]+[、..]))\s*/u;
|
const STRUCTURED_TASK_PREFIX_RE = /^(?:(?:\d+[..、])|(?:\d+\s+)|(?:[一二三四五六七八九十百千万]+[、..]))\s*/u;
|
||||||
const WORKLOG_DAY_SEPARATOR = '[[WR_DAY_SPLIT]]';
|
const WORKLOG_DAY_SEPARATOR = '[[WR_DAY_SPLIT]]';
|
||||||
const LEGACY_WORKLOG_DAY_SEPARATOR = ';';
|
|
||||||
|
|
||||||
function stripStructuredTaskPrefixV2(value: string) {
|
function stripStructuredTaskPrefixV2(value: string) {
|
||||||
return value.trim().replace(STRUCTURED_TASK_PREFIX_RE, '');
|
return value.trim().replace(STRUCTURED_TASK_PREFIX_RE, '');
|
||||||
@@ -387,10 +386,9 @@ function formatStructuredTaskDisplayLine(task: StructuredTask, index: number, sh
|
|||||||
|
|
||||||
function getWorkLogEntries(detail: string): string[] {
|
function getWorkLogEntries(detail: string): string[] {
|
||||||
if (!detail) return [];
|
if (!detail) return [];
|
||||||
// 新默认稿优先按显式分隔标记切分;历史数据继续兼容中文分号。
|
// 仅按显式分隔标记切分,避免正文里的中文分号被误判成跨天分隔。
|
||||||
const separator = detail.includes(WORKLOG_DAY_SEPARATOR) ? WORKLOG_DAY_SEPARATOR : LEGACY_WORKLOG_DAY_SEPARATOR;
|
|
||||||
return detail
|
return detail
|
||||||
.split(separator)
|
.split(WORKLOG_DAY_SEPARATOR)
|
||||||
.map(item => item.trim())
|
.map(item => item.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,12 +253,18 @@ export interface WorkbenchWorklogDistributionItem {
|
|||||||
/** 周标准工时:系统无配置项,后端不返回,产品确认前端落常量 35(不是 40) */
|
/** 周标准工时:系统无配置项,后端不返回,产品确认前端落常量 35(不是 40) */
|
||||||
export const WORKBENCH_WEEK_TARGET_HOURS = 35;
|
export const WORKBENCH_WEEK_TARGET_HOURS = 35;
|
||||||
|
|
||||||
/** 单周工时视图(builder 衍生;逐日工时为后端按填报日期段均摊到工作日的推算值) */
|
/** 单周工时视图(builder 衍生;接口返回周一~周日逐日工时,按周填报时仅均摊到工作日) */
|
||||||
export interface WorkbenchWeekWorklogView {
|
export interface WorkbenchWeekWorklogView {
|
||||||
weekStart: string;
|
weekStart: string;
|
||||||
weekLabel: string;
|
weekLabel: string;
|
||||||
/** 周一~周五逐日工时(5 长度,均摊推算值,周末份额归周五) */
|
/** 周一~周日逐日工时(7 长度;周末单天工时保留在周末当天) */
|
||||||
dailyHours: number[];
|
dailyHours: number[];
|
||||||
|
/** 是否展示周末两列;仅当周六或周日任一天 > 0 时展示 */
|
||||||
|
showWeekend: boolean;
|
||||||
|
/** 图表/逐日展示实际使用的横轴标签 */
|
||||||
|
visibleDayLabels: string[];
|
||||||
|
/** 图表/逐日展示实际使用的逐日工时数据 */
|
||||||
|
visibleDailyHours: number[];
|
||||||
/** 本周累计 */
|
/** 本周累计 */
|
||||||
totalHours: number;
|
totalHours: number;
|
||||||
target: number;
|
target: number;
|
||||||
@@ -269,7 +275,10 @@ export interface WorkbenchWeekWorklogView {
|
|||||||
distribution: WorkbenchWorklogDistributionItem[];
|
distribution: WorkbenchWorklogDistributionItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEEKDAY_COUNT = 5;
|
const WORKDAY_COUNT = 5;
|
||||||
|
const FULL_WEEKDAY_COUNT = 7;
|
||||||
|
const WORKDAY_LABELS = ['一', '二', '三', '四', '五'] as const;
|
||||||
|
const FULL_WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'] as const;
|
||||||
|
|
||||||
function roundHours(value: number) {
|
function roundHours(value: number) {
|
||||||
return Math.round(value * 10) / 10;
|
return Math.round(value * 10) / 10;
|
||||||
@@ -291,7 +300,12 @@ export function buildWorkbenchWeekWorklogView(source: Api.Project.MyWorklogWeekR
|
|||||||
const start = dayjs(source.weekStart);
|
const start = dayjs(source.weekStart);
|
||||||
const weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}周` : source.weekStart;
|
const weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}周` : source.weekStart;
|
||||||
|
|
||||||
const dailyHours = Array.from({ length: WEEKDAY_COUNT }, (_, i) => roundHours(source.dailyHours[i] ?? 0));
|
const dailyHours = Array.from({ length: FULL_WEEKDAY_COUNT }, (_, i) => roundHours(source.dailyHours[i] ?? 0));
|
||||||
|
const sat = Number(dailyHours[5] ?? 0);
|
||||||
|
const sun = Number(dailyHours[6] ?? 0);
|
||||||
|
const showWeekend = sat > 0 || sun > 0;
|
||||||
|
const visibleDayLabels = showWeekend ? [...FULL_WEEKDAY_LABELS] : [...WORKDAY_LABELS];
|
||||||
|
const visibleDailyHours = showWeekend ? [...dailyHours] : dailyHours.slice(0, WORKDAY_COUNT);
|
||||||
const totalHours = roundHours(dailyHours.reduce((s, h) => s + h, 0));
|
const totalHours = roundHours(dailyHours.reduce((s, h) => s + h, 0));
|
||||||
const target = WORKBENCH_WEEK_TARGET_HOURS;
|
const target = WORKBENCH_WEEK_TARGET_HOURS;
|
||||||
const delta = roundHours(totalHours - target);
|
const delta = roundHours(totalHours - target);
|
||||||
@@ -301,6 +315,9 @@ export function buildWorkbenchWeekWorklogView(source: Api.Project.MyWorklogWeekR
|
|||||||
weekStart: source.weekStart,
|
weekStart: source.weekStart,
|
||||||
weekLabel,
|
weekLabel,
|
||||||
dailyHours,
|
dailyHours,
|
||||||
|
showWeekend,
|
||||||
|
visibleDayLabels,
|
||||||
|
visibleDailyHours,
|
||||||
totalHours,
|
totalHours,
|
||||||
target,
|
target,
|
||||||
delta,
|
delta,
|
||||||
@@ -320,8 +337,6 @@ export function getGreeting(hour: number = dayjs().hour()) {
|
|||||||
|
|
||||||
// === 团队工时分布(D16 团队 tab,原 C12 teamWorklog) ===
|
// === 团队工时分布(D16 团队 tab,原 C12 teamWorklog) ===
|
||||||
|
|
||||||
export type WorkbenchTeamWorklogItemKind = 'project' | 'personal' | 'other';
|
|
||||||
|
|
||||||
/** 团队工时分布视图(builder 衍生) */
|
/** 团队工时分布视图(builder 衍生) */
|
||||||
export interface WorkbenchTeamWorklogView {
|
export interface WorkbenchTeamWorklogView {
|
||||||
weekStart: string;
|
weekStart: string;
|
||||||
@@ -330,11 +345,8 @@ export interface WorkbenchTeamWorklogView {
|
|||||||
memberId: string;
|
memberId: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
totalHours: number;
|
totalHours: number;
|
||||||
|
overtimeHours: number;
|
||||||
}>;
|
}>;
|
||||||
/** 项目/个人/其他 列(保序,去重) */
|
|
||||||
categories: Array<{ key: string; label: string; kind: WorkbenchTeamWorklogItemKind }>;
|
|
||||||
/** 每个 category 一个 series,data[i] = 第 i 个成员该 category 的工时(缺则 0) */
|
|
||||||
seriesMatrix: Array<{ key: string; label: string; kind: WorkbenchTeamWorklogItemKind; data: number[] }>;
|
|
||||||
/** 团队总工时 */
|
/** 团队总工时 */
|
||||||
totalHours: number;
|
totalHours: number;
|
||||||
/** 团队人均工时 */
|
/** 团队人均工时 */
|
||||||
@@ -345,10 +357,6 @@ export interface WorkbenchTeamWorklogView {
|
|||||||
fillRate: number;
|
fillRate: number;
|
||||||
/** 偏低人数:< 团队均值 × 0.8 */
|
/** 偏低人数:< 团队均值 × 0.8 */
|
||||||
lowCount: number;
|
lowCount: number;
|
||||||
/** 加班人数:> 周标准 × 1.125 */
|
|
||||||
highCount: number;
|
|
||||||
/** 加班判定阈值(小时),KPI 文案展示用 */
|
|
||||||
overtimeThreshold: number;
|
|
||||||
/** 工时最低成员(用于底部小结) */
|
/** 工时最低成员(用于底部小结) */
|
||||||
lowest: { memberName: string; hours: number } | null;
|
lowest: { memberName: string; hours: number } | null;
|
||||||
/** 工时最高成员(用于底部小结) */
|
/** 工时最高成员(用于底部小结) */
|
||||||
@@ -360,68 +368,46 @@ export function buildWorkbenchTeamWorklogView(source: Api.Project.TeamWorklogWee
|
|||||||
const weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}周` : source.weekStart;
|
const weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}周` : source.weekStart;
|
||||||
|
|
||||||
// 契约:members[0] 恒为当前用户,展示加「(我)」后缀
|
// 契约:members[0] 恒为当前用户,展示加「(我)」后缀
|
||||||
const members = source.members.map((member, index) => ({
|
const members = source.members.map((member, index) => {
|
||||||
|
const items = member.items.map(toWorklogDistributionItem);
|
||||||
|
const totalHours = roundHours(items.reduce((sum, item) => sum + item.hours, 0));
|
||||||
|
const overtimeHours = roundHours(member.overtimeHours ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
memberId: member.userId,
|
memberId: member.userId,
|
||||||
memberName: index === 0 ? `${member.userNickname}(我)` : member.userNickname,
|
memberName: index === 0 ? `${member.userNickname}(我)` : member.userNickname,
|
||||||
items: member.items.map(toWorklogDistributionItem)
|
totalHours,
|
||||||
}));
|
overtimeHours
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// 列保序去重:按成员遍历顺序首次出现即入列;项目优先、个人/其他按出现顺序追加
|
const totalHours = roundHours(members.reduce((sum, member) => sum + member.totalHours, 0));
|
||||||
const categoryMap = new Map<string, { key: string; label: string; kind: WorkbenchTeamWorklogItemKind }>();
|
const memberCount = members.length;
|
||||||
for (const m of members) {
|
|
||||||
for (const it of m.items) {
|
|
||||||
if (!categoryMap.has(it.key)) {
|
|
||||||
categoryMap.set(it.key, { key: it.key, label: it.label, kind: it.kind });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const categories = Array.from(categoryMap.values());
|
|
||||||
|
|
||||||
const memberView = members.map(m => ({
|
|
||||||
memberId: m.memberId,
|
|
||||||
memberName: m.memberName,
|
|
||||||
totalHours: roundHours(m.items.reduce((s, it) => s + it.hours, 0))
|
|
||||||
}));
|
|
||||||
|
|
||||||
const seriesMatrix = categories.map(cat => ({
|
|
||||||
...cat,
|
|
||||||
data: members.map(m => {
|
|
||||||
const hit = m.items.find(it => it.key === cat.key);
|
|
||||||
return hit ? roundHours(hit.hours) : 0;
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
|
|
||||||
const totalHours = roundHours(memberView.reduce((s, m) => s + m.totalHours, 0));
|
|
||||||
const memberCount = memberView.length;
|
|
||||||
const averageHours = memberCount > 0 ? roundHours(totalHours / memberCount) : 0;
|
const averageHours = memberCount > 0 ? roundHours(totalHours / memberCount) : 0;
|
||||||
const expectedTotalHours = memberCount * WORKBENCH_WEEK_TARGET_HOURS;
|
const expectedTotalHours = memberCount * WORKBENCH_WEEK_TARGET_HOURS;
|
||||||
const fillRate = expectedTotalHours > 0 ? clampPercent((totalHours / expectedTotalHours) * 100) : 0;
|
const fillRate = expectedTotalHours > 0 ? clampPercent((totalHours / expectedTotalHours) * 100) : 0;
|
||||||
|
|
||||||
const lowThreshold = averageHours * 0.8;
|
const lowThreshold = averageHours * 0.8;
|
||||||
const highThreshold = WORKBENCH_WEEK_TARGET_HOURS * 1.125;
|
const lowCount = members.filter(member => member.totalHours < lowThreshold).length;
|
||||||
const lowCount = memberView.filter(m => m.totalHours < lowThreshold).length;
|
|
||||||
const highCount = memberView.filter(m => m.totalHours > highThreshold).length;
|
|
||||||
|
|
||||||
let lowest: { memberName: string; hours: number } | null = null;
|
let lowest: { memberName: string; hours: number } | null = null;
|
||||||
let highest: { memberName: string; hours: number } | null = null;
|
let highest: { memberName: string; hours: number } | null = null;
|
||||||
for (const m of memberView) {
|
for (const member of members) {
|
||||||
if (!lowest || m.totalHours < lowest.hours) lowest = { memberName: m.memberName, hours: m.totalHours };
|
if (!lowest || member.totalHours < lowest.hours)
|
||||||
if (!highest || m.totalHours > highest.hours) highest = { memberName: m.memberName, hours: m.totalHours };
|
lowest = { memberName: member.memberName, hours: member.totalHours };
|
||||||
|
if (!highest || member.totalHours > highest.hours)
|
||||||
|
highest = { memberName: member.memberName, hours: member.totalHours };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
weekStart: source.weekStart,
|
weekStart: source.weekStart,
|
||||||
weekLabel,
|
weekLabel,
|
||||||
members: memberView,
|
members,
|
||||||
categories,
|
|
||||||
seriesMatrix,
|
|
||||||
totalHours,
|
totalHours,
|
||||||
averageHours,
|
averageHours,
|
||||||
expectedTotalHours,
|
expectedTotalHours,
|
||||||
fillRate,
|
fillRate,
|
||||||
lowCount,
|
lowCount,
|
||||||
highCount,
|
|
||||||
overtimeThreshold: roundHours(highThreshold),
|
|
||||||
lowest,
|
lowest,
|
||||||
highest
|
highest
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ function buildMyBarOption(): ECOption {
|
|||||||
grid: { left: 28, right: 8, top: 16, bottom: 24, containLabel: false },
|
grid: { left: 28, right: 8, top: 16, bottom: 24, containLabel: false },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: ['一', '二', '三', '四', '五'],
|
data: v?.visibleDayLabels ?? ['一', '二', '三', '四', '五'],
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||||
axisLabel: { color: '#6b7280', fontSize: 11 }
|
axisLabel: { color: '#6b7280', fontSize: 11 }
|
||||||
@@ -221,7 +221,7 @@ function buildMyBarOption(): ECOption {
|
|||||||
name: '每日工时',
|
name: '每日工时',
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
barWidth: 18,
|
barWidth: 18,
|
||||||
data: v?.dailyHours ?? [],
|
data: v?.visibleDailyHours ?? [],
|
||||||
itemStyle: { color: DAY_BAR_COLOR, borderRadius: [2, 2, 0, 0] }
|
itemStyle: { color: DAY_BAR_COLOR, borderRadius: [2, 2, 0, 0] }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -243,14 +243,9 @@ const { domRef: myBarRef, updateOptions: updateMyBar } = useEcharts(buildMyBarOp
|
|||||||
|
|
||||||
const teamView = computed(() => (teamWeekData.value ? buildWorkbenchTeamWorklogView(teamWeekData.value) : null));
|
const teamView = computed(() => (teamWeekData.value ? buildWorkbenchTeamWorklogView(teamWeekData.value) : null));
|
||||||
|
|
||||||
const teamSeriesWithColor = computed(() =>
|
|
||||||
(teamView.value?.seriesMatrix ?? []).map(s => ({ ...s, color: getWorkbenchItemColor(s.key, s.kind) }))
|
|
||||||
);
|
|
||||||
|
|
||||||
function buildTeamBarOption(): ECOption {
|
function buildTeamBarOption(): ECOption {
|
||||||
const v = teamView.value;
|
const v = teamView.value;
|
||||||
if (!v) return {};
|
if (!v) return {};
|
||||||
const colored = teamSeriesWithColor.value;
|
|
||||||
return {
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
@@ -259,15 +254,15 @@ function buildTeamBarOption(): ECOption {
|
|||||||
const params = Array.isArray(rawParams) ? rawParams : [rawParams];
|
const params = Array.isArray(rawParams) ? rawParams : [rawParams];
|
||||||
if (!params.length) return '';
|
if (!params.length) return '';
|
||||||
const name = params[0].axisValue as string;
|
const name = params[0].axisValue as string;
|
||||||
const total = params.reduce((s: number, p: any) => s + (Number(p.value) || 0), 0);
|
const totalHours = Number(params.find((p: any) => p.seriesName === '总工时')?.value ?? 0);
|
||||||
const lines = params
|
const overtimeHours = Number(params.find((p: any) => p.seriesName === '加班工时')?.value ?? 0);
|
||||||
.filter((p: any) => Number(p.value) > 0)
|
return [
|
||||||
.map((p: any) => `${p.marker}${p.seriesName} <b style="margin-left:6px">${p.value}h</b>`)
|
`<div style="font-weight:600;margin-bottom:4px">${name}</div>`,
|
||||||
.join('<br/>');
|
`总工时:${totalHours}h`,
|
||||||
return `<div style="font-weight:600;margin-bottom:4px">${name} · ${total}h</div>${lines}`;
|
`加班工时:${overtimeHours}h`
|
||||||
|
].join('<br/>');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 不显示项目图例:团队参与项目繁杂,图例会很长很乱;项目明细由 tooltip 悬浮呈现
|
|
||||||
grid: { left: 32, right: 12, top: 16, bottom: 24, containLabel: false },
|
grid: { left: 32, right: 12, top: 16, bottom: 24, containLabel: false },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
@@ -281,17 +276,28 @@ function buildTeamBarOption(): ECOption {
|
|||||||
splitLine: { lineStyle: { color: '#f3f4f6' } },
|
splitLine: { lineStyle: { color: '#f3f4f6' } },
|
||||||
axisLabel: { color: '#9ca3af', fontSize: 10, formatter: '{value}h' }
|
axisLabel: { color: '#9ca3af', fontSize: 10, formatter: '{value}h' }
|
||||||
},
|
},
|
||||||
series: colored.map((s, i) => ({
|
series: [
|
||||||
name: s.label,
|
{
|
||||||
|
name: '总工时',
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
stack: 'member',
|
barWidth: 16,
|
||||||
barWidth: 22,
|
data: v.members.map(member => member.totalHours),
|
||||||
data: s.data,
|
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: s.color,
|
color: '#409EFF',
|
||||||
borderRadius: i === colored.length - 1 ? [3, 3, 0, 0] : 0
|
borderRadius: [3, 3, 0, 0]
|
||||||
}
|
}
|
||||||
}))
|
},
|
||||||
|
{
|
||||||
|
name: '加班工时',
|
||||||
|
type: 'bar',
|
||||||
|
barWidth: 16,
|
||||||
|
data: v.members.map(member => member.overtimeHours),
|
||||||
|
itemStyle: {
|
||||||
|
color: '#F59E0B',
|
||||||
|
borderRadius: [3, 3, 0, 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +363,10 @@ watch(activeTab, async tab => {
|
|||||||
<div class="ww-section-title">
|
<div class="ww-section-title">
|
||||||
<SvgIcon icon="mdi:calendar-week" class="ww-section-icon" />
|
<SvgIcon icon="mdi:calendar-week" class="ww-section-icon" />
|
||||||
<span>每日工时</span>
|
<span>每日工时</span>
|
||||||
<ElTooltip content="系统按填报日期段均摊到工作日的推算值(周末份额计入周五),非逐日实填" placement="top">
|
<ElTooltip
|
||||||
|
content="接口返回周一~周日逐日工时;按周填报时仅均摊到工作日,周末单天工时保留在周末当天展示"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
<span class="ww-section-info-wrap">
|
<span class="ww-section-info-wrap">
|
||||||
<SvgIcon icon="mdi:information-outline" class="ww-section-info" />
|
<SvgIcon icon="mdi:information-outline" class="ww-section-info" />
|
||||||
</span>
|
</span>
|
||||||
@@ -417,14 +426,6 @@ watch(activeTab, async tab => {
|
|||||||
</span>
|
</span>
|
||||||
<span class="tw-kpi__sub">低于均值 80%</span>
|
<span class="tw-kpi__sub">低于均值 80%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tw-kpi">
|
|
||||||
<span class="tw-kpi__label">加班</span>
|
|
||||||
<span class="tw-kpi__value" :class="{ 'is-warn': teamView.highCount > 0 }">
|
|
||||||
{{ teamView.highCount }}
|
|
||||||
<span class="tw-kpi__unit">人</span>
|
|
||||||
</span>
|
|
||||||
<span class="tw-kpi__sub">超 {{ teamView.overtimeThreshold }}h</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="teamBarRef" class="tw-bar" />
|
<div ref="teamBarRef" class="tw-bar" />
|
||||||
@@ -594,7 +595,7 @@ watch(activeTab, async tab => {
|
|||||||
/* ============ 团队工时 ============ */
|
/* ============ 团队工时 ============ */
|
||||||
.tw-kpis {
|
.tw-kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -621,9 +622,6 @@ watch(activeTab, async tab => {
|
|||||||
.tw-kpi__value.is-danger {
|
.tw-kpi__value.is-danger {
|
||||||
color: var(--el-color-danger);
|
color: var(--el-color-danger);
|
||||||
}
|
}
|
||||||
.tw-kpi__value.is-warn {
|
|
||||||
color: var(--el-color-warning);
|
|
||||||
}
|
|
||||||
.tw-kpi__unit {
|
.tw-kpi__unit {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
Reference in New Issue
Block a user