import dayjs from 'dayjs'; export type WorkbenchTodoCategory = 'task' | 'ticket' | 'personal' | 'approval'; export type WorkbenchTodoMainTab = 'all' | WorkbenchTodoCategory; export type WorkbenchTodoDeadlineFilter = 'overdue' | 'today' | 'week' | null; export type WorkbenchTodoPriority = 'high' | 'mid' | 'low'; export interface WorkbenchTodoItemSource { id: string; category: WorkbenchTodoCategory; /** 左侧分类 tag 文案;不传时按 category 默认映射 */ categoryLabel?: string; /** 左侧分类 tag 色调;不传时按 category 默认映射 */ categoryTone?: WorkbenchTodoItem['categoryTone']; title: string; /** 创建时间,ISO 字符串。列表默认按这个升序 */ createdTime: string; /** 截止时间,ISO 字符串 */ deadline: string | null; /** 来源(提交人/项目名/工单号) */ source: string; /** 进度百分比,仅任务和我的事项使用 */ progressRate?: number | null; /** 优先级,用于前端排序与高亮 */ priority?: WorkbenchTodoPriority; /** 优先级原始标签(字典 label,如 P0~P3),有值才渲染角标;不做高/中/低翻译 */ priorityLabel?: string | null; /** 是否逾期 */ overdue?: boolean; /** 跳转路由 key(可选,未配则不跳转) */ routeKey?: string; /** 任务条目携带:所属项目 ID,点击带对象上下文跳进该项目的任务导航 */ projectId?: string; /** 审批业务类型 */ approvalBizType?: 'overtime_application' | string; /** 审批业务 ID */ approvalBizId?: string; } export interface WorkbenchTodoItem extends Omit { deadlineLabel: string; /** 相对截止天数:负数表示已逾期 */ remainingDays: number | null; categoryLabel: string; categoryTone: 'sky' | 'emerald' | 'amber' | 'rose' | 'violet'; } /** 「我参与的项目」展示项(由 Api.Project.MyParticipatedProjectItem 衍生) */ export interface WorkbenchParticipatedProjectView { id: string; name: string; code: string | null; statusName: string | null; statusTone: 'sky' | 'emerald' | 'amber'; myRole: string | null; progress: number; myTaskCount: number; myPendingTaskCount: number; } const todoCategoryMeta: Record< WorkbenchTodoCategory, { label: string; tone: WorkbenchTodoItem['categoryTone']; icon: string } > = { task: { label: '任务', tone: 'emerald', icon: 'mdi:checkbox-marked-circle-outline' }, ticket: { label: '工单', tone: 'amber', icon: 'mdi:ticket-confirmation-outline' }, personal: { label: '我的事项', tone: 'violet', icon: 'mdi:notebook-edit-outline' }, approval: { label: '待审批', tone: 'sky', icon: 'mdi:checkbox-multiple-marked-outline' } }; const todoPriorityWeight: Record = { high: 0, mid: 1, low: 2 }; /** 列表只含进行中项目;按已知状态编码上色,未知回退 sky */ function resolveParticipatedProjectTone(statusCode: string): 'sky' | 'emerald' | 'amber' { if (statusCode === 'active') return 'emerald'; if (statusCode === 'paused') return 'amber'; return 'sky'; } function clampPercent(value: number) { if (!Number.isFinite(value)) return 0; return Math.min(100, Math.max(0, Math.round(value))); } function formatDeadline(value: string | null) { if (!value) return '不限'; const target = dayjs(value); if (!target.isValid()) return '不限'; const now = dayjs().startOf('day'); const start = target.startOf('day'); const days = start.diff(now, 'day'); if (days === 0) return `今日 ${target.format('HH:mm')}`; if (days === 1) return `明日 ${target.format('HH:mm')}`; if (days === -1) return `昨日 ${target.format('HH:mm')}`; if (days > 0 && days <= 7) return `${days} 天后 ${target.format('MM-DD')}`; if (days < 0) return `逾期 ${Math.abs(days)} 天`; return target.format('YYYY-MM-DD'); } function getRemainingDays(value: string | null) { if (!value) return null; const target = dayjs(value); if (!target.isValid()) return null; return target.startOf('day').diff(dayjs().startOf('day'), 'day'); } export function buildWorkbenchTodoItems(source: readonly WorkbenchTodoItemSource[]): WorkbenchTodoItem[] { return [...source] .sort((left, right) => { const leftCreated = dayjs(left.createdTime).valueOf(); const rightCreated = dayjs(right.createdTime).valueOf(); if (leftCreated !== rightCreated) return leftCreated - rightCreated; const leftDeadline = left.deadline ? dayjs(left.deadline).valueOf() : Number.POSITIVE_INFINITY; const rightDeadline = right.deadline ? dayjs(right.deadline).valueOf() : Number.POSITIVE_INFINITY; return leftDeadline - rightDeadline; }) .map(item => { const meta = todoCategoryMeta[item.category]; return { ...item, deadlineLabel: formatDeadline(item.deadline), remainingDays: getRemainingDays(item.deadline), categoryLabel: item.categoryLabel || meta.label, categoryTone: item.categoryTone || meta.tone } satisfies WorkbenchTodoItem; }); } export function filterWorkbenchTodoItemsByCategory(items: readonly WorkbenchTodoItem[], tab: WorkbenchTodoMainTab) { if (tab === 'all') return [...items]; return items.filter(item => item.category === tab); } export function filterWorkbenchTodoItemsByDeadline( items: readonly WorkbenchTodoItem[], filter: WorkbenchTodoDeadlineFilter ) { if (!filter) return [...items]; if (filter === 'overdue') return items.filter(item => isOverdue(item)); if (filter === 'today') return items.filter(item => item.remainingDays === 0); // filter === 'week':含今日,含逾期一起暴露给用户处理 return items.filter(item => item.remainingDays !== null && item.remainingDays <= 7); } export function isWorkbenchTodoOverdue(item: WorkbenchTodoItem) { return isOverdue(item); } function isOverdue(item: WorkbenchTodoItem) { if (item.overdue) return true; return item.remainingDays !== null && item.remainingDays < 0; } export function sortWorkbenchTodoItemsByPriority( items: readonly WorkbenchTodoItem[], direction: 'asc' | 'desc' = 'desc' ) { const factor = direction === 'desc' ? 1 : -1; return [...items].sort((left, right) => { const leftWeight = todoPriorityWeight[left.priority ?? 'low']; const rightWeight = todoPriorityWeight[right.priority ?? 'low']; if (leftWeight !== rightWeight) return (leftWeight - rightWeight) * factor; return dayjs(left.createdTime).valueOf() - dayjs(right.createdTime).valueOf(); }); } export function buildWorkbenchParticipatedProjects( source: readonly Api.Project.MyParticipatedProjectItem[] ): WorkbenchParticipatedProjectView[] { return source.map(item => ({ id: item.id, name: item.name, code: item.code, statusName: item.statusName, statusTone: resolveParticipatedProjectTone(item.statusCode), myRole: item.myRole, progress: clampPercent(item.progress), myTaskCount: item.myTaskCount, myPendingTaskCount: item.myPendingTaskCount })); } /** 「我负责的项目」成员负载展示项 */ export interface WorkbenchOwnedProjectMemberView { userId: string; userName: string | null; /** 该成员在本项目下进行中任务数 */ activeTaskCount: number; } /** 「我负责的项目」展示项(由 Api.Project.MyOwnedProjectItem 衍生) */ export interface WorkbenchOwnedProjectView { id: string; name: string; code: string | null; progress: number; myRole: string | null; executionCount: number; taskCount: number; overdueCount: number; memberCount: number; /** 计划结束日期 YYYY-MM-DD,可空 */ plannedEndDate: string | null; /** 距计划结束剩余天数(负=已逾期);plannedEndDate 为空时 null */ remainingDays: number | null; members: WorkbenchOwnedProjectMemberView[]; } export function buildWorkbenchOwnedProjects( source: readonly Api.Project.MyOwnedProjectItem[] ): WorkbenchOwnedProjectView[] { return source.map(item => ({ id: item.id, name: item.name, code: item.code, progress: clampPercent(item.progress), myRole: item.myRole, executionCount: item.executionCount, taskCount: item.taskCount, overdueCount: item.overdueCount, memberCount: item.memberCount, plannedEndDate: item.plannedEndDate, remainingDays: getRemainingDays(item.plannedEndDate), members: item.members.map(member => ({ userId: member.userId, userName: member.userName, activeTaskCount: member.activeTaskCount })) })); } /** 工时分布行项:项目 / 我的事项 / 其他(杂项) */ export interface WorkbenchWorklogDistributionItem { /** 唯一 key;project 用 projectId,personal/other 用固定字面量 */ key: string; label: string; hours: number; kind: 'project' | 'personal' | 'other'; /** kind === 'project' 时携带,用于跳转项目对象上下文 */ projectId?: string; } /** 周标准工时:系统无配置项,后端不返回,产品确认前端落常量 35(不是 40) */ export const WORKBENCH_WEEK_TARGET_HOURS = 35; /** 单周工时视图(builder 衍生;接口返回周一~周日逐日工时,按周填报时仅均摊到工作日) */ export interface WorkbenchWeekWorklogView { weekStart: string; weekLabel: string; /** 周一~周日逐日工时(7 长度;周末单天工时保留在周末当天) */ dailyHours: number[]; /** 是否展示周末两列;仅当周六或周日任一天 > 0 时展示 */ showWeekend: boolean; /** 图表/逐日展示实际使用的横轴标签 */ visibleDayLabels: string[]; /** 图表/逐日展示实际使用的逐日工时数据 */ visibleDailyHours: number[]; /** 本周累计 */ totalHours: number; target: number; /** 累计 - 目标;正=领先、负=落后 */ delta: number; /** 达成率,0-100 整数 */ completionRate: number; distribution: WorkbenchWorklogDistributionItem[]; } const WORKDAY_COUNT = 5; const FULL_WEEKDAY_COUNT = 7; const WORKDAY_LABELS = ['一', '二', '三', '四', '五'] as const; const FULL_WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'] as const; function roundHours(value: number) { return Math.round(value * 10) / 10; } /** 契约分布项 → 展示行项(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 weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}周` : source.weekStart; 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 target = WORKBENCH_WEEK_TARGET_HOURS; const delta = roundHours(totalHours - target); const completionRate = target > 0 ? clampPercent((totalHours / target) * 100) : 0; return { weekStart: source.weekStart, weekLabel, dailyHours, showWeekend, visibleDayLabels, visibleDailyHours, totalHours, target, delta, completionRate, distribution: source.distribution.map(toWorklogDistributionItem) }; } export function getGreeting(hour: number = dayjs().hour()) { if (hour < 6) return '凌晨好'; if (hour < 11) return '早上好'; if (hour < 13) return '中午好'; if (hour < 18) return '下午好'; if (hour < 22) return '晚上好'; return '夜深了'; } // === 团队工时分布(D16 团队 tab,原 C12 teamWorklog) === /** 团队工时分布视图(builder 衍生) */ export interface WorkbenchTeamWorklogView { weekStart: string; weekLabel: string; members: Array<{ memberId: string; memberName: string; totalHours: number; overtimeHours: number; }>; /** 团队总工时 */ totalHours: number; /** 团队人均工时 */ averageHours: number; /** 应填工时合计(人数 × WORKBENCH_WEEK_TARGET_HOURS) */ expectedTotalHours: number; /** 填报率 0-100 整数(= 总工时 / 应填工时) */ fillRate: number; /** 偏低人数:< 团队均值 × 0.8 */ lowCount: number; /** 工时最低成员(用于底部小结) */ lowest: { memberName: string; hours: number } | null; /** 工时最高成员(用于底部小结) */ highest: { memberName: string; hours: number } | null; } export function buildWorkbenchTeamWorklogView(source: Api.Project.TeamWorklogWeekResult): WorkbenchTeamWorklogView { const start = dayjs(source.weekStart); const weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}周` : source.weekStart; // 契约:members[0] 恒为当前用户,展示加「(我)」后缀 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, memberName: index === 0 ? `${member.userNickname}(我)` : member.userNickname, totalHours, overtimeHours }; }); const totalHours = roundHours(members.reduce((sum, member) => sum + member.totalHours, 0)); const memberCount = members.length; const averageHours = memberCount > 0 ? roundHours(totalHours / memberCount) : 0; const expectedTotalHours = memberCount * WORKBENCH_WEEK_TARGET_HOURS; const fillRate = expectedTotalHours > 0 ? clampPercent((totalHours / expectedTotalHours) * 100) : 0; const lowThreshold = averageHours * 0.8; const lowCount = members.filter(member => member.totalHours < lowThreshold).length; let lowest: { memberName: string; hours: number } | null = null; let highest: { memberName: string; hours: number } | null = null; for (const member of members) { if (!lowest || member.totalHours < lowest.hours) lowest = { memberName: member.memberName, hours: member.totalHours }; if (!highest || member.totalHours > highest.hours) highest = { memberName: member.memberName, hours: member.totalHours }; } return { weekStart: source.weekStart, weekLabel, members, totalHours, averageHours, expectedTotalHours, fillRate, lowCount, lowest, highest }; } // === 团队负载(C13 · teamLoad) === export type WorkbenchTeamLoadLevel = 'high' | 'mid' | 'normal'; export type WorkbenchTeamLoadItemKind = 'project' | 'personal' | 'other'; export interface WorkbenchTeamLoadItemSource { /** projectId 或 'personal' / 'other' */ key: string; label: string; kind: WorkbenchTeamLoadItemKind; /** 该项目/我的事项下,该成员未完成的任务/事项数(含待开始/已暂停;任务按"负责人/协办人"口径,单任务多人各算 1 条) */ count: number; } export interface WorkbenchTeamLoadMemberSource { memberId: string; memberName: string; /** 未完成数按项目 + 我的事项的拆分(合计即"未完成总数") */ items: WorkbenchTeamLoadItemSource[]; /** 今天 ≤ 计划结束 ≤ 今天+3 天 且未完成(与逾期互斥) */ dueSoon: number; /** 计划结束 < 今天 且未完成 */ overdue: number; } export interface WorkbenchTeamLoadSource { members: WorkbenchTeamLoadMemberSource[]; } export interface WorkbenchTeamLoadSegment extends WorkbenchTeamLoadItemSource { /** 段宽占"柱子内部"的百分比(段总和 = 100%) */ widthPercent: number; } export interface WorkbenchTeamLoadMember extends Omit { /** items 合计(未完成总数) */ inProgress: number; /** dueSoon + overdue */ urgent: number; level: WorkbenchTeamLoadLevel; /** 项目段(去掉 count = 0) */ segments: WorkbenchTeamLoadSegment[]; /** 柱子总长占容器百分比(0-100),溢出时 = 100 */ barWidthPercent: number; /** 溢出数量:inProgress - scaleMax(不溢出 = 0),柱子末端显示 "+N" */ overflowExtra: number; } export interface WorkbenchTeamLoadView { /** 已按 level → inProgress desc 排序,高负载置顶 */ members: WorkbenchTeamLoadMember[]; /** 柱子量程上限(固定值,避免某个成员极端值把全员压扁) */ scaleMax: number; highCount: number; midCount: number; /** 临期+逾期总条数(团队聚合) */ urgentTotal: number; } const TEAM_LOAD_INPROGRESS_HIGH = 6; const TEAM_LOAD_INPROGRESS_MID = 4; const TEAM_LOAD_URGENT_HIGH = 2; const TEAM_LOAD_URGENT_MID = 1; /** 柱子量程上限:固定 10,确保高负载阈值 6 在 60% 位置可辨;超出走 "+N" 文字 */ const TEAM_LOAD_SCALE_MAX = 10; function resolveTeamLoadLevel(inProgress: number, urgent: number): WorkbenchTeamLoadLevel { if (inProgress >= TEAM_LOAD_INPROGRESS_HIGH || urgent >= TEAM_LOAD_URGENT_HIGH) return 'high'; if (inProgress >= TEAM_LOAD_INPROGRESS_MID || urgent >= TEAM_LOAD_URGENT_MID) return 'mid'; return 'normal'; } const LEVEL_RANK: Record = { high: 0, mid: 1, normal: 2 }; export function buildWorkbenchTeamLoadView(source: WorkbenchTeamLoadSource): WorkbenchTeamLoadView { const scaleMax = TEAM_LOAD_SCALE_MAX; const enriched: WorkbenchTeamLoadMember[] = source.members.map(m => { const inProgress = m.items.reduce((s, it) => s + it.count, 0); const urgent = m.dueSoon + m.overdue; const barWidthPercent = scaleMax > 0 ? Math.min(100, (inProgress / scaleMax) * 100) : 0; const overflowExtra = Math.max(0, inProgress - scaleMax); // 段宽永远相对柱子内部按 count/inProgress 算(段总和 = 100%) // 柱子总长靠 barWidthPercent 控制;不溢出时柱子未顶满,溢出时柱子顶满 + 末端 +N const segments: WorkbenchTeamLoadSegment[] = m.items .filter(it => it.count > 0) .map(it => ({ ...it, widthPercent: inProgress > 0 ? (it.count / inProgress) * 100 : 0 })); return { memberId: m.memberId, memberName: m.memberName, dueSoon: m.dueSoon, overdue: m.overdue, inProgress, urgent, level: resolveTeamLoadLevel(inProgress, urgent), segments, barWidthPercent, overflowExtra }; }); enriched.sort((a, b) => { if (LEVEL_RANK[a.level] !== LEVEL_RANK[b.level]) return LEVEL_RANK[a.level] - LEVEL_RANK[b.level]; if (a.inProgress !== b.inProgress) return b.inProgress - a.inProgress; return b.urgent - a.urgent; }); const highCount = enriched.filter(m => m.level === 'high').length; const midCount = enriched.filter(m => m.level === 'mid').length; const urgentTotal = enriched.reduce((s, m) => s + m.urgent, 0); return { members: enriched, scaleMax, highCount, midCount, urgentTotal }; } export type WorkbenchHealthLevel = 'green' | 'yellow' | 'red'; export interface WorkbenchProjectHealthCardSource { projectId: string; projectName: string; code: string; health: WorkbenchHealthLevel; riskCount: number; overdueTasks: number; backlogRequirements: number; } export interface WorkbenchProjectHealthCard extends WorkbenchProjectHealthCardSource { healthLabel: string; } export function buildWorkbenchProjectHealthCards( source: readonly WorkbenchProjectHealthCardSource[] ): WorkbenchProjectHealthCard[] { const labelMap: Record = { green: '健康', yellow: '关注', red: '风险' }; return source.map(s => ({ ...s, healthLabel: labelMap[s.health] })); } /** * 前端兜底过滤:剔除已完成 / 已取消 / 进度满 100 的执行(默认不在工作台呈现)。 * 后端接口已按此口径过滤,此处为双保险;泛型保证接口返回类型可复用。 */ export function buildWorkbenchMyExecutionItems( source: readonly T[] ): T[] { return source.filter(item => { if (item.statusCode === 'completed' || item.statusCode === 'cancelled') return false; if (item.progressRate >= 100) return false; return true; }); }