Files
cn-rdms-web/src/views/workbench/homepage.ts
dk 54c97c9729 fix(工作台-工时、周报、日志管理): 详细如下。
- 移除历史工作日分隔符兼容逻辑,仅使用显式分隔标记切分
- 扩展工时数据显示范围从5个工作日到7天完整周,并添加周末展示控制
- 在团队工时统计中新增加班工时字段并调整图表展示方式
- 移除团队工时分布中的分类矩阵,简化数据结构
- 为API访问日志和错误日志搜索组件添加用户选择功能
- 调整日志搜索界面布局,将用户编号输入改为用户选择下拉框
- 移除用户类型筛选条件以简化搜索界面
- 更新工时图表配置以支持周末数据显示和新的数据结构
2026-07-14 11:43:47 +08:00

576 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<WorkbenchTodoItemSource, 'deadline'> {
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<WorkbenchTodoPriority, number> = {
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 {
/** 唯一 keyproject 用 projectIdpersonal/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<WorkbenchTeamLoadMemberSource, 'items'> {
/** 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<WorkbenchTeamLoadLevel, number> = { 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<WorkbenchHealthLevel, string> = { green: '健康', yellow: '关注', red: '风险' };
return source.map(s => ({ ...s, healthLabel: labelMap[s.health] }));
}
/**
* 前端兜底过滤:剔除已完成 / 已取消 / 进度满 100 的执行(默认不在工作台呈现)。
* 后端接口已按此口径过滤,此处为双保险;泛型保证接口返回类型可复用。
*/
export function buildWorkbenchMyExecutionItems<T extends { statusCode: string; progressRate: number }>(
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;
});
}