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

@@ -1,7 +1,5 @@
import dayjs from 'dayjs';
export type WorkbenchTrend = 'up' | 'down' | 'flat';
export type WorkbenchTodoCategory = 'task' | 'ticket' | 'personal' | 'approval';
export type WorkbenchTodoMainTab = 'all' | WorkbenchTodoCategory;
@@ -10,40 +8,6 @@ export type WorkbenchTodoDeadlineFilter = 'overdue' | 'today' | 'week' | null;
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 {
id: string;
category: WorkbenchTodoCategory;
@@ -54,12 +18,18 @@ export interface WorkbenchTodoItemSource {
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 */
@@ -74,26 +44,6 @@ export interface WorkbenchTodoItem extends Omit<WorkbenchTodoItemSource, 'deadli
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 衍生) */
export interface WorkbenchParticipatedProjectView {
id: string;
@@ -123,14 +73,6 @@ const todoPriorityWeight: Record<WorkbenchTodoPriority, number> = {
low: 2
};
const activityToneMap: Record<WorkbenchActivityItemSource['targetKind'], WorkbenchActivityItem['tone']> = {
requirement: 'sky',
task: 'emerald',
ticket: 'amber',
project: 'violet',
product: 'rose'
};
/** 列表只含进行中项目;按已知状态编码上色,未知回退 sky */
function resolveParticipatedProjectTone(statusCode: string): 'sky' | 'emerald' | 'amber' {
if (statusCode === 'active') return 'emerald';
@@ -143,25 +85,6 @@ function clampPercent(value: number) {
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) {
if (!value) return '不限';
const target = dayjs(value);
@@ -187,60 +110,6 @@ function getRemainingDays(value: string | null) {
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[] {
return [...source]
.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(
source: readonly Api.Project.MyParticipatedProjectItem[]
): WorkbenchParticipatedProjectView[] {
@@ -389,37 +246,16 @@ export interface WorkbenchWorklogDistributionItem {
projectId?: string;
}
/** 单周工时数据源(按天填 / 按周填两类共存 */
export interface WorkbenchWeekWorklogSource {
/** ISO 周首日周一YYYY-MM-DD */
weekStart: string;
/** 整周一笔填的工时总和(将按工作日 5 等分均摊到周一~周五) */
weeklyFilledHours: number;
/** 周一~周五按天填的工时,长度恒为 5 */
dailyHours: [number, number, number, number, number];
/** 工时分布;前端不再做截断,超出 5 行靠容器滚动 */
distribution: WorkbenchWorklogDistributionItem[];
/** 周目标小时数(默认 40 */
target: number;
}
/** 周标准工时:系统无配置项,后端不返回,产品确认前端落常量 35不是 40 */
export const WORKBENCH_WEEK_TARGET_HOURS = 35;
/** 周工时 mock 容器:本周 + 上周 */
export interface WorkbenchMyWeekWorklogSource {
current: WorkbenchWeekWorklogSource;
previous: WorkbenchWeekWorklogSource;
}
/** 单周工时视图builder 衍生) */
/** 周工时视图builder 衍生;逐日工时为后端按填报日期段均摊到工作日的推算值) */
export interface WorkbenchWeekWorklogView {
weekStart: string;
weekLabel: string;
/** 周一~周五按天填部分5 长度 */
dailyByDay: number[];
/** 周一~周五按周均分部分5 长度,每项 = weeklyFilledHours / 5 */
dailyByWeekAvg: number[];
/** 周一~周五合计5 长度dailyByDay + dailyByWeekAvg */
dailyTotal: number[];
/** 累计(含按周) */
/** 周一~周五逐日工时5 长度,均摊推算值,周末份额归周五 */
dailyHours: number[];
/** 本周累计 */
totalHours: number;
target: number;
/** 累计 - 目标;正=领先、负=落后 */
@@ -435,29 +271,37 @@ function roundHours(value: number) {
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 weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}` : source.weekStart;
const weekAvg = roundHours(source.weeklyFilledHours / WEEKDAY_COUNT);
const dailyByWeekAvg = Array.from({ length: WEEKDAY_COUNT }, () => weekAvg);
const dailyByDay = source.dailyHours.map(roundHours);
const dailyTotal = dailyByDay.map((h, i) => roundHours(h + dailyByWeekAvg[i]));
const totalHours = roundHours(dailyTotal.reduce((s, h) => s + h, 0));
const delta = roundHours(totalHours - source.target);
const completionRate = source.target > 0 ? clampPercent((totalHours / source.target) * 100) : 0;
const dailyHours = Array.from({ length: WEEKDAY_COUNT }, (_, i) => roundHours(source.dailyHours[i] ?? 0));
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,
dailyByDay,
dailyByWeekAvg,
dailyTotal,
dailyHours,
totalHours,
target: source.target,
target,
delta,
completionRate,
distribution: source.distribution
distribution: source.distribution.map(toWorklogDistributionItem)
};
}
@@ -470,31 +314,10 @@ export function getGreeting(hour: number = dayjs().hour()) {
return '夜深了';
}
// === 团队工时分布C12 · teamWorklog ===
// === 团队工时分布(D16 团队 tabC12 teamWorklog ===
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 衍生) */
export interface WorkbenchTeamWorklogView {
weekStart: string;
@@ -512,27 +335,36 @@ export interface WorkbenchTeamWorklogView {
totalHours: number;
/** 团队人均工时 */
averageHours: number;
/** 应填工时合计(人数 × expectedHoursPerMember */
/** 应填工时合计(人数 × WORKBENCH_WEEK_TARGET_HOURS */
expectedTotalHours: number;
/** 填报率 0-100 整数(= 总工时 / 应填工时) */
fillRate: number;
/** 偏低人数:< 团队均值 × 0.8 */
lowCount: number;
/** 加班人数:> 45h应填 × 1.125 */
/** 加班人数:> 周标准 × 1.125 */
highCount: number;
/** 加班判定阈值小时KPI 文案展示用 */
overtimeThreshold: number;
/** 工时最低成员(用于底部小结) */
lowest: { 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 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 }>();
for (const m of source.members) {
for (const m of members) {
for (const it of m.items) {
if (!categoryMap.has(it.key)) {
categoryMap.set(it.key, { key: it.key, label: it.label, kind: it.kind });
@@ -541,7 +373,7 @@ export function buildWorkbenchTeamWorklogView(source: WorkbenchTeamWorklogSource
}
const categories = Array.from(categoryMap.values());
const memberView = source.members.map(m => ({
const memberView = members.map(m => ({
memberId: m.memberId,
memberName: m.memberName,
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 => ({
...cat,
data: source.members.map(m => {
data: members.map(m => {
const hit = m.items.find(it => it.key === cat.key);
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 memberCount = memberView.length;
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 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 highCount = memberView.filter(m => m.totalHours > highThreshold).length;
@@ -585,6 +417,7 @@ export function buildWorkbenchTeamWorklogView(source: WorkbenchTeamWorklogSource
fillRate,
lowCount,
highCount,
overtimeThreshold: roundHours(highThreshold),
lowest,
highest
};
@@ -594,31 +427,29 @@ export function buildWorkbenchTeamWorklogView(source: WorkbenchTeamWorklogSource
export type WorkbenchTeamLoadLevel = 'high' | 'mid' | 'normal';
export type WorkbenchTeamLoadItemKind = 'project' | 'personal';
export type WorkbenchTeamLoadItemKind = 'project' | 'personal' | 'other';
export interface WorkbenchTeamLoadItemSource {
/** projectId 或 'personal' */
/** projectId 或 'personal' / 'other' */
key: string;
label: string;
kind: WorkbenchTeamLoadItemKind;
/** 该项目/个人事项下,该成员进行中的任务/事项数(任务按"负责人/协办人"口径,单任务多人各算 1 条) */
/** 该项目/个人事项下,该成员未完成的任务/事项数(含待开始/已暂停;任务按"负责人/协办人"口径,单任务多人各算 1 条) */
count: number;
}
export interface WorkbenchTeamLoadMemberSource {
memberId: string;
memberName: string;
/** 进行中按项目 + 个人事项的拆分(合计即"进行中总数" */
/** 未完成数按项目 + 个人事项的拆分(合计即"未完成总数" */
items: WorkbenchTeamLoadItemSource[];
/** 今天 ≤ 计划结束 ≤ 今天+3 天 且未完成 */
/** 今天 ≤ 计划结束 ≤ 今天+3 天 且未完成(与逾期互斥) */
dueSoon: number;
/** 计划结束 < 今天 且未完成 */
overdue: number;
}
export interface WorkbenchTeamLoadSource {
/** 数据快照所属周(与 C12 对齐口径) */
weekStart: string;
members: WorkbenchTeamLoadMemberSource[];
}
@@ -628,7 +459,7 @@ export interface WorkbenchTeamLoadSegment extends WorkbenchTeamLoadItemSource {
}
export interface WorkbenchTeamLoadMember extends Omit<WorkbenchTeamLoadMemberSource, 'items'> {
/** items 合计 */
/** items 合计(未完成总数) */
inProgress: number;
/** dueSoon + overdue */
urgent: number;
@@ -642,7 +473,6 @@ export interface WorkbenchTeamLoadMember extends Omit<WorkbenchTeamLoadMemberSou
}
export interface WorkbenchTeamLoadView {
weekStart: string;
/** 已按 level → inProgress desc 排序,高负载置顶 */
members: WorkbenchTeamLoadMember[];
/** 柱子量程上限(固定值,避免某个成员极端值把全员压扁) */
@@ -709,7 +539,6 @@ export function buildWorkbenchTeamLoadView(source: WorkbenchTeamLoadSource): Wor
const urgentTotal = enriched.reduce((s, m) => s + m.urgent, 0);
return {
weekStart: source.weekStart,
members: enriched,
scaleMax,
highCount,
@@ -741,19 +570,6 @@ export function buildWorkbenchProjectHealthCards(
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 的执行(默认不在工作台呈现)。
* 后端接口已按此口径过滤,此处为双保险;泛型保证接口返回类型可复用。