feat(projects): 工作台接口切换为真实数据

This commit is contained in:
2026-06-12 19:49:17 +08:00
parent 0652a24c5e
commit 6896a86130
9 changed files with 1062 additions and 839 deletions

View File

@@ -131,12 +131,14 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
* @param callback callback function * @param callback callback function
*/ */
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) { async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
if (!isRendered()) return;
const updatedOpts = callback(chartOptions, optionsFactory); const updatedOpts = callback(chartOptions, optionsFactory);
Object.assign(chartOptions, updatedOpts); Object.assign(chartOptions, updatedOpts);
// 图表未初始化(容器尺寸未就绪)时只缓存最新 options待 render() 初始化时一并应用;
// 否则数据先于初始化到达会被静默丢弃,首屏永远停留在空数据
if (!isRendered()) return;
if (isRendered()) { if (isRendered()) {
chart?.clear(); chart?.clear();
} }

View File

@@ -1,3 +1,4 @@
import dayjs from 'dayjs';
import { normalizeNullableStringId, normalizeStringId } from './shared'; import { normalizeNullableStringId, normalizeStringId } from './shared';
type ProjectStatusCode = Api.Project.ProjectStatusCode; type ProjectStatusCode = Api.Project.ProjectStatusCode;
@@ -76,6 +77,60 @@ export type MyOwnedProjectResponse = Omit<Api.Project.MyOwnedProjectItem, 'id' |
members?: MyOwnedProjectMemberResponse[] | null; members?: MyOwnedProjectMemberResponse[] | null;
}; };
export type MyTaskResponse = Omit<
Api.Project.MyTaskItem,
| 'id'
| 'projectId'
| 'executionId'
| 'priority'
| 'plannedEndDate'
| 'progressRate'
| 'createTime'
| 'parentTaskId'
| 'availableActions'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
executionId?: StringIdResponse | null;
priority?: string | number | null;
plannedEndDate?: ProjectLocalDateValue;
progressRate?: number | string | null;
createTime?: string | number | null;
parentTaskId?: StringIdResponse | null;
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
};
export type TeamLoadDistributionItemResponse = Omit<Api.Project.TeamLoadDistributionItem, 'projectId'> & {
projectId?: StringIdResponse | null;
};
export type TeamLoadMemberResponse = Omit<Api.Project.TeamLoadMember, 'userId' | 'items'> & {
userId: StringIdResponse;
items?: TeamLoadDistributionItemResponse[] | null;
};
export type TeamLoadResponse = {
members?: TeamLoadMemberResponse[] | null;
};
export type WorklogDistributionItemResponse = Omit<Api.Project.WorklogDistributionItem, 'projectId'> & {
projectId?: StringIdResponse | null;
};
export type MyWorklogWeekResponse = Omit<Api.Project.MyWorklogWeekResult, 'dailyHours' | 'distribution'> & {
dailyHours?: number[] | null;
distribution?: WorklogDistributionItemResponse[] | null;
};
export type TeamWorklogWeekMemberResponse = Omit<Api.Project.TeamWorklogWeekMember, 'userId' | 'items'> & {
userId: StringIdResponse;
items?: WorklogDistributionItemResponse[] | null;
};
export type TeamWorklogWeekResponse = Omit<Api.Project.TeamWorklogWeekResult, 'members'> & {
members?: TeamWorklogWeekMemberResponse[] | null;
};
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & { export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
id: StringIdResponse; id: StringIdResponse;
executionId: StringIdResponse; executionId: StringIdResponse;
@@ -263,6 +318,28 @@ export function normalizeProjectLocalDate(value: ProjectLocalDateValue | undefin
return String(value); return String(value);
} }
/**
* 后端 LocalDateTime 统一序列化为毫秒时间戳(也可能是数字字符串/格式化字符串),
* 归一为 'YYYY-MM-DD HH:mm:ss' 供展示与 dayjs 解析。
*/
export function normalizeProjectDateTime(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') {
return '';
}
let parsed: dayjs.Dayjs;
if (typeof value === 'number') {
parsed = dayjs(value);
} else if (/^\d+$/.test(value)) {
// 字符串形态的毫秒时间戳dayjs 无法直接解析,先转数值(时间值非 ID安全整数范围内
parsed = dayjs(Number(value));
} else {
parsed = dayjs(value);
}
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : '';
}
export function normalizeLifecycleActions<ActionCode extends string>( export function normalizeLifecycleActions<ActionCode extends string>(
actions: LifecycleActionResponse<ActionCode>[] | null | undefined actions: LifecycleActionResponse<ActionCode>[] | null | undefined
): Api.Project.LifecycleAction<ActionCode>[] { ): Api.Project.LifecycleAction<ActionCode>[] {
@@ -296,6 +373,15 @@ function normalizePriority(value: string | number | null | undefined): string {
return String(value); return String(value);
} }
function normalizeProgressRate(value: number | string | null | undefined) {
if (value === null || value === undefined || value === '') {
return null;
}
const numeric = typeof value === 'number' ? value : Number(value ?? 0);
return Number.isFinite(numeric) ? numeric : null;
}
export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution { export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
return { return {
...response, ...response,
@@ -366,6 +452,75 @@ export function normalizeMyOwnedProject(response: MyOwnedProjectResponse): Api.P
}; };
} }
export function normalizeMyTask(response: MyTaskResponse): Api.Project.MyTaskItem {
return {
...response,
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
executionId: normalizeNullableStringId(response.executionId),
executionName: response.executionName ?? null,
statusName: response.statusName ?? null,
priority: normalizePriority(response.priority),
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
progressRate: normalizeProgressRate(response.progressRate) ?? 0,
createTime: normalizeProjectDateTime(response.createTime),
parentTaskId: normalizeNullableStringId(response.parentTaskId),
terminal: Boolean(response.terminal),
allowEdit: Boolean(response.allowEdit),
availableActions: normalizeLifecycleActions(response.availableActions)
};
}
function normalizeWorklogDistributionItem(
response: WorklogDistributionItemResponse | TeamLoadDistributionItemResponse
): { projectId: string | null; projectName: string | null; kind: 'project' | 'personal' | 'other' } {
return {
projectId: normalizeNullableStringId(response.projectId),
projectName: response.projectName ?? null,
kind: response.kind
};
}
export function normalizeTeamLoad(response: TeamLoadResponse): Api.Project.TeamLoadResult {
return {
members: (response.members ?? []).map(member => ({
userId: normalizeStringId(member.userId),
userNickname: member.userNickname ?? '',
items: (member.items ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
count: typeof item.count === 'number' ? item.count : 0
})),
dueSoonCount: typeof member.dueSoonCount === 'number' ? member.dueSoonCount : 0,
overdueCount: typeof member.overdueCount === 'number' ? member.overdueCount : 0
}))
};
}
export function normalizeMyWorklogWeek(response: MyWorklogWeekResponse): Api.Project.MyWorklogWeekResult {
return {
weekStart: response.weekStart ?? '',
dailyHours: response.dailyHours ?? [0, 0, 0, 0, 0],
distribution: (response.distribution ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
hours: typeof item.hours === 'number' ? item.hours : 0
}))
};
}
export function normalizeTeamWorklogWeek(response: TeamWorklogWeekResponse): Api.Project.TeamWorklogWeekResult {
return {
weekStart: response.weekStart ?? '',
members: (response.members ?? []).map(member => ({
userId: normalizeStringId(member.userId),
userNickname: member.userNickname ?? '',
items: (member.items ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
hours: typeof item.hours === 'number' ? item.hours : 0
}))
}))
};
}
export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee { export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
return { return {
...response, ...response,

View File

@@ -13,6 +13,8 @@ import {
type MyExecutionResponse, type MyExecutionResponse,
type MyOwnedProjectResponse, type MyOwnedProjectResponse,
type MyParticipatedProjectResponse, type MyParticipatedProjectResponse,
type MyTaskResponse,
type MyWorklogWeekResponse,
type ProjectExecutionResponse, type ProjectExecutionResponse,
type ProjectLocalDateValue, type ProjectLocalDateValue,
type ProjectMemberResponse, type ProjectMemberResponse,
@@ -20,19 +22,25 @@ import {
type TaskAssigneeFromApiResponse, type TaskAssigneeFromApiResponse,
type TaskAssigneeLogResponse, type TaskAssigneeLogResponse,
type TaskWorklogResponse, type TaskWorklogResponse,
type TeamLoadResponse,
type TeamWorklogWeekResponse,
getProjectLifecycleActions, getProjectLifecycleActions,
normalizeExecutionAssignee, normalizeExecutionAssignee,
normalizeExecutionAssigneeLog, normalizeExecutionAssigneeLog,
normalizeMyExecution, normalizeMyExecution,
normalizeMyOwnedProject, normalizeMyOwnedProject,
normalizeMyParticipatedProject, normalizeMyParticipatedProject,
normalizeMyTask,
normalizeMyWorklogWeek,
normalizeProjectExecution, normalizeProjectExecution,
normalizeProjectLocalDate, normalizeProjectLocalDate,
normalizeProjectMember, normalizeProjectMember,
normalizeProjectTask, normalizeProjectTask,
normalizeTaskAssignee, normalizeTaskAssignee,
normalizeTaskAssigneeLog, normalizeTaskAssigneeLog,
normalizeTaskWorklog normalizeTaskWorklog,
normalizeTeamLoad,
normalizeTeamWorklogWeek
} from './project-shared'; } from './project-shared';
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`; const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
@@ -440,6 +448,57 @@ export async function fetchGetMyOwnedProjectPage(params?: Api.Project.MyProjectS
})); }));
} }
/** 获取工作台「我的任务」(跨项目聚合,负责人/在岗协办人口径,只返回非终态;隐式取当前登录用户) */
export async function fetchGetMyTaskPage(params?: Api.Project.MyTaskSearchParams) {
type MyTaskPageResponse = Api.Project.PageResult<MyTaskResponse>;
const result = await request<MyTaskPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/tasks/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyTaskPageResponse>, data => ({
...data,
list: data.list.map(normalizeMyTask)
}));
}
/** 获取工作台「团队负载」(团队 = 当前用户 + 管理链路直接下级members[0] 恒为当前用户) */
export async function fetchGetMyTeamLoad() {
const result = await request<TeamLoadResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/team-load`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<TeamLoadResponse>, normalizeTeamLoad);
}
/** 获取工作台「我的工时周聚合」weekStart 传任意日期,后端归一到所在周周一;逐日工时为均摊推算值) */
export async function fetchGetMyWorklogWeek(params: Api.Project.WorklogWeekParams) {
const result = await request<MyWorklogWeekResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/worklog-week`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyWorklogWeekResponse>, normalizeMyWorklogWeek);
}
/** 获取工作台「团队工时周聚合」(成员集合与团队负载同口径;周标准工时后端不返回,前端落常量) */
export async function fetchGetTeamWorklogWeek(params: Api.Project.WorklogWeekParams) {
const result = await request<TeamWorklogWeekResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/team-worklog-week`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<TeamWorklogWeekResponse>, normalizeTeamWorklogWeek);
}
/** 获取项目执行状态看板 */ /** 获取项目执行状态看板 */
export function fetchGetProjectExecutionStatusBoard( export function fetchGetProjectExecutionStatusBoard(
projectId: string, projectId: string,

View File

@@ -405,6 +405,112 @@ declare namespace Api {
members: MyOwnedProjectMember[]; members: MyOwnedProjectMember[];
} }
/** 工作台「我的任务」(跨项目)查询入参 */
type MyTaskSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
/** 身份过滤owner 我负责 / collaborator 我协办;缺省 = 两者并集 */
involveType: 'owner' | 'collaborator';
}
>;
/** 工作台「我的任务」单项(跨项目;当前用户为负责人或在岗协办人,接口只返回非终态任务) */
interface MyTaskItem {
/** 任务 ID雪花 ID字符串 */
id: string;
taskTitle: string;
/** 所属项目 */
projectId: string;
projectName: string;
/** 所属执行,未挂执行为 null */
executionId: string | null;
executionName: string | null;
/** 任务状态pending / active / paused非终态 */
statusCode: ProjectTaskStatusCode;
statusName: string | null;
/** 优先级字典 valuerdms_req_priority"0"~"3",数字越小越高) */
priority: string;
/** 计划结束日期YYYY-MM-DD可空 */
plannedEndDate: string | null;
/** 任务进度0-100后端定稿直接返回无进度明确返 0 */
progressRate: number;
/** 创建时间YYYY-MM-DD HH:mm:ss后端返毫秒时间戳适配层归一 */
createTime: string;
/** 我的角色owner 负责人 / collaborator 协办人;双重身份只返 owner */
myRole: 'owner' | 'collaborator';
/** 父任务 ID字符串一级任务为 null */
parentTaskId: string | null;
/** 是否终态;本接口只返非终态任务,正常恒为 false */
terminal: boolean;
/** 当前状态是否允许编辑任务 */
allowEdit: boolean;
/** 当前登录用户可执行的生命周期动作与任务详情同口径auto_start 不返回),无动作为 [] */
availableActions: LifecycleAction<ProjectTaskActionCode>[];
}
/** 工作台「团队负载」分布子项kind != project 时 projectId / projectName 为 null */
interface TeamLoadDistributionItem {
projectId: string | null;
projectName: string | null;
/** project 项目任务 / personal 个人事项 / other 无法归类的残留 */
kind: 'project' | 'personal' | 'other';
/** 未完成任务数(含待开始/已暂停) */
count: number;
}
/** 工作台「团队负载」成员members[0] 恒为当前用户) */
interface TeamLoadMember {
/** 用户 ID字符串 */
userId: string;
userNickname: string;
/** 未完成任务按归属分布,无任务为 [] */
items: TeamLoadDistributionItem[];
/** 临期:今天 ≤ 计划结束 ≤ 今天+3 天,且未完成(与逾期互斥) */
dueSoonCount: number;
/** 逾期:计划结束 < 今天,且未完成 */
overdueCount: number;
}
/** 工作台「团队负载」响应GET /project/project/me/team-load团队 = 自己 + 管理链路直接下级) */
interface TeamLoadResult {
members: TeamLoadMember[];
}
/** 工作台工时分布子项kind != project 时 projectId / projectName 为 nullhours=0 的行后端不输出) */
interface WorklogDistributionItem {
projectId: string | null;
projectName: string | null;
kind: 'project' | 'personal' | 'other';
hours: number;
}
/** 工作台「我的工时周聚合」响应GET /project/project/me/worklog-week */
interface MyWorklogWeekResult {
/** 归一后的周一日期 YYYY-MM-DD */
weekStart: string;
/** 周一~周五逐日工时(固定 5 元素;均摊推算值,周末份额归周五) */
dailyHours: number[];
/** 本周工时按归属分布hours 降序 */
distribution: WorklogDistributionItem[];
}
/** 工作台「团队工时周聚合」成员members[0] 恒为当前用户;该周未填报成员 items 为 [] */
interface TeamWorklogWeekMember {
userId: string;
userNickname: string;
items: WorklogDistributionItem[];
}
/** 工作台「团队工时周聚合」响应GET /project/project/me/team-worklog-week周标准工时后端不返回前端落常量 35 */
interface TeamWorklogWeekResult {
weekStart: string;
members: TeamWorklogWeekMember[];
}
/** 工作台工时周聚合查询入参weekStart 传任意日期,后端归一到所在周周一) */
interface WorklogWeekParams {
weekStart: string;
}
/** 创建执行入参(含 ownerId + assigneeUserIds */ /** 创建执行入参(含 ownerId + assigneeUserIds */
interface CreateProjectExecutionParams { interface CreateProjectExecutionParams {
executionName: string; executionName: string;

View File

@@ -1,7 +1,5 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
export type WorkbenchTrend = 'up' | 'down' | 'flat';
export type WorkbenchTodoCategory = 'task' | 'ticket' | 'personal' | 'approval'; export type WorkbenchTodoCategory = 'task' | 'ticket' | 'personal' | 'approval';
export type WorkbenchTodoMainTab = 'all' | WorkbenchTodoCategory; export type WorkbenchTodoMainTab = 'all' | WorkbenchTodoCategory;
@@ -10,40 +8,6 @@ export type WorkbenchTodoDeadlineFilter = 'overdue' | 'today' | 'week' | null;
export type WorkbenchTodoPriority = 'high' | 'mid' | 'low'; export type WorkbenchTodoPriority = 'high' | 'mid' | 'low';
export interface WorkbenchKpiSource {
/** 待办 */
todo: {
count: number;
diffFromYesterday: number;
};
/** 我负责的任务 */
task: {
count: number;
diffFromYesterday: number;
};
/** 我参与的项目 */
project: {
count: number;
activeCount: number;
};
/** 我负责的需求 */
requirement: {
count: number;
pendingReview: number;
};
}
export interface WorkbenchKpiCard {
key: 'todo' | 'task' | 'project' | 'requirement';
label: string;
value: number;
trend: WorkbenchTrend;
trendText: string;
hint: string;
icon: string;
tone: 'sky' | 'emerald' | 'amber' | 'rose';
}
export interface WorkbenchTodoItemSource { export interface WorkbenchTodoItemSource {
id: string; id: string;
category: WorkbenchTodoCategory; category: WorkbenchTodoCategory;
@@ -54,12 +18,18 @@ export interface WorkbenchTodoItemSource {
deadline: string | null; deadline: string | null;
/** 来源(提交人/项目名/工单号) */ /** 来源(提交人/项目名/工单号) */
source: string; source: string;
/** 进度百分比,仅任务和个人事项使用 */
progressRate?: number | null;
/** 优先级,用于前端排序与高亮 */ /** 优先级,用于前端排序与高亮 */
priority?: WorkbenchTodoPriority; priority?: WorkbenchTodoPriority;
/** 优先级原始标签(字典 label如 P0~P3有值才渲染角标不做高/中/低翻译 */
priorityLabel?: string | null;
/** 是否逾期 */ /** 是否逾期 */
overdue?: boolean; overdue?: boolean;
/** 跳转路由 key可选未配则不跳转 */ /** 跳转路由 key可选未配则不跳转 */
routeKey?: string; routeKey?: string;
/** 任务条目携带:所属项目 ID点击带对象上下文跳进该项目的任务导航 */
projectId?: string;
/** 审批业务类型 */ /** 审批业务类型 */
approvalBizType?: 'overtime_application' | string; approvalBizType?: 'overtime_application' | string;
/** 审批业务 ID */ /** 审批业务 ID */
@@ -74,26 +44,6 @@ export interface WorkbenchTodoItem extends Omit<WorkbenchTodoItemSource, 'deadli
categoryTone: 'sky' | 'emerald' | 'amber' | 'rose' | 'violet'; categoryTone: 'sky' | 'emerald' | 'amber' | 'rose' | 'violet';
} }
export interface WorkbenchActivityItemSource {
id: string;
actor: string;
action: string;
/** 目标对象(需求/任务/工单/项目名等) */
target: string;
/** 目标对象的种类,用于颜色 */
targetKind: 'requirement' | 'task' | 'ticket' | 'project' | 'product';
/** 时间ISO */
time: string;
/** 是否 @ 了当前用户 */
mentioned?: boolean;
}
export interface WorkbenchActivityItem extends Omit<WorkbenchActivityItemSource, 'time'> {
timeLabel: string;
relativeLabel: string;
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'violet';
}
/** 「我参与的项目」展示项(由 Api.Project.MyParticipatedProjectItem 衍生) */ /** 「我参与的项目」展示项(由 Api.Project.MyParticipatedProjectItem 衍生) */
export interface WorkbenchParticipatedProjectView { export interface WorkbenchParticipatedProjectView {
id: string; id: string;
@@ -123,14 +73,6 @@ const todoPriorityWeight: Record<WorkbenchTodoPriority, number> = {
low: 2 low: 2
}; };
const activityToneMap: Record<WorkbenchActivityItemSource['targetKind'], WorkbenchActivityItem['tone']> = {
requirement: 'sky',
task: 'emerald',
ticket: 'amber',
project: 'violet',
product: 'rose'
};
/** 列表只含进行中项目;按已知状态编码上色,未知回退 sky */ /** 列表只含进行中项目;按已知状态编码上色,未知回退 sky */
function resolveParticipatedProjectTone(statusCode: string): 'sky' | 'emerald' | 'amber' { function resolveParticipatedProjectTone(statusCode: string): 'sky' | 'emerald' | 'amber' {
if (statusCode === 'active') return 'emerald'; if (statusCode === 'active') return 'emerald';
@@ -143,25 +85,6 @@ function clampPercent(value: number) {
return Math.min(100, Math.max(0, Math.round(value))); return Math.min(100, Math.max(0, Math.round(value)));
} }
function formatRelative(value: string) {
const target = dayjs(value);
if (!target.isValid()) return '--';
const now = dayjs();
const diffMinutes = now.diff(target, 'minute');
if (diffMinutes < 1) return '刚刚';
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
const diffHours = now.diff(target, 'hour');
if (diffHours < 24) return `${diffHours} 小时前`;
const diffDays = now.diff(target, 'day');
if (diffDays < 7) return `${diffDays} 天前`;
return target.format('MM-DD HH:mm');
}
function formatDeadline(value: string | null) { function formatDeadline(value: string | null) {
if (!value) return '不限'; if (!value) return '不限';
const target = dayjs(value); const target = dayjs(value);
@@ -187,60 +110,6 @@ function getRemainingDays(value: string | null) {
return target.startOf('day').diff(dayjs().startOf('day'), 'day'); return target.startOf('day').diff(dayjs().startOf('day'), 'day');
} }
export function buildWorkbenchKpiCards(source: WorkbenchKpiSource): WorkbenchKpiCard[] {
function diffTrend(diff: number): { trend: WorkbenchTrend; text: string } {
if (diff > 0) return { trend: 'up', text: `较昨日 +${diff}` };
if (diff < 0) return { trend: 'down', text: `较昨日 ${diff}` };
return { trend: 'flat', text: '与昨日持平' };
}
const todoTrend = diffTrend(source.todo.diffFromYesterday);
const taskTrend = diffTrend(source.task.diffFromYesterday);
return [
{
key: 'todo',
label: '我的待办',
value: source.todo.count,
trend: todoTrend.trend,
trendText: todoTrend.text,
hint: '需要我处理的评审、任务、工单与 @ 提醒合计',
icon: 'mdi:clipboard-text-clock-outline',
tone: 'sky'
},
{
key: 'task',
label: '我负责的任务',
value: source.task.count,
trend: taskTrend.trend,
trendText: taskTrend.text,
hint: '当前由我负责的进行中任务',
icon: 'mdi:checkbox-marked-circle-outline',
tone: 'emerald'
},
{
key: 'project',
label: '我参与的项目',
value: source.project.count,
trend: 'flat',
trendText: `进行中 ${source.project.activeCount}`,
hint: '我作为成员参与的项目总数',
icon: 'mdi:briefcase-outline',
tone: 'amber'
},
{
key: 'requirement',
label: '我负责的需求',
value: source.requirement.count,
trend: 'flat',
trendText: `待评审 ${source.requirement.pendingReview}`,
hint: '由我担任产品经理或负责人的需求',
icon: 'mdi:file-document-multiple-outline',
tone: 'rose'
}
];
}
export function buildWorkbenchTodoItems(source: readonly WorkbenchTodoItemSource[]): WorkbenchTodoItem[] { export function buildWorkbenchTodoItems(source: readonly WorkbenchTodoItemSource[]): WorkbenchTodoItem[] {
return [...source] return [...source]
.sort((left, right) => { .sort((left, right) => {
@@ -301,18 +170,6 @@ export function sortWorkbenchTodoItemsByPriority(
}); });
} }
export function buildWorkbenchActivityItems(source: readonly WorkbenchActivityItemSource[]): WorkbenchActivityItem[] {
return [...source]
.filter(item => dayjs(item.time).isValid())
.sort((left, right) => dayjs(right.time).valueOf() - dayjs(left.time).valueOf())
.map(item => ({
...item,
timeLabel: dayjs(item.time).format('YYYY-MM-DD HH:mm'),
relativeLabel: formatRelative(item.time),
tone: activityToneMap[item.targetKind]
}));
}
export function buildWorkbenchParticipatedProjects( export function buildWorkbenchParticipatedProjects(
source: readonly Api.Project.MyParticipatedProjectItem[] source: readonly Api.Project.MyParticipatedProjectItem[]
): WorkbenchParticipatedProjectView[] { ): WorkbenchParticipatedProjectView[] {
@@ -389,37 +246,16 @@ export interface WorkbenchWorklogDistributionItem {
projectId?: string; projectId?: string;
} }
/** 单周工时数据源(按天填 / 按周填两类共存 */ /** 周标准工时:系统无配置项,后端不返回,产品确认前端落常量 35不是 40 */
export interface WorkbenchWeekWorklogSource { export const WORKBENCH_WEEK_TARGET_HOURS = 35;
/** ISO 周首日周一YYYY-MM-DD */
weekStart: string;
/** 整周一笔填的工时总和(将按工作日 5 等分均摊到周一~周五) */
weeklyFilledHours: number;
/** 周一~周五按天填的工时,长度恒为 5 */
dailyHours: [number, number, number, number, number];
/** 工时分布;前端不再做截断,超出 5 行靠容器滚动 */
distribution: WorkbenchWorklogDistributionItem[];
/** 周目标小时数(默认 40 */
target: number;
}
/** 周工时 mock 容器:本周 + 上周 */ /** 周工时视图builder 衍生;逐日工时为后端按填报日期段均摊到工作日的推算值) */
export interface WorkbenchMyWeekWorklogSource {
current: WorkbenchWeekWorklogSource;
previous: WorkbenchWeekWorklogSource;
}
/** 单周工时视图builder 衍生) */
export interface WorkbenchWeekWorklogView { export interface WorkbenchWeekWorklogView {
weekStart: string; weekStart: string;
weekLabel: string; weekLabel: string;
/** 周一~周五按天填部分5 长度 */ /** 周一~周五逐日工时5 长度,均摊推算值,周末份额归周五 */
dailyByDay: number[]; dailyHours: number[];
/** 周一~周五按周均分部分5 长度,每项 = weeklyFilledHours / 5 */ /** 本周累计 */
dailyByWeekAvg: number[];
/** 周一~周五合计5 长度dailyByDay + dailyByWeekAvg */
dailyTotal: number[];
/** 累计(含按周) */
totalHours: number; totalHours: number;
target: number; target: number;
/** 累计 - 目标;正=领先、负=落后 */ /** 累计 - 目标;正=领先、负=落后 */
@@ -435,29 +271,37 @@ function roundHours(value: number) {
return Math.round(value * 10) / 10; return Math.round(value * 10) / 10;
} }
export function buildWorkbenchWeekWorklogView(source: WorkbenchWeekWorklogSource): WorkbenchWeekWorklogView { /** 契约分布项 → 展示行项key/label 归一kind != project 时用固定字面量) */
function toWorklogDistributionItem(item: Api.Project.WorklogDistributionItem): WorkbenchWorklogDistributionItem {
const isProject = item.kind === 'project' && Boolean(item.projectId);
return {
key: isProject ? (item.projectId as string) : item.kind,
label: item.projectName ?? (item.kind === 'personal' ? '个人事项' : '其他'),
hours: roundHours(item.hours),
kind: item.kind,
projectId: isProject ? (item.projectId as string) : undefined
};
}
export function buildWorkbenchWeekWorklogView(source: Api.Project.MyWorklogWeekResult): WorkbenchWeekWorklogView {
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 weekAvg = roundHours(source.weeklyFilledHours / WEEKDAY_COUNT); const dailyHours = Array.from({ length: WEEKDAY_COUNT }, (_, i) => roundHours(source.dailyHours[i] ?? 0));
const dailyByWeekAvg = Array.from({ length: WEEKDAY_COUNT }, () => weekAvg); const totalHours = roundHours(dailyHours.reduce((s, h) => s + h, 0));
const dailyByDay = source.dailyHours.map(roundHours); const target = WORKBENCH_WEEK_TARGET_HOURS;
const dailyTotal = dailyByDay.map((h, i) => roundHours(h + dailyByWeekAvg[i])); const delta = roundHours(totalHours - target);
const totalHours = roundHours(dailyTotal.reduce((s, h) => s + h, 0)); const completionRate = target > 0 ? clampPercent((totalHours / target) * 100) : 0;
const delta = roundHours(totalHours - source.target);
const completionRate = source.target > 0 ? clampPercent((totalHours / source.target) * 100) : 0;
return { return {
weekStart: source.weekStart, weekStart: source.weekStart,
weekLabel, weekLabel,
dailyByDay, dailyHours,
dailyByWeekAvg,
dailyTotal,
totalHours, totalHours,
target: source.target, target,
delta, delta,
completionRate, completionRate,
distribution: source.distribution distribution: source.distribution.map(toWorklogDistributionItem)
}; };
} }
@@ -470,31 +314,10 @@ export function getGreeting(hour: number = dayjs().hour()) {
return '夜深了'; return '夜深了';
} }
// === 团队工时分布C12 · teamWorklog === // === 团队工时分布(D16 团队 tabC12 teamWorklog ===
export type WorkbenchTeamWorklogItemKind = 'project' | 'personal' | 'other'; export type WorkbenchTeamWorklogItemKind = 'project' | 'personal' | 'other';
export interface WorkbenchTeamWorklogItem {
/** 项目 id 或 'personal' / 'other' */
key: string;
label: string;
hours: number;
kind: WorkbenchTeamWorklogItemKind;
}
export interface WorkbenchTeamWorklogMemberSource {
memberId: string;
memberName: string;
items: WorkbenchTeamWorklogItem[];
}
export interface WorkbenchTeamWorklogSource {
weekStart: string;
/** 单人周应填工时(用于填报率口径) */
expectedHoursPerMember: number;
members: WorkbenchTeamWorklogMemberSource[];
}
/** 团队工时分布视图builder 衍生) */ /** 团队工时分布视图builder 衍生) */
export interface WorkbenchTeamWorklogView { export interface WorkbenchTeamWorklogView {
weekStart: string; weekStart: string;
@@ -512,27 +335,36 @@ export interface WorkbenchTeamWorklogView {
totalHours: number; totalHours: number;
/** 团队人均工时 */ /** 团队人均工时 */
averageHours: number; averageHours: number;
/** 应填工时合计(人数 × expectedHoursPerMember */ /** 应填工时合计(人数 × WORKBENCH_WEEK_TARGET_HOURS */
expectedTotalHours: number; expectedTotalHours: number;
/** 填报率 0-100 整数(= 总工时 / 应填工时) */ /** 填报率 0-100 整数(= 总工时 / 应填工时) */
fillRate: number; fillRate: number;
/** 偏低人数:< 团队均值 × 0.8 */ /** 偏低人数:< 团队均值 × 0.8 */
lowCount: number; lowCount: number;
/** 加班人数:> 45h应填 × 1.125 */ /** 加班人数:> 周标准 × 1.125 */
highCount: number; highCount: number;
/** 加班判定阈值小时KPI 文案展示用 */
overtimeThreshold: number;
/** 工时最低成员(用于底部小结) */ /** 工时最低成员(用于底部小结) */
lowest: { memberName: string; hours: number } | null; lowest: { memberName: string; hours: number } | null;
/** 工时最高成员(用于底部小结) */ /** 工时最高成员(用于底部小结) */
highest: { memberName: string; hours: number } | null; highest: { memberName: string; hours: number } | null;
} }
export function buildWorkbenchTeamWorklogView(source: WorkbenchTeamWorklogSource): WorkbenchTeamWorklogView { export function buildWorkbenchTeamWorklogView(source: Api.Project.TeamWorklogWeekResult): WorkbenchTeamWorklogView {
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;
// 契约members[0] 恒为当前用户,展示加「(我)」后缀
const members = source.members.map((member, index) => ({
memberId: member.userId,
memberName: index === 0 ? `${member.userNickname}(我)` : member.userNickname,
items: member.items.map(toWorklogDistributionItem)
}));
// 列保序去重:按成员遍历顺序首次出现即入列;项目优先、个人/其他按出现顺序追加 // 列保序去重:按成员遍历顺序首次出现即入列;项目优先、个人/其他按出现顺序追加
const categoryMap = new Map<string, { key: string; label: string; kind: WorkbenchTeamWorklogItemKind }>(); const categoryMap = new Map<string, { key: string; label: string; kind: WorkbenchTeamWorklogItemKind }>();
for (const m of source.members) { for (const m of members) {
for (const it of m.items) { for (const it of m.items) {
if (!categoryMap.has(it.key)) { if (!categoryMap.has(it.key)) {
categoryMap.set(it.key, { key: it.key, label: it.label, kind: it.kind }); categoryMap.set(it.key, { key: it.key, label: it.label, kind: it.kind });
@@ -541,7 +373,7 @@ export function buildWorkbenchTeamWorklogView(source: WorkbenchTeamWorklogSource
} }
const categories = Array.from(categoryMap.values()); const categories = Array.from(categoryMap.values());
const memberView = source.members.map(m => ({ const memberView = members.map(m => ({
memberId: m.memberId, memberId: m.memberId,
memberName: m.memberName, memberName: m.memberName,
totalHours: roundHours(m.items.reduce((s, it) => s + it.hours, 0)) totalHours: roundHours(m.items.reduce((s, it) => s + it.hours, 0))
@@ -549,7 +381,7 @@ export function buildWorkbenchTeamWorklogView(source: WorkbenchTeamWorklogSource
const seriesMatrix = categories.map(cat => ({ const seriesMatrix = categories.map(cat => ({
...cat, ...cat,
data: source.members.map(m => { data: members.map(m => {
const hit = m.items.find(it => it.key === cat.key); const hit = m.items.find(it => it.key === cat.key);
return hit ? roundHours(hit.hours) : 0; return hit ? roundHours(hit.hours) : 0;
}) })
@@ -558,11 +390,11 @@ export function buildWorkbenchTeamWorklogView(source: WorkbenchTeamWorklogSource
const totalHours = roundHours(memberView.reduce((s, m) => s + m.totalHours, 0)); const totalHours = roundHours(memberView.reduce((s, m) => s + m.totalHours, 0));
const memberCount = memberView.length; const memberCount = memberView.length;
const averageHours = memberCount > 0 ? roundHours(totalHours / memberCount) : 0; const averageHours = memberCount > 0 ? roundHours(totalHours / memberCount) : 0;
const expectedTotalHours = memberCount * source.expectedHoursPerMember; 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 = source.expectedHoursPerMember * 1.125; const highThreshold = WORKBENCH_WEEK_TARGET_HOURS * 1.125;
const lowCount = memberView.filter(m => m.totalHours < lowThreshold).length; const lowCount = memberView.filter(m => m.totalHours < lowThreshold).length;
const highCount = memberView.filter(m => m.totalHours > highThreshold).length; const highCount = memberView.filter(m => m.totalHours > highThreshold).length;
@@ -585,6 +417,7 @@ export function buildWorkbenchTeamWorklogView(source: WorkbenchTeamWorklogSource
fillRate, fillRate,
lowCount, lowCount,
highCount, highCount,
overtimeThreshold: roundHours(highThreshold),
lowest, lowest,
highest highest
}; };
@@ -594,31 +427,29 @@ export function buildWorkbenchTeamWorklogView(source: WorkbenchTeamWorklogSource
export type WorkbenchTeamLoadLevel = 'high' | 'mid' | 'normal'; export type WorkbenchTeamLoadLevel = 'high' | 'mid' | 'normal';
export type WorkbenchTeamLoadItemKind = 'project' | 'personal'; export type WorkbenchTeamLoadItemKind = 'project' | 'personal' | 'other';
export interface WorkbenchTeamLoadItemSource { export interface WorkbenchTeamLoadItemSource {
/** projectId 或 'personal' */ /** projectId 或 'personal' / 'other' */
key: string; key: string;
label: string; label: string;
kind: WorkbenchTeamLoadItemKind; kind: WorkbenchTeamLoadItemKind;
/** 该项目/个人事项下,该成员进行中的任务/事项数(任务按"负责人/协办人"口径,单任务多人各算 1 条) */ /** 该项目/个人事项下,该成员未完成的任务/事项数(含待开始/已暂停;任务按"负责人/协办人"口径,单任务多人各算 1 条) */
count: number; count: number;
} }
export interface WorkbenchTeamLoadMemberSource { export interface WorkbenchTeamLoadMemberSource {
memberId: string; memberId: string;
memberName: string; memberName: string;
/** 进行中按项目 + 个人事项的拆分(合计即"进行中总数" */ /** 未完成数按项目 + 个人事项的拆分(合计即"未完成总数" */
items: WorkbenchTeamLoadItemSource[]; items: WorkbenchTeamLoadItemSource[];
/** 今天 ≤ 计划结束 ≤ 今天+3 天 且未完成 */ /** 今天 ≤ 计划结束 ≤ 今天+3 天 且未完成(与逾期互斥) */
dueSoon: number; dueSoon: number;
/** 计划结束 < 今天 且未完成 */ /** 计划结束 < 今天 且未完成 */
overdue: number; overdue: number;
} }
export interface WorkbenchTeamLoadSource { export interface WorkbenchTeamLoadSource {
/** 数据快照所属周(与 C12 对齐口径) */
weekStart: string;
members: WorkbenchTeamLoadMemberSource[]; members: WorkbenchTeamLoadMemberSource[];
} }
@@ -628,7 +459,7 @@ export interface WorkbenchTeamLoadSegment extends WorkbenchTeamLoadItemSource {
} }
export interface WorkbenchTeamLoadMember extends Omit<WorkbenchTeamLoadMemberSource, 'items'> { export interface WorkbenchTeamLoadMember extends Omit<WorkbenchTeamLoadMemberSource, 'items'> {
/** items 合计 */ /** items 合计(未完成总数) */
inProgress: number; inProgress: number;
/** dueSoon + overdue */ /** dueSoon + overdue */
urgent: number; urgent: number;
@@ -642,7 +473,6 @@ export interface WorkbenchTeamLoadMember extends Omit<WorkbenchTeamLoadMemberSou
} }
export interface WorkbenchTeamLoadView { export interface WorkbenchTeamLoadView {
weekStart: string;
/** 已按 level → inProgress desc 排序,高负载置顶 */ /** 已按 level → inProgress desc 排序,高负载置顶 */
members: WorkbenchTeamLoadMember[]; members: WorkbenchTeamLoadMember[];
/** 柱子量程上限(固定值,避免某个成员极端值把全员压扁) */ /** 柱子量程上限(固定值,避免某个成员极端值把全员压扁) */
@@ -709,7 +539,6 @@ export function buildWorkbenchTeamLoadView(source: WorkbenchTeamLoadSource): Wor
const urgentTotal = enriched.reduce((s, m) => s + m.urgent, 0); const urgentTotal = enriched.reduce((s, m) => s + m.urgent, 0);
return { return {
weekStart: source.weekStart,
members: enriched, members: enriched,
scaleMax, scaleMax,
highCount, highCount,
@@ -741,19 +570,6 @@ export function buildWorkbenchProjectHealthCards(
return source.map(s => ({ ...s, healthLabel: labelMap[s.health] })); return source.map(s => ({ ...s, healthLabel: labelMap[s.health] }));
} }
export interface WorkbenchProgressBarSource {
projectId: string;
projectName: string;
/** 完成率 0-100 */
weekCompletionRate: number;
}
export type WorkbenchProgressBar = WorkbenchProgressBarSource;
export function buildWorkbenchProgressBars(source: readonly WorkbenchProgressBarSource[]): WorkbenchProgressBar[] {
return source.map(s => ({ ...s, weekCompletionRate: Math.min(100, Math.max(0, Math.round(s.weekCompletionRate))) }));
}
/** /**
* 前端兜底过滤:剔除已完成 / 已取消 / 进度满 100 的执行(默认不在工作台呈现)。 * 前端兜底过滤:剔除已完成 / 已取消 / 进度满 100 的执行(默认不在工作台呈现)。
* 后端接口已按此口径过滤,此处为双保险;泛型保证接口返回类型可复用。 * 后端接口已按此口径过滤,此处为双保险;泛型保证接口返回类型可复用。

View File

@@ -1,423 +1,7 @@
import dayjs from 'dayjs'; import type { WorkbenchProjectHealthCardSource } from './homepage';
import type {
WorkbenchActivityItemSource,
WorkbenchKpiSource,
WorkbenchMyWeekWorklogSource,
WorkbenchProgressBarSource,
WorkbenchProjectHealthCardSource,
WorkbenchTeamLoadSource,
WorkbenchTeamWorklogSource,
WorkbenchTodoItemSource
} from './homepage';
const now = dayjs();
const iso = (date: dayjs.Dayjs) => date.format('YYYY-MM-DD HH:mm:ss');
export const workbenchKpiMock = {
todo: { count: 8, diffFromYesterday: 1 },
task: { count: 14, diffFromYesterday: -1 },
project: { count: 3, activeCount: 2 },
requirement: { count: 6, pendingReview: 2 }
} satisfies WorkbenchKpiSource;
export const workbenchTodoMock = [
{
id: 'todo-1',
category: 'task',
title: '支付回调接口联调遗留问题处理',
createdTime: iso(now.subtract(7, 'day').hour(9).minute(20)),
deadline: iso(now.subtract(1, 'day').hour(17).minute(30)),
source: '收银台 V3 · 后端联调',
priority: 'high',
overdue: true,
routeKey: 'project_list'
},
{
id: 'todo-2',
category: 'ticket',
title: '工单 #1018 用户登录异常',
createdTime: iso(now.subtract(6, 'day').hour(11).minute(0)),
deadline: iso(now.subtract(2, 'day').hour(10).minute(0)),
source: '客户支持 · 紧急',
priority: 'mid',
overdue: true,
routeKey: 'ticket'
},
{
id: 'todo-3',
category: 'approval',
title: '李四 · 第 21 周周报待审批',
createdTime: iso(now.subtract(2, 'day').hour(18).minute(20)),
deadline: null,
source: '周报 · 产品组',
priority: 'mid',
routeKey: 'workbench'
},
{
id: 'todo-4',
category: 'personal',
title: '提交昨日工时与本周计划',
createdTime: iso(now.subtract(4, 'day').hour(8).minute(30)),
deadline: iso(now.hour(18).minute(0)),
source: '个人事项 · 工时',
priority: 'mid',
routeKey: 'personal-center_my-item'
},
{
id: 'todo-5',
category: 'task',
title: '会员等级提示文案最终校对',
createdTime: iso(now.subtract(3, 'day').hour(10).minute(10)),
deadline: iso(now.hour(20).minute(0)),
source: '会员中心 · 文案',
priority: 'high',
routeKey: 'project_list'
},
{
id: 'todo-6',
category: 'ticket',
title: '工单 #1024 客户反馈待处理',
createdTime: iso(now.subtract(3, 'day').hour(15).minute(0)),
deadline: iso(now.add(2, 'day').hour(17).minute(0)),
source: '王五 · 客户反馈',
priority: 'mid',
routeKey: 'ticket'
},
{
id: 'todo-7',
category: 'personal',
title: '复盘上周交付遗留并归档',
createdTime: iso(now.subtract(2, 'day').hour(9).minute(0)),
deadline: iso(now.add(4, 'day').hour(18).minute(0)),
source: '个人事项 · 复盘',
priority: 'low',
routeKey: 'personal-center_my-item'
},
{
id: 'todo-8',
category: 'task',
title: '订单中心结算文档评审',
createdTime: iso(now.subtract(2, 'day').hour(13).minute(20)),
deadline: iso(now.add(5, 'day').hour(15).minute(0)),
source: '订单中心 · 文档',
priority: 'mid',
routeKey: 'project_list'
},
{
id: 'todo-9',
category: 'approval',
title: '张三 · 5 月加班 12h 申请待审批',
createdTime: iso(now.subtract(1, 'day').hour(17).minute(0)),
deadline: null,
source: '加班申请 · 研发组',
priority: 'mid',
routeKey: 'workbench'
},
{
id: 'todo-10',
category: 'personal',
title: '安排下周外出培训行程',
createdTime: iso(now.subtract(1, 'day').hour(19).minute(40)),
deadline: iso(now.add(10, 'day').hour(12).minute(0)),
source: '个人事项 · 行程',
priority: 'low',
routeKey: 'personal-center_my-item'
},
{
id: 'todo-11',
category: 'task',
title: '会员中心首页骨架屏改造',
createdTime: iso(now.subtract(20, 'hour')),
deadline: iso(now.add(12, 'day').hour(18).minute(0)),
source: '会员中心 · 前端',
priority: 'low',
routeKey: 'project_list'
},
{
id: 'todo-12',
category: 'ticket',
title: '工单 #1031 提示信息文案优化',
createdTime: iso(now.subtract(8, 'hour')),
deadline: iso(now.add(14, 'day').hour(17).minute(0)),
source: '客户支持 · 普通',
priority: 'low',
routeKey: 'ticket'
}
] satisfies WorkbenchTodoItemSource[];
export const workbenchActivityMock = [
{
id: 'act-1',
actor: '张三',
action: '提交了需求评审申请',
target: '订单导出 V2',
targetKind: 'requirement',
time: iso(now.subtract(10, 'minute'))
},
{
id: 'act-2',
actor: '李四',
action: '完成了任务',
target: '支付回调接口联调',
targetKind: 'task',
time: iso(now.subtract(2, 'hour'))
},
{
id: 'act-3',
actor: '李四',
action: '在需求中 @ 了你',
target: '会员等级',
targetKind: 'requirement',
time: iso(now.subtract(1, 'day').hour(17).minute(23)),
mentioned: true
},
{
id: 'act-4',
actor: '王五',
action: '提交了工单',
target: '#1024 · 客户反馈',
targetKind: 'ticket',
time: iso(now.subtract(1, 'day').hour(15).minute(8))
},
{
id: 'act-5',
actor: '赵六',
action: '把项目状态调整为',
target: '试运行(订单中心)',
targetKind: 'project',
time: iso(now.subtract(2, 'day').hour(10).minute(0))
},
{
id: 'act-6',
actor: '系统',
action: '提醒:你有 1 项任务即将逾期',
target: 'API 返回结构调整评审',
targetKind: 'requirement',
time: iso(now.subtract(3, 'day').hour(9).minute(30))
},
{
id: 'act-7',
actor: '钱七',
action: '更新了产品资料',
target: '收银台 V3 · 定位说明',
targetKind: 'product',
time: iso(now.subtract(4, 'day').hour(16).minute(45))
}
] satisfies WorkbenchActivityItemSource[];
const currentWeekStart = now.startOf('isoWeek').format('YYYY-MM-DD');
const previousWeekStart = now.subtract(1, 'week').startOf('isoWeek').format('YYYY-MM-DD');
export const workbenchMyWeekWorklogMock = {
current: {
weekStart: currentWeekStart,
weeklyFilledHours: 5,
dailyHours: [4, 7, 6, 8, 7.5],
target: 40,
distribution: [
{ key: 'prj-mall-v2', label: '商城 V2 升级', hours: 12.5, kind: 'project', projectId: 'prj-mall-v2' },
{ key: 'prj-risk', label: '风控引擎接入', hours: 8, kind: 'project', projectId: 'prj-risk' },
{ key: 'prj-cashier', label: '收银台 V3', hours: 6, kind: 'project', projectId: 'prj-cashier' },
{ key: 'prj-order', label: '订单中心', hours: 5, kind: 'project', projectId: 'prj-order' },
{ key: 'prj-member', label: '会员中心', hours: 3, kind: 'project', projectId: 'prj-member' },
{ key: 'prj-marketing', label: '营销中台', hours: 2, kind: 'project', projectId: 'prj-marketing' },
{ key: 'personal', label: '个人事项', hours: 4, kind: 'personal' },
{ key: 'other', label: '其他', hours: 2, kind: 'other' }
]
},
previous: {
weekStart: previousWeekStart,
weeklyFilledHours: 0,
dailyHours: [8, 8, 7, 8, 7],
target: 40,
distribution: [
{ key: 'prj-mall-v2', label: '商城 V2 升级', hours: 15, kind: 'project', projectId: 'prj-mall-v2' },
{ key: 'prj-risk', label: '风控引擎接入', hours: 9, kind: 'project', projectId: 'prj-risk' },
{ key: 'prj-cashier', label: '收银台 V3', hours: 7, kind: 'project', projectId: 'prj-cashier' },
{ key: 'personal', label: '个人事项', hours: 5, kind: 'personal' },
{ key: 'other', label: '其他', hours: 2, kind: 'other' }
]
}
} satisfies WorkbenchMyWeekWorklogSource;
// 团队工时口径约定:「团队 = 当前用户所在团队,含自己」。
// 后端 /workbench/team-worklog 接口返回的 members 必须包含当前用户自己——
// 这样没有下级的人普通员工切到「团队工时」tab 也至少有自己这一条数据,
// 不会出现空白态。约定 members[0] = 当前用户UI 用「(我)」后缀标识。
export const workbenchTeamWorklogMock = {
current: {
weekStart: currentWeekStart,
expectedHoursPerMember: 40,
members: [
{
memberId: 'u-1',
memberName: '张三(我)',
items: [
{ key: 'prj-cashier', label: '收银台 V3', hours: 22, kind: 'project' },
{ key: 'prj-order', label: '订单中心', hours: 10, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 4, kind: 'personal' },
{ key: 'other', label: '其他', hours: 2, kind: 'other' }
]
},
{
memberId: 'u-2',
memberName: '李四',
items: [
{ key: 'prj-cashier', label: '收银台 V3', hours: 18, kind: 'project' },
{ key: 'prj-member', label: '会员中心', hours: 20, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 3, kind: 'personal' },
{ key: 'other', label: '其他', hours: 1, kind: 'other' }
]
},
{
memberId: 'u-3',
memberName: '王五',
items: [
{ key: 'prj-member', label: '会员中心', hours: 14, kind: 'project' },
{ key: 'prj-order', label: '订单中心', hours: 12, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 2, kind: 'personal' },
{ key: 'other', label: '其他', hours: 2, kind: 'other' }
]
},
{
memberId: 'u-4',
memberName: '赵六',
items: [
{ key: 'prj-cashier', label: '收银台 V3', hours: 24, kind: 'project' },
{ key: 'prj-order', label: '订单中心', hours: 18, kind: 'project' },
{ key: 'prj-member', label: '会员中心', hours: 4, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 2, kind: 'personal' }
]
},
{
memberId: 'u-5',
memberName: '钱七',
items: [
{ key: 'prj-order', label: '订单中心', hours: 14, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 6, kind: 'personal' },
{ key: 'other', label: '其他', hours: 5, kind: 'other' }
]
}
]
},
previous: {
weekStart: previousWeekStart,
expectedHoursPerMember: 40,
members: [
{
memberId: 'u-1',
memberName: '张三(我)',
items: [
{ key: 'prj-cashier', label: '收银台 V3', hours: 26, kind: 'project' },
{ key: 'prj-order', label: '订单中心', hours: 8, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 3, kind: 'personal' },
{ key: 'other', label: '其他', hours: 3, kind: 'other' }
]
},
{
memberId: 'u-2',
memberName: '李四',
items: [
{ key: 'prj-cashier', label: '收银台 V3', hours: 15, kind: 'project' },
{ key: 'prj-member', label: '会员中心', hours: 22, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 4, kind: 'personal' }
]
},
{
memberId: 'u-3',
memberName: '王五',
items: [
{ key: 'prj-member', label: '会员中心', hours: 16, kind: 'project' },
{ key: 'prj-order', label: '订单中心', hours: 10, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 5, kind: 'personal' },
{ key: 'other', label: '其他', hours: 3, kind: 'other' }
]
},
{
memberId: 'u-4',
memberName: '赵六',
items: [
{ key: 'prj-cashier', label: '收银台 V3', hours: 20, kind: 'project' },
{ key: 'prj-order', label: '订单中心', hours: 16, kind: 'project' },
{ key: 'prj-member', label: '会员中心', hours: 6, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 3, kind: 'personal' }
]
},
{
memberId: 'u-5',
memberName: '钱七',
items: [
{ key: 'prj-order', label: '订单中心', hours: 16, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 4, kind: 'personal' },
{ key: 'other', label: '其他', hours: 4, kind: 'other' }
]
}
]
}
} satisfies { current: WorkbenchTeamWorklogSource; previous: WorkbenchTeamWorklogSource };
// 项目 key 与 workbenchTeamWorklogMock 对齐,保证跨 widget 项目色一致
export const workbenchTeamLoadMock = {
weekStart: now.startOf('isoWeek').format('YYYY-MM-DD'),
members: [
// 高负载:进行中 7 或 临期+逾期 ≥ 2
{
memberId: 'u-1',
memberName: '张三',
items: [
{ key: 'prj-cashier', label: '收银台 V3', kind: 'project', count: 4 },
{ key: 'prj-order', label: '订单中心', kind: 'project', count: 2 },
{ key: 'personal', label: '个人事项', kind: 'personal', count: 1 }
],
dueSoon: 2,
overdue: 1
},
{
memberId: 'u-4',
memberName: '赵六',
items: [
{ key: 'prj-cashier', label: '收银台 V3', kind: 'project', count: 2 },
{ key: 'prj-order', label: '订单中心', kind: 'project', count: 2 },
{ key: 'prj-member', label: '会员中心', kind: 'project', count: 1 }
],
dueSoon: 1,
overdue: 2
},
// 中负载:进行中 ≥ 4 或 临期+逾期 ≥ 1
{
memberId: 'u-2',
memberName: '李四',
items: [
{ key: 'prj-cashier', label: '收银台 V3', kind: 'project', count: 2 },
{ key: 'prj-member', label: '会员中心', kind: 'project', count: 2 }
],
dueSoon: 1,
overdue: 0
},
{
memberId: 'u-3',
memberName: '王五',
items: [
{ key: 'prj-member', label: '会员中心', kind: 'project', count: 2 },
{ key: 'prj-order', label: '订单中心', kind: 'project', count: 1 }
],
dueSoon: 1,
overdue: 0
},
// 正常
{
memberId: 'u-5',
memberName: '钱七',
items: [
{ key: 'prj-order', label: '订单中心', kind: 'project', count: 1 },
{ key: 'personal', label: '个人事项', kind: 'personal', count: 1 }
],
dueSoon: 0,
overdue: 0
}
]
} satisfies WorkbenchTeamLoadSource;
// projectHealth 为「演示态保留」widgetRDMS 业务暂无风险登记 / 计划基线流程,不接真实数据),
// 本 mock 是其唯一数据源;其余 widget 已全部接通真实接口,对应 mock 已清除。
export const workbenchProjectHealthMock = [ export const workbenchProjectHealthMock = [
{ {
projectId: 'prj-1', projectId: 'prj-1',
@@ -447,9 +31,3 @@ export const workbenchProjectHealthMock = [
backlogRequirements: 6 backlogRequirements: 6
} }
] satisfies WorkbenchProjectHealthCardSource[]; ] satisfies WorkbenchProjectHealthCardSource[];
export const workbenchProgressChartMock = [
{ projectId: 'prj-1', projectName: '收银台 V3', weekCompletionRate: 78 },
{ projectId: 'prj-2', projectName: '会员中心', weekCompletionRate: 62 },
{ projectId: 'prj-3', projectName: '订单中心', weekCompletionRate: 45 }
] satisfies WorkbenchProgressBarSource[];

View File

@@ -1,19 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, onActivated, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context'; import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetMyWorklogWeek, fetchGetTeamWorklogWeek } from '@/service/api';
import { type ECOption, useEcharts } from '@/hooks/common/echarts'; import { type ECOption, useEcharts } from '@/hooks/common/echarts';
import { getWorkbenchItemColor } from '../composables/use-workbench-colors'; import { getWorkbenchItemColor } from '../composables/use-workbench-colors';
import { import {
type WorkbenchTeamWorklogView,
type WorkbenchWeekWorklogView,
type WorkbenchWorklogDistributionItem, type WorkbenchWorklogDistributionItem,
buildWorkbenchTeamWorklogView, buildWorkbenchTeamWorklogView,
buildWorkbenchWeekWorklogView buildWorkbenchWeekWorklogView
} from '../homepage'; } from '../homepage';
import { workbenchMyWeekWorklogMock, workbenchTeamWorklogMock } from '../mock';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyWeekWorklog' }); defineOptions({ name: 'WorkbenchMyWeekWorklog' });
@@ -26,7 +23,8 @@ defineEmits<{ (e: 'hide'): void }>();
const router = useRouter(); const router = useRouter();
const { loading, refresh } = useWorkbenchRefresh(); const myWeekData = ref<Api.Project.MyWorklogWeekResult | null>(null);
const teamWeekData = ref<Api.Project.TeamWorklogWeekResult | null>(null);
// EP type='week' 默认 firstDayOfWeek=7从日历点选时返回当周"周日"。 // EP type='week' 默认 firstDayOfWeek=7从日历点选时返回当周"周日"。
// 我们按 ISO 周(周一-周日)存储;遇到周日 +1 天再 startOf('isoWeek'),避免回退到上一周。 // 我们按 ISO 周(周一-周日)存储;遇到周日 +1 天再 startOf('isoWeek'),避免回退到上一周。
@@ -50,25 +48,68 @@ const selectedWeekStart = computed(() => {
return aligned ? aligned.format('YYYY-MM-DD') : ''; return aligned ? aligned.format('YYYY-MM-DD') : '';
}); });
// 周切换必须重拉,不能被"并发守卫"拦掉,故不走 useWorkbenchRefresh
// 自管 loading + 请求序号,旧响应(慢请求落后于新一次切周)直接丢弃
const loading = ref(false);
let requestSeq = 0;
async function loadWorklogWeek() {
const weekStart = selectedWeekStart.value;
if (!weekStart) return;
requestSeq += 1;
const seq = requestSeq;
loading.value = true;
try {
const [myResult, teamResult] = await Promise.all([
fetchGetMyWorklogWeek({ weekStart }),
fetchGetTeamWorklogWeek({ weekStart })
]);
if (seq !== requestSeq) return;
myWeekData.value = myResult.error || !myResult.data ? null : myResult.data;
teamWeekData.value = teamResult.error || !teamResult.data ? null : teamResult.data;
} finally {
if (seq === requestSeq) {
loading.value = false;
}
}
}
function refresh() {
loadWorklogWeek();
}
type TabKey = 'my' | 'team'; type TabKey = 'my' | 'team';
const activeTab = ref<TabKey>('my'); const activeTab = ref<TabKey>('my');
// ============ 我的工时 ============ // 周切换(含初始)拉取两 tab 数据;竞态由 loadWorklogWeek 内请求序号兜底
watch(selectedWeekStart, loadWorklogWeek, { immediate: true });
const myView = computed<WorkbenchWeekWorklogView | null>(() => { // 工作台路由 keepAlive切回时组件不重挂载immediate watch 不再触发。
if (!selectedWeekStart.value) return null; // 每次激活归位到当前周并重拉;首次激活与挂载同拍(上面 immediate 已拉过),跳过避免双发
if (selectedWeekStart.value === workbenchMyWeekWorklogMock.current.weekStart) { let activatedOnce = false;
return buildWorkbenchWeekWorklogView(workbenchMyWeekWorklogMock.current); onActivated(() => {
if (!activatedOnce) {
activatedOnce = true;
return;
} }
if (selectedWeekStart.value === workbenchMyWeekWorklogMock.previous.weekStart) { const currentWeekDate = dayjs().startOf('isoWeek');
return buildWorkbenchWeekWorklogView(workbenchMyWeekWorklogMock.previous); if (selectedWeekStart.value === currentWeekDate.format('YYYY-MM-DD')) {
// 周未变时 watch 不会触发,手动重拉取最新填报
loadWorklogWeek();
} else {
selectedWeekDate.value = currentWeekDate.toDate();
} }
return null;
}); });
const isCurrentWeek = computed(() => selectedWeekStart.value === workbenchMyWeekWorklogMock.current.weekStart); // ============ 我的工时 ============
const totalLabel = computed(() => (isCurrentWeek.value ? '累计' : '上周累计')); const myView = computed(() => (myWeekData.value ? buildWorkbenchWeekWorklogView(myWeekData.value) : null));
const isCurrentWeek = computed(() => selectedWeekStart.value === dayjs().startOf('isoWeek').format('YYYY-MM-DD'));
const totalLabel = computed(() => (isCurrentWeek.value ? '累计' : '该周累计'));
const deltaInfo = computed(() => { const deltaInfo = computed(() => {
if (!myView.value) return null; if (!myView.value) return null;
@@ -80,9 +121,7 @@ const deltaInfo = computed(() => {
return { text: `达成 ${completionRate}%`, tone: completionRate >= 100 ? ('success' as const) : ('muted' as const) }; return { text: `达成 ${completionRate}%`, tone: completionRate >= 100 ? ('success' as const) : ('muted' as const) };
}); });
// 每日柱图的"按天/按周"分色(与项目色无关,保留本地常量)
const DAY_BAR_COLOR = '#409EFF'; const DAY_BAR_COLOR = '#409EFF';
const WEEK_BAR_COLOR = '#A0CFFF';
interface DistributionRow extends WorkbenchWorklogDistributionItem { interface DistributionRow extends WorkbenchWorklogDistributionItem {
color: string; color: string;
@@ -155,10 +194,8 @@ function buildMyBarOption(): ECOption {
formatter: (rawParams: any) => { formatter: (rawParams: any) => {
const params = Array.isArray(rawParams) ? rawParams : [rawParams]; const params = Array.isArray(rawParams) ? rawParams : [rawParams];
const dayName = params[0]?.axisValue ?? ''; const dayName = params[0]?.axisValue ?? '';
const dayPart = params.find((p: any) => p.seriesName === '按天填')?.value ?? 0; const total = Number(params[0]?.value ?? 0);
const weekPart = params.find((p: any) => p.seriesName === '按周均分')?.value ?? 0; return `${dayName}${total}h`;
const total = Number(dayPart) + Number(weekPart);
return `${dayName}${total}h<br/>按天填 ${dayPart}h<br/>按周均分 ${weekPart}h`;
} }
}, },
grid: { left: 28, right: 8, top: 16, bottom: 24, containLabel: false }, grid: { left: 28, right: 8, top: 16, bottom: 24, containLabel: false },
@@ -176,20 +213,11 @@ function buildMyBarOption(): ECOption {
}, },
series: [ series: [
{ {
name: '按天填', name: '每日工时',
type: 'bar', type: 'bar',
stack: 'total',
barWidth: 18, barWidth: 18,
data: v?.dailyByDay ?? [], data: v?.dailyHours ?? [],
itemStyle: { color: DAY_BAR_COLOR, borderRadius: [0, 0, 2, 2] } itemStyle: { color: DAY_BAR_COLOR, borderRadius: [2, 2, 0, 0] }
},
{
name: '按周均分',
type: 'bar',
stack: 'total',
barWidth: 18,
data: v?.dailyByWeekAvg ?? [],
itemStyle: { color: WEEK_BAR_COLOR, borderRadius: [2, 2, 0, 0] }
} }
] ]
}; };
@@ -208,16 +236,7 @@ const { domRef: myBarRef, updateOptions: updateMyBar } = useEcharts(buildMyBarOp
// ============ 团队工时 ============ // ============ 团队工时 ============
const teamView = computed<WorkbenchTeamWorklogView | null>(() => { const teamView = computed(() => (teamWeekData.value ? buildWorkbenchTeamWorklogView(teamWeekData.value) : null));
if (!selectedWeekStart.value) return null;
if (selectedWeekStart.value === workbenchTeamWorklogMock.current.weekStart) {
return buildWorkbenchTeamWorklogView(workbenchTeamWorklogMock.current);
}
if (selectedWeekStart.value === workbenchTeamWorklogMock.previous.weekStart) {
return buildWorkbenchTeamWorklogView(workbenchTeamWorklogMock.previous);
}
return null;
});
const teamSeriesWithColor = computed(() => const teamSeriesWithColor = computed(() =>
(teamView.value?.seriesMatrix ?? []).map(s => ({ ...s, color: getWorkbenchItemColor(s.key, s.kind) })) (teamView.value?.seriesMatrix ?? []).map(s => ({ ...s, color: getWorkbenchItemColor(s.key, s.kind) }))
@@ -339,6 +358,9 @@ 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">
<SvgIcon icon="mdi:information-outline" class="ww-section-info" />
</ElTooltip>
</div> </div>
</div> </div>
@@ -351,16 +373,6 @@ watch(activeTab, async tab => {
<div class="ww-block"> <div class="ww-block">
<div ref="myBarRef" class="ww-bar" /> <div ref="myBarRef" class="ww-bar" />
<div class="ww-bar-legend">
<span class="ww-bar-legend__item">
<span class="ww-bar-legend__swatch" :style="{ background: DAY_BAR_COLOR }" />
按天填
</span>
<span class="ww-bar-legend__item">
<span class="ww-bar-legend__swatch" :style="{ background: WEEK_BAR_COLOR }" />
按周均分
</span>
</div>
</div> </div>
</div> </div>
@@ -410,7 +422,7 @@ watch(activeTab, async tab => {
{{ teamView.highCount }} {{ teamView.highCount }}
<span class="tw-kpi__unit"></span> <span class="tw-kpi__unit"></span>
</span> </span>
<span class="tw-kpi__sub"> 45h</span> <span class="tw-kpi__sub"> {{ teamView.overtimeThreshold }}h</span>
</div> </div>
</div> </div>
@@ -523,6 +535,12 @@ watch(activeTab, async tab => {
color: var(--el-text-color-placeholder); color: var(--el-text-color-placeholder);
flex-shrink: 0; flex-shrink: 0;
} }
.ww-section-info {
font-size: 13px;
color: var(--el-text-color-placeholder);
flex-shrink: 0;
cursor: help;
}
.ww-pie-wrap { .ww-pie-wrap {
position: relative; position: relative;
@@ -540,26 +558,6 @@ watch(activeTab, async tab => {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.ww-bar-legend {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 8px;
font-size: 11px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
.ww-bar-legend__item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.ww-bar-legend__swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
}
.ww-footer { .ww-footer {
display: flex; display: flex;

View File

@@ -1,8 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { fetchGetMyTeamLoad } from '@/service/api';
import { getWorkbenchItemColor } from '../composables/use-workbench-colors'; import { getWorkbenchItemColor } from '../composables/use-workbench-colors';
import { type WorkbenchTeamLoadLevel, buildWorkbenchTeamLoadView } from '../homepage'; import {
import { workbenchTeamLoadMock } from '../mock'; type WorkbenchTeamLoadLevel,
type WorkbenchTeamLoadMemberSource,
buildWorkbenchTeamLoadView
} from '../homepage';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh'; import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
@@ -14,21 +18,40 @@ interface Props {
withDefaults(defineProps<Props>(), { editing: false }); withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void }>(); defineEmits<{ (e: 'hide'): void }>();
const { loading, refresh } = useWorkbenchRefresh(); const teamLoadMembers = ref<Api.Project.TeamLoadMember[]>([]);
const view = computed(() => buildWorkbenchTeamLoadView(workbenchTeamLoadMock)); async function loadTeamLoad() {
const { error, data } = await fetchGetMyTeamLoad();
teamLoadMembers.value = error || !data ? [] : data.members;
}
const { loading, refresh } = useWorkbenchRefresh(loadTeamLoad);
// 契约members[0] 恒为当前用户展示加「」后缀builder 内部会按负载等级重排,标记跟名字走)
function toTeamLoadSource(member: Api.Project.TeamLoadMember, index: number): WorkbenchTeamLoadMemberSource {
return {
memberId: member.userId,
memberName: index === 0 ? `${member.userNickname}(我)` : member.userNickname,
items: member.items.map(item => ({
key: item.kind === 'project' && item.projectId ? item.projectId : item.kind,
label: item.projectName ?? (item.kind === 'personal' ? '个人事项' : '其他'),
kind: item.kind,
count: item.count
})),
dueSoon: member.dueSoonCount,
overdue: member.overdueCount
};
}
const view = computed(() => buildWorkbenchTeamLoadView({ members: teamLoadMembers.value.map(toTeamLoadSource) }));
onMounted(loadTeamLoad);
const LEVEL_LABEL: Record<WorkbenchTeamLoadLevel, string> = { const LEVEL_LABEL: Record<WorkbenchTeamLoadLevel, string> = {
high: '高负载', high: '高负载',
mid: '中负载', mid: '中负载',
normal: '正常' normal: '正常'
}; };
function urgentTooltip(dueSoon: number, overdue: number) {
if (dueSoon > 0 && overdue > 0) return `临期 ${dueSoon} · 逾期 ${overdue}`;
if (overdue > 0) return `逾期 ${overdue}`;
return `临期 ${dueSoon}`;
}
</script> </script>
<template> <template>
@@ -64,12 +87,12 @@ function urgentTooltip(dueSoon: number, overdue: number) {
</div> </div>
</div> </div>
<ul class="tl-list"> <ul v-if="view.members.length" class="tl-list">
<li v-for="m in view.members" :key="m.memberId" class="tl-row"> <li v-for="m in view.members" :key="m.memberId" class="tl-row">
<span class="tl-row__dot" :class="`is-${m.level}`" :title="LEVEL_LABEL[m.level]" /> <span class="tl-row__dot" :class="`is-${m.level}`" :title="LEVEL_LABEL[m.level]" />
<span class="tl-row__name">{{ m.memberName }}</span> <span class="tl-row__name">{{ m.memberName }}</span>
<div class="tl-row__bar-wrap"> <div class="tl-row__bar-wrap">
<div class="tl-row__bar" :style="{ width: `${m.barWidthPercent}%` }"> <div class="tl-row__bar">
<ElTooltip <ElTooltip
v-for="seg in m.segments" v-for="seg in m.segments"
:key="seg.key" :key="seg.key"
@@ -85,27 +108,29 @@ function urgentTooltip(dueSoon: number, overdue: number) {
/> />
</ElTooltip> </ElTooltip>
</div> </div>
<span v-if="m.overflowExtra > 0" class="tl-row__overflow">+{{ m.overflowExtra }}</span>
</div> </div>
<span class="tl-row__metrics"> <span class="tl-row__metrics">
<span class="tl-row__metric" :class="`is-${m.level}`"> <span class="tl-row__metric">
<b>{{ m.inProgress }}</b> <b>{{ m.inProgress }}</b>
进行 未完成
</span> </span>
<span v-if="m.urgent > 0" class="tl-row__metric is-urgent"> <span v-if="m.dueSoon > 0" class="tl-row__metric is-due-soon">
<ElTooltip :content="urgentTooltip(m.dueSoon, m.overdue)" placement="top"> <b>{{ m.dueSoon }}</b>
<span> 临期
<b>{{ m.urgent }}</b> </span>
临期 <span v-if="m.overdue > 0" class="tl-row__metric is-overdue">
<SvgIcon v-if="m.overdue > 0" icon="mdi:alert" class="tl-row__warn-icon" /> <b>{{ m.overdue }}</b>
</span> 逾期
</ElTooltip> <SvgIcon icon="mdi:alert" class="tl-row__warn-icon" />
</span> </span>
</span> </span>
</li> </li>
</ul> </ul>
<ElEmpty v-else description="暂无团队负载数据" :image-size="72" class="tl-empty" />
<div class="tl-hint"> = 进行中 6 临期+逾期 2 · = 进行中 4 临期+逾期 1</div> <div class="tl-hint">
口径 = 未完成含待开始/已暂停· = 未完成 6 临期+逾期 2 · = 未完成 4 临期+逾期 1
</div>
</WorkbenchModuleCard> </WorkbenchModuleCard>
</template> </template>
@@ -168,7 +193,8 @@ function urgentTooltip(dueSoon: number, overdue: number) {
} }
.tl-row { .tl-row {
display: grid; display: grid;
grid-template-columns: 10px 64px 1fr auto; /* 指标列固定宽:每行右侧文字宽度不同(有无临期/逾期)会让 1fr 柱子列长短不一,固定后所有柱子等长对齐 */
grid-template-columns: 10px 64px 1fr 200px;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 7px 0; padding: 7px 0;
@@ -206,14 +232,15 @@ function urgentTooltip(dueSoon: number, overdue: number) {
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
} }
/* 柱子恒撑满整行:长度不再编码数量(数量看右侧数字),段宽表示该成员内部构成占比;无数据时为空灰槽 */
.tl-row__bar { .tl-row__bar {
width: 100%;
height: 8px; height: 8px;
border-radius: 4px; border-radius: 4px;
background: var(--el-fill-color); background: var(--el-fill-color);
overflow: hidden; overflow: hidden;
display: flex; display: flex;
min-width: 0; min-width: 0;
transition: width 0.3s ease;
} }
.tl-row__seg { .tl-row__seg {
height: 100%; height: 100%;
@@ -226,49 +253,36 @@ function urgentTooltip(dueSoon: number, overdue: number) {
.tl-row__seg + .tl-row__seg { .tl-row__seg + .tl-row__seg {
box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.75); box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.75);
} }
.tl-row__overflow {
flex-shrink: 0;
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: 8px;
background: var(--el-color-danger);
color: #fff;
font-size: 10px;
font-weight: 600;
line-height: 1.4;
}
.tl-row__metrics { .tl-row__metrics {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 10px; gap: 10px;
font-size: 12px; font-size: 12px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
white-space: nowrap; white-space: nowrap;
} }
/* 数字统一黑色,负载等级只靠行首圆点表达;仅临期(橙)/ 逾期(红)带警示色 */
.tl-row__metric {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.tl-row__metric b { .tl-row__metric b {
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
font-weight: 600; font-weight: 600;
margin-right: 2px; margin-right: 2px;
} }
.tl-row__metric.is-high b { /* 临期用纯橙el-color-warning 偏黄,与"逾期红"区分度不够) */
color: var(--el-color-danger); .tl-row__metric.is-due-soon,
.tl-row__metric.is-due-soon b {
color: rgb(234 88 12);
} }
.tl-row__metric.is-mid b { .tl-row__metric.is-overdue,
color: var(--el-color-warning); .tl-row__metric.is-overdue b {
}
.tl-row__metric.is-normal b {
color: var(--el-color-success);
}
.tl-row__metric.is-urgent {
color: var(--el-color-danger);
}
.tl-row__metric.is-urgent b {
color: var(--el-color-danger); color: var(--el-color-danger);
} }
.tl-row__warn-icon { .tl-row__warn-icon {
vertical-align: -2px;
margin-left: 2px; margin-left: 2px;
font-size: 12px; font-size: 12px;
color: var(--el-color-danger); color: var(--el-color-danger);
@@ -280,4 +294,11 @@ function urgentTooltip(dueSoon: number, overdue: number) {
color: var(--el-text-color-placeholder); color: var(--el-text-color-placeholder);
line-height: 1.5; line-height: 1.5;
} }
.tl-empty {
flex: 1;
min-height: 0;
margin: auto;
padding: 8px 0;
}
</style> </style>

View File

@@ -1,16 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, markRaw, onMounted, ref, watch } from 'vue'; import { type Component, computed, markRaw, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { RouteKey } from '@elegant-router/types'; import type { RouteKey } from '@elegant-router/types';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import { import {
fetchApproveOvertimeApplication, fetchApproveOvertimeApplication,
fetchChangePersonalItemStatus,
fetchChangeProjectTaskStatus,
fetchGetMonthlyReportApprovalPage, fetchGetMonthlyReportApprovalPage,
fetchGetMyTaskPage,
fetchGetOvertimeApplicationApprovalPage, fetchGetOvertimeApplicationApprovalPage,
fetchGetPersonalItemDetail,
fetchGetPersonalItemPage,
fetchGetProjectReportApprovalPage, fetchGetProjectReportApprovalPage,
fetchGetProjectTask,
fetchGetWeeklyReportApprovalPage, fetchGetWeeklyReportApprovalPage,
fetchRejectOvertimeApplication fetchRejectOvertimeApplication
} from '@/service/api'; } from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { useDict } from '@/hooks/business/dict';
import type { WorklogChangedPayload } from '@/views/project/project/execution/shared';
import TaskStatusActionDialog from '@/views/project/project/execution/modules/status-action-dialog.vue';
import TaskWorklogDialog from '@/views/project/project/execution/modules/task-worklog-dialog.vue';
import PersonalItemDetailDialog from '@/views/personal-center/my-item/modules/personal-item-detail-dialog.vue';
import PersonalItemOperateDialog from '@/views/personal-center/my-item/modules/personal-item-operate-dialog.vue'; import PersonalItemOperateDialog from '@/views/personal-center/my-item/modules/personal-item-operate-dialog.vue';
import PersonalItemStatusActionDialog from '@/views/personal-center/my-item/modules/personal-item-status-action-dialog.vue';
import OvertimeApplicationActionDialog from '@/views/personal-center/overtime-application/modules/overtime-application-action-dialog.vue'; import OvertimeApplicationActionDialog from '@/views/personal-center/overtime-application/modules/overtime-application-action-dialog.vue';
import OvertimeApplicationDetailDialog from '@/views/personal-center/overtime-application/modules/overtime-application-detail-dialog.vue'; import OvertimeApplicationDetailDialog from '@/views/personal-center/overtime-application/modules/overtime-application-detail-dialog.vue';
import WorkReportPrototypePageDialog from '@/views/personal-center/work-report/shared/components/prototype-page-dialog.vue'; import WorkReportPrototypePageDialog from '@/views/personal-center/work-report/shared/components/prototype-page-dialog.vue';
@@ -30,10 +46,14 @@ import {
isWorkbenchTodoOverdue, isWorkbenchTodoOverdue,
sortWorkbenchTodoItemsByPriority sortWorkbenchTodoItemsByPriority
} from '../homepage'; } from '../homepage';
import { workbenchTodoMock } from '../mock';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh'; import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline'; import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiPauseCircleOutline from '~icons/mdi/pause-circle-outline';
import IconMdiPlayCircleOutline from '~icons/mdi/play-circle-outline';
type SortKey = 'created' | 'priority' | 'deadline'; type SortKey = 'created' | 'priority' | 'deadline';
type OvertimeApprovalActionType = 'approve' | 'reject'; type OvertimeApprovalActionType = 'approve' | 'reject';
@@ -51,9 +71,20 @@ defineEmits<{
(e: 'hide'): void; (e: 'hide'): void;
}>(); }>();
const router = useRouter();
const { routerPushByKey } = useRouterPush(); const { routerPushByKey } = useRouterPush();
const { loading, refresh } = useWorkbenchRefresh(); const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const { loading, refresh } = useWorkbenchRefresh(async () => {
await Promise.all([
loadMyTaskItems(),
loadPersonalTodoItems(),
loadOvertimeApprovalItems(),
loadWorkReportApprovalItems()
]);
});
const PAGE_SIZE = 5; const PAGE_SIZE = 5;
@@ -95,20 +126,44 @@ const approvalBizTabs: Array<{ key: ApprovalBizType; label: string }> = [
{ key: 'overtime_application', label: '加班申请' } { key: 'overtime_application', label: '加班申请' }
]; ];
const allItems = computed(() => buildWorkbenchTodoItems(workbenchTodoMock)); const myTaskItems = ref<WorkbenchTodoItem[]>([]);
// 保留任务原始行,供操作图标按 availableActions 渲染并取 projectId / executionId 调状态变更接口
const myTaskRows = ref<Api.Project.MyTaskItem[]>([]);
const personalTodoItems = ref<WorkbenchTodoItem[]>([]);
// 保留个人事项原始行,供操作图标(详情/填报/完成)按 availableActions 渲染并回传给弹层
const personalItemRows = ref<Api.PersonalItem.PersonalItem[]>([]);
const overtimeApprovalItems = ref<WorkbenchTodoItem[]>([]); const overtimeApprovalItems = ref<WorkbenchTodoItem[]>([]);
const overtimeApprovalRows = ref<Api.OvertimeApplication.OvertimeApplication[]>([]); const overtimeApprovalRows = ref<Api.OvertimeApplication.OvertimeApplication[]>([]);
const workReportApprovalItems = ref<WorkbenchTodoItem[]>([]); const workReportApprovalItems = ref<WorkbenchTodoItem[]>([]);
const weeklyApprovalRows = ref<Api.WorkReport.Weekly.WeeklyReport[]>([]); const weeklyApprovalRows = ref<Api.WorkReport.Weekly.WeeklyReport[]>([]);
const monthlyApprovalRows = ref<Api.WorkReport.Monthly.MonthlyReport[]>([]); const monthlyApprovalRows = ref<Api.WorkReport.Monthly.MonthlyReport[]>([]);
const projectApprovalRows = ref<Api.WorkReport.Project.ProjectReport[]>([]); const projectApprovalRows = ref<Api.WorkReport.Project.ProjectReport[]>([]);
const mergedItems = computed(() => { // 工单 tab 等工单业务上线,当前为空态
const mockItems = allItems.value.filter(item => item.category !== 'approval'); const mergedItems = computed(() => [
...myTaskItems.value,
...personalTodoItems.value,
...overtimeApprovalItems.value,
...workReportApprovalItems.value
]);
return [...mockItems, ...overtimeApprovalItems.value, ...workReportApprovalItems.value]; // 个人事项操作弹层:详情用 view 模式、新增用 add 模式,共用同一实例
}); const personalOperateVisible = ref(false);
const personalOperateType = ref<'add' | 'view'>('add');
const addDialogVisible = ref(false); const personalOperateRow = ref<Api.PersonalItem.PersonalItem | null>(null);
// 个人事项填报:复用详情弹层的 worklog tab
const personalDetailVisible = ref(false);
const personalDetailRow = ref<Api.PersonalItem.PersonalItem | null>(null);
// 个人事项完成:复用状态动作弹层收集原因 + 二次确认
const personalStatusVisible = ref(false);
const personalStatusRow = ref<Api.PersonalItem.PersonalItem | null>(null);
const personalStatusAction = ref<Api.PersonalItem.PersonalItemLifecycleAction | null>(null);
// 任务生命周期动作:复用任务工作区的状态动作弹层收集原因
const taskStatusVisible = ref(false);
const taskStatusRow = ref<Api.Project.MyTaskItem | null>(null);
const taskStatusAction = ref<Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode> | null>(null);
// 任务填报工时:复用任务工作区的工时弹层;弹层需要完整任务详情,点击时按需拉取
const taskWorklogVisible = ref(false);
const taskWorklogTask = ref<Api.Project.ProjectTask | null>(null);
const overtimeDetailVisible = ref(false); const overtimeDetailVisible = ref(false);
const overtimeActionVisible = ref(false); const overtimeActionVisible = ref(false);
const overtimeActionSubmitting = ref(false); const overtimeActionSubmitting = ref(false);
@@ -122,13 +177,228 @@ const OVERTIME_APPROVAL_ACTION_ICONS = {
detail: markRaw(IconMdiEyeOutline) detail: markRaw(IconMdiEyeOutline)
}; };
function handleOpenAdd() { const PERSONAL_ACTION_ICONS = {
addDialogVisible.value = true; detail: markRaw(IconMdiEyeOutline),
worklog: markRaw(IconMdiClipboardEditOutline),
complete: markRaw(IconMdiCheckCircleOutline)
};
// auto_start 是系统自动动作,接口不返回;无图标的未知动作不渲染按钮
const TASK_ACTION_ICONS: Partial<Record<Api.Project.ProjectTaskActionCode, Component>> = {
pause: markRaw(IconMdiPauseCircleOutline),
resume: markRaw(IconMdiPlayCircleOutline),
complete: markRaw(IconMdiCheckCircleOutline),
cancel: markRaw(IconMdiCloseCircleOutline)
};
function getTaskActionButtonType(code: Api.Project.ProjectTaskActionCode) {
if (code === 'complete') return 'success' as const;
if (code === 'cancel') return 'danger' as const;
return 'primary' as const;
} }
function handleAddSubmitted() { function getTodoProgress(item: WorkbenchTodoItem) {
if (typeof item.progressRate !== 'number' || !Number.isFinite(item.progressRate)) {
return 0;
}
return Math.min(100, Math.max(0, Math.round(item.progressRate)));
}
function shouldShowTodoProgress(item: WorkbenchTodoItem) {
return (item.category === 'task' || item.category === 'personal') && typeof item.progressRate === 'number';
}
function handleOpenAdd() {
personalOperateType.value = 'add';
personalOperateRow.value = null;
personalOperateVisible.value = true;
}
async function handleAddSubmitted() {
activeTab.value = 'personal'; activeTab.value = 'personal';
activeDeadlineFilter.value = null; activeDeadlineFilter.value = null;
await loadPersonalTodoItems();
}
function findPersonalItemRow(item: WorkbenchTodoItem) {
return personalItemRows.value.find(row => `personal-${row.id}` === item.id) || null;
}
function getPersonalCompleteAction(row: Api.PersonalItem.PersonalItem) {
return row.availableActions?.find(action => action.actionCode === 'complete') || null;
}
// 仅当个人事项当前可完成availableActions 含 complete时才渲染完成图标
function canCompletePersonalItem(item: WorkbenchTodoItem) {
const row = findPersonalItemRow(item);
return Boolean(row && getPersonalCompleteAction(row));
}
// 详情:复用个人事项操作弹层的 view 只读模式
function openPersonalDetail(item: WorkbenchTodoItem) {
const row = findPersonalItemRow(item);
if (!row) return;
personalOperateType.value = 'view';
personalOperateRow.value = row;
personalOperateVisible.value = true;
}
// 填报:拉最新详情后打开详情弹层的 worklog tab
async function openPersonalWorklog(item: WorkbenchTodoItem) {
const row = findPersonalItemRow(item);
if (!row) return;
const { error, data } = await fetchGetPersonalItemDetail(row.id);
personalDetailRow.value = error || !data ? row : data;
personalDetailVisible.value = true;
}
// 完成:走状态动作弹层(二次确认 + 按 needReason 收集原因)
function openPersonalComplete(item: WorkbenchTodoItem) {
const row = findPersonalItemRow(item);
if (!row) return;
const completeAction = getPersonalCompleteAction(row);
if (!completeAction) return;
personalStatusRow.value = row;
personalStatusAction.value = completeAction;
personalStatusVisible.value = true;
}
async function handlePersonalStatusSubmit(reason: string | null) {
if (!personalStatusRow.value || !personalStatusAction.value) return;
const { error } = await fetchChangePersonalItemStatus(personalStatusRow.value.id, {
actionCode: personalStatusAction.value.actionCode,
reason
});
if (error) return;
personalStatusVisible.value = false;
window.$message?.success(`${personalStatusAction.value.actionName}成功`);
await loadPersonalTodoItems();
}
async function handlePersonalWorklogChanged() {
await loadPersonalTodoItems();
}
function findMyTaskRow(item: WorkbenchTodoItem) {
return myTaskRows.value.find(row => `task-${row.id}` === item.id) || null;
}
// 状态变更接口挂在执行路径下,未挂执行的任务不渲染动作按钮
function getTaskActions(item: WorkbenchTodoItem) {
const row = findMyTaskRow(item);
if (!row?.executionId) return [];
return row.availableActions.filter(action => Boolean(TASK_ACTION_ICONS[action.actionCode]));
}
// 填报工时同样依赖执行路径(工时接口挂在 project/execution/task 下)
function canReportTaskWorklog(item: WorkbenchTodoItem) {
return Boolean(findMyTaskRow(item)?.executionId);
}
// 填报:工时弹层需要完整任务详情(负责人/状态/日期),按需拉一次详情再打开
async function openTaskWorklog(item: WorkbenchTodoItem) {
const row = findMyTaskRow(item);
if (!row?.executionId) return;
const { error, data } = await fetchGetProjectTask(row.projectId, row.executionId, row.id);
if (error || !data) return;
taskWorklogTask.value = data;
taskWorklogVisible.value = true;
}
// 填报会联动任务进度/状态(如 auto_start变更后刷新任务列表
async function handleTaskWorklogChanged(payload: WorklogChangedPayload) {
await loadMyTaskItems();
// 与任务工作区联动一致:进度填到 100 且我是任务负责人时提示完成(仅单任务,不做级联)
if (payload.mode === 'delete' || payload.progressRate !== 100) return;
const task = taskWorklogTask.value;
if (!task || task.id !== payload.taskId || task.ownerId !== currentUserId.value) return;
// 以刷新后的行为准:后端按进度/负责人口径返回了 complete 才提示
const row = myTaskRows.value.find(item => item.id === payload.taskId);
const completeAction = row?.availableActions.find(action => action.actionCode === 'complete');
if (!row || !completeAction) return;
taskStatusRow.value = row;
taskStatusAction.value = completeAction;
try {
await window.$messageBox?.confirm('任务进度已达 100%,是否完成当前任务?', '完成确认', {
confirmButtonText: '完成任务',
cancelButtonText: '仅保留工时',
type: 'info'
});
} catch {
return;
}
await submitTaskStatusChange(null);
}
const taskStatusActionTitle = computed(() =>
taskStatusAction.value ? `任务状态变更:${taskStatusAction.value.actionName}` : '任务状态变更'
);
async function handleTaskAction(
item: WorkbenchTodoItem,
action: Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode>
) {
const row = findMyTaskRow(item);
if (!row?.executionId) return;
taskStatusRow.value = row;
taskStatusAction.value = action;
// 完成动作:二次确认后直接提交(与任务工作区同口径;有未完成子任务时后端会拒绝,按业务错误提示)
if (action.actionCode === 'complete') {
try {
await window.$messageBox?.confirm(`确定要完成任务“${row.taskTitle}”吗?`, '完成确认', {
confirmButtonText: '完成任务',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
await submitTaskStatusChange(null);
return;
}
// 其他非必填原因的动作(暂停/恢复)直接提交,不弹原因弹层
if (!action.needReason) {
await submitTaskStatusChange(null);
return;
}
taskStatusVisible.value = true;
}
async function submitTaskStatusChange(reason: string | null) {
const row = taskStatusRow.value;
const action = taskStatusAction.value;
if (!row?.executionId || !action) return;
const { error } = await fetchChangeProjectTaskStatus(row.projectId, row.executionId, {
taskId: row.id,
data: { actionCode: action.actionCode, reason }
});
if (error) return;
taskStatusVisible.value = false;
window.$message?.success(`${action.actionName}成功`);
await loadMyTaskItems();
} }
const tabCounts = computed(() => { const tabCounts = computed(() => {
@@ -253,6 +523,15 @@ function handleClickItem(item: WorkbenchTodoItem) {
return; return;
} }
// 任务条目:带项目对象上下文跳进该项目的任务导航(执行池页)
if (item.category === 'task' && item.projectId) {
router.push({
path: '/project/project/execution',
query: { [OBJECT_CONTEXT_QUERY_KEY]: item.projectId }
});
return;
}
if (!item.routeKey) return; if (!item.routeKey) return;
routerPushByKey(item.routeKey as RouteKey); routerPushByKey(item.routeKey as RouteKey);
} }
@@ -336,6 +615,71 @@ async function handleWorkReportSubmitted() {
await loadWorkReportApprovalItems(); await loadWorkReportApprovalItems();
} }
// 优先级角标用字典 label 原样回显rdms_req_priorityP0~P3不翻译成高/中/低
const { getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE);
// 任务优先级字典 value"0" P0 ~ "3" P3数字越小越高映射待办优先级仅用于排序与高亮权重
function mapTaskTodoPriority(priority: string) {
if (priority === '0') return 'high' as const;
if (priority === '1') return 'mid' as const;
return 'low' as const;
}
// 我的任务:跨项目单接口聚合(负责/协办并集、只返回非终态,过滤排序、进度与可执行动作均由后端完成)
async function loadMyTaskItems() {
const { error, data } = await fetchGetMyTaskPage({ pageNo: 1, pageSize: -1 });
if (error || !data) {
myTaskRows.value = [];
myTaskItems.value = [];
return;
}
myTaskRows.value = data.list;
myTaskItems.value = buildWorkbenchTodoItems(
data.list.map(task => ({
id: `task-${task.id}`,
category: 'task' as const,
title: task.taskTitle,
createdTime: task.createTime,
deadline: task.plannedEndDate,
source: task.projectName,
progressRate: task.progressRate,
priority: mapTaskTodoPriority(task.priority),
priorityLabel: getPriorityLabel(task.priority) || task.priority,
projectId: task.projectId
}))
);
}
// 待办口径未到终态的个人事项pending / active / pausedterminal 态completed / cancelled不进待办
const PERSONAL_TODO_STATUSES: Api.PersonalItem.PersonalItemStatusCode[] = ['pending', 'active', 'paused'];
async function loadPersonalTodoItems() {
const { error, data } = await fetchGetPersonalItemPage({ pageNo: 1, pageSize: 100 });
if (error || !data) {
personalItemRows.value = [];
personalTodoItems.value = [];
return;
}
const rows = data.list.filter(item => PERSONAL_TODO_STATUSES.includes(item.statusCode));
personalItemRows.value = rows;
personalTodoItems.value = buildWorkbenchTodoItems(
rows.map(item => ({
id: `personal-${item.id}`,
category: 'personal',
title: item.taskTitle,
createdTime: item.createTime,
deadline: item.plannedEndDate,
source: '个人事项',
progressRate: item.progressRate,
routeKey: 'personal-center_my-item'
}))
);
}
async function loadOvertimeApprovalItems() { async function loadOvertimeApprovalItems() {
const { error, data } = await fetchGetOvertimeApplicationApprovalPage({ const { error, data } = await fetchGetOvertimeApplicationApprovalPage({
pageNo: 1, pageNo: 1,
@@ -443,7 +787,12 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
} }
onMounted(async () => { onMounted(async () => {
await Promise.all([loadOvertimeApprovalItems(), loadWorkReportApprovalItems()]); await Promise.all([
loadMyTaskItems(),
loadPersonalTodoItems(),
loadOvertimeApprovalItems(),
loadWorkReportApprovalItems()
]);
}); });
</script> </script>
@@ -534,25 +883,51 @@ onMounted(async () => {
<div class="workbench-todo__content"> <div class="workbench-todo__content">
<div v-if="pagedItems.length" class="workbench-todo__list"> <div v-if="pagedItems.length" class="workbench-todo__list">
<article <article v-for="item in pagedItems" :key="item.id" class="workbench-todo__item">
v-for="item in pagedItems"
:key="item.id"
class="workbench-todo__item"
:class="{ 'workbench-todo__item--clickable': Boolean(item.routeKey || item.approvalBizType) }"
@click="handleClickItem(item)"
>
<div class="workbench-todo__leading"> <div class="workbench-todo__leading">
<span class="workbench-todo__category" :class="`workbench-todo__category--${item.categoryTone}`"> <span class="workbench-todo__category" :class="`workbench-todo__category--${item.categoryTone}`">
{{ item.categoryLabel }} {{ item.categoryLabel }}
</span> </span>
<span v-if="item.priority === 'high'" class="workbench-todo__priority"></span> <span
v-if="item.priorityLabel"
class="workbench-todo__priority"
:class="{ 'workbench-todo__priority--high': item.priority === 'high' }"
>
{{ item.priorityLabel }}
</span>
</div> </div>
<div class="workbench-todo__body"> <div class="workbench-todo__body">
<h4 class="workbench-todo__item-title">{{ item.title }}</h4> <h4 class="workbench-todo__item-title">
<span
class="workbench-todo__item-title-text"
:class="{
'workbench-todo__item-title-text--clickable': Boolean(
item.routeKey || item.approvalBizType || item.projectId
)
}"
@click="handleClickItem(item)"
>
{{ item.title }}
</span>
</h4>
<div class="workbench-todo__meta"> <div class="workbench-todo__meta">
<span class="workbench-todo__source">{{ item.source }}</span> <span class="workbench-todo__source">{{ item.source }}</span>
</div> </div>
<div
v-if="shouldShowTodoProgress(item)"
class="workbench-todo__progress"
:aria-label="`进度 ${getTodoProgress(item)}%`"
>
<span class="workbench-todo__progress-label">进度</span>
<ElProgress
class="workbench-todo__progress-bar"
:percentage="getTodoProgress(item)"
:stroke-width="6"
:show-text="false"
/>
<span class="workbench-todo__progress-text">{{ getTodoProgress(item) }}%</span>
</div>
</div> </div>
<div class="workbench-todo__trailing"> <div class="workbench-todo__trailing">
@@ -574,6 +949,44 @@ onMounted(async () => {
</ElButton> </ElButton>
</ElTooltip> </ElTooltip>
</div> </div>
<div
v-else-if="item.category === 'task' && canReportTaskWorklog(item)"
class="workbench-todo__actions"
@click.stop
>
<ElTooltip content="填报工时">
<ElButton link type="primary" class="workbench-todo__action-btn" @click="openTaskWorklog(item)">
<component :is="PERSONAL_ACTION_ICONS.worklog" class="text-15px" />
</ElButton>
</ElTooltip>
<ElTooltip v-for="action in getTaskActions(item)" :key="action.actionCode" :content="action.actionName">
<ElButton
link
:type="getTaskActionButtonType(action.actionCode)"
class="workbench-todo__action-btn"
@click="handleTaskAction(item, action)"
>
<component :is="TASK_ACTION_ICONS[action.actionCode]" class="text-15px" />
</ElButton>
</ElTooltip>
</div>
<div v-else-if="item.category === 'personal'" class="workbench-todo__actions" @click.stop>
<ElTooltip content="详情">
<ElButton link type="primary" class="workbench-todo__action-btn" @click="openPersonalDetail(item)">
<component :is="PERSONAL_ACTION_ICONS.detail" class="text-15px" />
</ElButton>
</ElTooltip>
<ElTooltip content="填报工时">
<ElButton link type="primary" class="workbench-todo__action-btn" @click="openPersonalWorklog(item)">
<component :is="PERSONAL_ACTION_ICONS.worklog" class="text-15px" />
</ElButton>
</ElTooltip>
<ElTooltip v-if="canCompletePersonalItem(item)" content="完成">
<ElButton link type="success" class="workbench-todo__action-btn" @click="openPersonalComplete(item)">
<component :is="PERSONAL_ACTION_ICONS.complete" class="text-15px" />
</ElButton>
</ElTooltip>
</div>
<span class="workbench-todo__deadline" :class="getDeadlineToneClass(item)"> <span class="workbench-todo__deadline" :class="getDeadlineToneClass(item)">
{{ item.deadlineLabel }} {{ item.deadlineLabel }}
</span> </span>
@@ -597,13 +1010,43 @@ onMounted(async () => {
<!-- append-to-body脱离 grid item transform 容器弹窗才能正常全屏居中 --> <!-- append-to-body脱离 grid item transform 容器弹窗才能正常全屏居中 -->
<PersonalItemOperateDialog <PersonalItemOperateDialog
v-model:visible="addDialogVisible" v-model:visible="personalOperateVisible"
operate-type="add" :operate-type="personalOperateType"
:row-data="null" :row-data="personalOperateRow"
append-to-body append-to-body
@submitted="handleAddSubmitted" @submitted="handleAddSubmitted"
/> />
<PersonalItemDetailDialog
v-model:visible="personalDetailVisible"
:row-data="personalDetailRow"
default-tab="worklog"
append-to-body
@changed="handlePersonalWorklogChanged"
/>
<PersonalItemStatusActionDialog
v-model:visible="personalStatusVisible"
:action="personalStatusAction"
append-to-body
@submit="handlePersonalStatusSubmit"
/>
<TaskStatusActionDialog
v-model:visible="taskStatusVisible"
:title="taskStatusActionTitle"
:action="taskStatusAction"
append-to-body
@submit="submitTaskStatusChange"
/>
<TaskWorklogDialog
v-model:visible="taskWorklogVisible"
:task="taskWorklogTask"
append-to-body
@changed="handleTaskWorklogChanged"
/>
<OvertimeApplicationDetailDialog <OvertimeApplicationDetailDialog
v-model:visible="overtimeDetailVisible" v-model:visible="overtimeDetailVisible"
:row-data="currentOvertimeApplication" :row-data="currentOvertimeApplication"
@@ -826,7 +1269,7 @@ onMounted(async () => {
.workbench-todo__item { .workbench-todo__item {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: 72px minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
padding: 14px 16px; padding: 14px 16px;
@@ -838,19 +1281,12 @@ onMounted(async () => {
background-color 160ms ease; background-color 160ms ease;
} }
.workbench-todo__item--clickable {
cursor: pointer;
}
.workbench-todo__item--clickable:hover {
border-color: rgb(14 116 144 / 60%);
background-color: rgb(240 253 250 / 84%);
}
.workbench-todo__leading { .workbench-todo__leading {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 72px;
min-width: 0;
} }
.workbench-todo__category { .workbench-todo__category {
@@ -891,20 +1327,26 @@ onMounted(async () => {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 20px; min-width: 20px;
height: 20px; height: 20px;
padding: 0 4px;
border-radius: 6px; border-radius: 6px;
background-color: rgb(254 226 226 / 96%); background-color: rgb(241 245 249 / 96%);
color: rgb(220 38 38 / 96%); color: rgb(71 85 105 / 96%);
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
} }
.workbench-todo__priority--high {
background-color: rgb(254 226 226 / 96%);
color: rgb(220 38 38 / 96%);
}
.workbench-todo__body { .workbench-todo__body {
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 5px;
} }
.workbench-todo__item-title { .workbench-todo__item-title {
@@ -918,10 +1360,21 @@ onMounted(async () => {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.workbench-todo__item-title-text--clickable {
cursor: pointer;
transition: color 160ms ease;
}
.workbench-todo__item-title-text--clickable:hover {
color: rgb(14 116 144 / 96%);
text-decoration: underline;
}
.workbench-todo__meta { .workbench-todo__meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap;
} }
.workbench-todo__source { .workbench-todo__source {
@@ -933,6 +1386,41 @@ onMounted(async () => {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.workbench-todo__progress {
display: flex;
align-items: center;
gap: 6px;
width: min(100%, 260px);
margin-top: 1px;
}
.workbench-todo__progress-label {
flex: 0 0 auto;
color: rgb(100 116 139 / 92%);
font-size: 12px;
line-height: 1;
white-space: nowrap;
}
.workbench-todo__progress-bar {
flex: 1 1 auto;
min-width: 96px;
}
.workbench-todo__progress-text {
flex: 0 0 34px;
color: rgb(71 85 105 / 94%);
font-size: 12px;
font-weight: 600;
line-height: 1;
text-align: right;
font-variant-numeric: tabular-nums;
}
.workbench-todo__progress :deep(.el-progress-bar__outer) {
background-color: rgb(226 232 240 / 96%);
}
.workbench-todo__trailing { .workbench-todo__trailing {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -992,7 +1480,7 @@ onMounted(async () => {
@media (width <= 600px) { @media (width <= 600px) {
.workbench-todo__item { .workbench-todo__item {
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: 72px minmax(0, 1fr);
} }
.workbench-todo__trailing { .workbench-todo__trailing {