feat(projects): 工作台小组件设计

This commit is contained in:
2026-05-28 08:20:01 +08:00
parent 3988eaf910
commit 4ed4b537ad
54 changed files with 4726 additions and 2720 deletions

View File

@@ -2,7 +2,7 @@ import dayjs from 'dayjs';
export type WorkbenchTrend = 'up' | 'down' | 'flat';
export type WorkbenchTodoCategory = 'task' | 'ticket' | 'personal' | 'review';
export type WorkbenchTodoCategory = 'task' | 'ticket' | 'personal' | 'approval';
export type WorkbenchTodoMainTab = 'all' | WorkbenchTodoCategory;
@@ -12,32 +12,6 @@ export type WorkbenchTodoPriority = 'high' | 'mid' | 'low';
export type WorkbenchProjectStatus = 'active' | 'preview' | 'paused';
export interface WorkbenchBannerSummarySource {
/** 今日待办数 */
todoCount: number;
/** 即将到期数(今日/明日内截止) */
upcomingCount: number;
/** 本周已完成 */
weekDone: number;
/** 本周总量 */
weekTotal: number;
/** 本周进行中 */
weekInProgress: number;
/** 本周逾期 */
weekOverdue: number;
}
export interface WorkbenchBannerSummary {
todoCount: number;
upcomingCount: number;
weekDone: number;
weekTotal: number;
weekInProgress: number;
weekOverdue: number;
/** 完成率0-100 整数) */
weekCompletionRate: number;
}
export interface WorkbenchKpiSource {
/** 待办 */
todo: {
@@ -149,7 +123,7 @@ const todoCategoryMeta: Record<
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' },
review: { label: '待审', tone: 'sky', icon: 'mdi:file-search-outline' }
approval: { label: '待审', tone: 'sky', icon: 'mdi:checkbox-multiple-marked-outline' }
};
const todoPriorityWeight: Record<WorkbenchTodoPriority, number> = {
@@ -221,22 +195,6 @@ function getRemainingDays(value: string | null) {
return target.startOf('day').diff(dayjs().startOf('day'), 'day');
}
export function buildWorkbenchBannerSummary(source: WorkbenchBannerSummarySource): WorkbenchBannerSummary {
const total = Math.max(0, source.weekTotal);
const done = Math.max(0, source.weekDone);
const rate = total === 0 ? 0 : clampPercent((done / total) * 100);
return {
todoCount: Math.max(0, source.todoCount),
upcomingCount: Math.max(0, source.upcomingCount),
weekDone: done,
weekTotal: total,
weekInProgress: Math.max(0, source.weekInProgress),
weekOverdue: Math.max(0, source.weekOverdue),
weekCompletionRate: rate
};
}
export function buildWorkbenchKpiCards(source: WorkbenchKpiSource): WorkbenchKpiCard[] {
function diffTrend(diff: number): { trend: WorkbenchTrend; text: string } {
if (diff > 0) return { trend: 'up', text: `较昨日 +${diff}` };
@@ -376,6 +334,134 @@ export function buildWorkbenchProjectItems(source: readonly WorkbenchProjectItem
});
}
export interface WorkbenchOwnedProjectMilestone {
id: string;
title: string;
timeLabel: string;
tone: 'amber' | 'slate';
}
export interface WorkbenchOwnedProjectMember {
name: string;
/** 负载 0-100百分比 */
load: number;
level: 'ok' | 'warn' | 'over';
}
export interface WorkbenchOwnedProjectItemSource {
id: string;
name: string;
code: string;
/** 进度 0-100 */
progress: number;
executionCount: number;
taskCount: number;
memberCount: number;
overdueCount: number;
/** 距离计划结束剩余天数(负数表示已逾期) */
remainingDays: number;
/** 我在该项目中的角色 */
myRole: string;
milestones: WorkbenchOwnedProjectMilestone[];
members: WorkbenchOwnedProjectMember[];
}
export interface WorkbenchOwnedProjectItem extends WorkbenchOwnedProjectItemSource {
progress: number;
}
export function buildWorkbenchOwnedProjectItems(
source: readonly WorkbenchOwnedProjectItemSource[]
): WorkbenchOwnedProjectItem[] {
return source.map(item => ({
...item,
progress: clampPercent(item.progress)
}));
}
/** 工时分布行项:项目 / 个人事项 / 其他(杂项) */
export interface WorkbenchWorklogDistributionItem {
/** 唯一 keyproject 用 projectIdpersonal/other 用固定字面量 */
key: string;
label: string;
hours: number;
kind: 'project' | 'personal' | 'other';
/** kind === 'project' 时携带,用于跳转项目对象上下文 */
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;
}
/** 双周工时 mock 容器:本周 + 上周 */
export interface WorkbenchMyWeekWorklogSource {
current: WorkbenchWeekWorklogSource;
previous: WorkbenchWeekWorklogSource;
}
/** 单周工时视图builder 衍生) */
export interface WorkbenchWeekWorklogView {
weekStart: string;
weekLabel: string;
/** 周一~周五按天填部分5 长度) */
dailyByDay: number[];
/** 周一~周五按周均分部分5 长度,每项 = weeklyFilledHours / 5 */
dailyByWeekAvg: number[];
/** 周一~周五合计5 长度dailyByDay + dailyByWeekAvg */
dailyTotal: number[];
/** 累计(含按周) */
totalHours: number;
target: number;
/** 累计 - 目标;正=领先、负=落后 */
delta: number;
/** 达成率0-100 整数 */
completionRate: number;
distribution: WorkbenchWorklogDistributionItem[];
}
const WEEKDAY_COUNT = 5;
function roundHours(value: number) {
return Math.round(value * 10) / 10;
}
export function buildWorkbenchWeekWorklogView(source: WorkbenchWeekWorklogSource): 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;
return {
weekStart: source.weekStart,
weekLabel,
dailyByDay,
dailyByWeekAvg,
dailyTotal,
totalHours,
target: source.target,
delta,
completionRate,
distribution: source.distribution
};
}
export function getGreeting(hour: number = dayjs().hour()) {
if (hour < 6) return '凌晨好';
if (hour < 11) return '早上好';
@@ -385,85 +471,252 @@ export function getGreeting(hour: number = dayjs().hour()) {
return '夜深了';
}
export function getTodayLabel() {
const today = dayjs();
const weekdayMap = ['日', '一', '二', '三', '四', '五', '六'];
return `今天 ${today.format('YYYY-MM-DD')} 星期${weekdayMap[today.day()]}`;
// === 团队工时分布C12 · teamWorklog ===
export type WorkbenchTeamWorklogItemKind = 'project' | 'personal' | 'other';
export interface WorkbenchTeamWorklogItem {
/** 项目 id 或 'personal' / 'other' */
key: string;
label: string;
hours: number;
kind: WorkbenchTeamWorklogItemKind;
}
export type WorkbenchMyTaskBucket = 'today' | 'week' | 'overdue' | 'all';
export interface WorkbenchMyTaskItemSource {
id: string;
title: string;
statusCode: string;
statusLabel: string;
executionName: string;
projectName: string;
priority: 'high' | 'mid' | 'low';
deadline: string | null;
}
export interface WorkbenchMyTaskItem extends Omit<WorkbenchMyTaskItemSource, 'deadline'> {
deadlineLabel: string;
remainingDays: number | null;
overdue: boolean;
}
export function buildWorkbenchMyTaskItems(source: readonly WorkbenchMyTaskItemSource[]): WorkbenchMyTaskItem[] {
return [...source]
.sort((a, b) => {
const av = a.deadline ? dayjs(a.deadline).valueOf() : Number.POSITIVE_INFINITY;
const bv = b.deadline ? dayjs(b.deadline).valueOf() : Number.POSITIVE_INFINITY;
return av - bv;
})
.map(item => {
const remaining = getRemainingDays(item.deadline);
return {
...item,
deadlineLabel: formatDeadline(item.deadline),
remainingDays: remaining,
overdue: remaining !== null && remaining < 0
} satisfies WorkbenchMyTaskItem;
});
}
export function filterWorkbenchMyTaskItems(items: readonly WorkbenchMyTaskItem[], bucket: WorkbenchMyTaskBucket) {
if (bucket === 'all') return [...items];
if (bucket === 'overdue') return items.filter(i => i.overdue);
if (bucket === 'today') return items.filter(i => i.remainingDays === 0);
return items.filter(i => i.remainingDays !== null && i.remainingDays >= 0 && i.remainingDays <= 7);
}
export interface WorkbenchMyRequirementGroupSource {
statusCode: string;
statusLabel: string;
count: number;
tone: 'sky' | 'amber' | 'emerald' | 'rose';
}
export type WorkbenchMyRequirementGroup = WorkbenchMyRequirementGroupSource;
export function buildWorkbenchMyRequirementGroups(
source: readonly WorkbenchMyRequirementGroupSource[]
): WorkbenchMyRequirementGroup[] {
return [...source];
}
export interface WorkbenchTeamTodoRowSource {
projectId: string;
projectName: string;
export interface WorkbenchTeamWorklogMemberSource {
memberId: string;
memberName: string;
inProgress: number;
overdue: number;
weekDone: number;
items: WorkbenchTeamWorklogItem[];
}
export type WorkbenchTeamTodoRow = WorkbenchTeamTodoRowSource;
export interface WorkbenchTeamWorklogSource {
weekStart: string;
/** 单人周应填工时(用于填报率口径) */
expectedHoursPerMember: number;
members: WorkbenchTeamWorklogMemberSource[];
}
export function buildWorkbenchTeamTodoRows(source: readonly WorkbenchTeamTodoRowSource[]): WorkbenchTeamTodoRow[] {
return [...source].sort((a, b) => b.overdue - a.overdue);
/** 团队工时分布视图builder 衍生) */
export interface WorkbenchTeamWorklogView {
weekStart: string;
weekLabel: string;
members: Array<{
memberId: string;
memberName: string;
totalHours: number;
}>;
/** 项目/个人/其他 列(保序,去重) */
categories: Array<{ key: string; label: string; kind: WorkbenchTeamWorklogItemKind }>;
/** 每个 category 一个 seriesdata[i] = 第 i 个成员该 category 的工时(缺则 0 */
seriesMatrix: Array<{ key: string; label: string; kind: WorkbenchTeamWorklogItemKind; data: number[] }>;
/** 团队总工时 */
totalHours: number;
/** 团队人均工时 */
averageHours: number;
/** 应填工时合计(人数 × expectedHoursPerMember */
expectedTotalHours: number;
/** 填报率 0-100 整数(= 总工时 / 应填工时) */
fillRate: number;
/** 偏低人数:< 团队均值 × 0.8 */
lowCount: number;
/** 加班人数:> 45h应填 × 1.125 */
highCount: number;
/** 工时最低成员(用于底部小结) */
lowest: { memberName: string; hours: number } | null;
/** 工时最高成员(用于底部小结) */
highest: { memberName: string; hours: number } | null;
}
export function buildWorkbenchTeamWorklogView(source: WorkbenchTeamWorklogSource): WorkbenchTeamWorklogView {
const start = dayjs(source.weekStart);
const weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}` : source.weekStart;
// 列保序去重:按成员遍历顺序首次出现即入列;项目优先、个人/其他按出现顺序追加
const categoryMap = new Map<string, { key: string; label: string; kind: WorkbenchTeamWorklogItemKind }>();
for (const m of source.members) {
for (const it of m.items) {
if (!categoryMap.has(it.key)) {
categoryMap.set(it.key, { key: it.key, label: it.label, kind: it.kind });
}
}
}
const categories = Array.from(categoryMap.values());
const memberView = source.members.map(m => ({
memberId: m.memberId,
memberName: m.memberName,
totalHours: roundHours(m.items.reduce((s, it) => s + it.hours, 0))
}));
const seriesMatrix = categories.map(cat => ({
...cat,
data: source.members.map(m => {
const hit = m.items.find(it => it.key === cat.key);
return hit ? roundHours(hit.hours) : 0;
})
}));
const totalHours = roundHours(memberView.reduce((s, m) => s + m.totalHours, 0));
const memberCount = memberView.length;
const averageHours = memberCount > 0 ? roundHours(totalHours / memberCount) : 0;
const expectedTotalHours = memberCount * source.expectedHoursPerMember;
const fillRate = expectedTotalHours > 0 ? clampPercent((totalHours / expectedTotalHours) * 100) : 0;
const lowThreshold = averageHours * 0.8;
const highThreshold = source.expectedHoursPerMember * 1.125;
const lowCount = memberView.filter(m => m.totalHours < lowThreshold).length;
const highCount = memberView.filter(m => m.totalHours > highThreshold).length;
let lowest: { memberName: string; hours: number } | null = null;
let highest: { memberName: string; hours: number } | null = null;
for (const m of memberView) {
if (!lowest || m.totalHours < lowest.hours) lowest = { memberName: m.memberName, hours: m.totalHours };
if (!highest || m.totalHours > highest.hours) highest = { memberName: m.memberName, hours: m.totalHours };
}
return {
weekStart: source.weekStart,
weekLabel,
members: memberView,
categories,
seriesMatrix,
totalHours,
averageHours,
expectedTotalHours,
fillRate,
lowCount,
highCount,
lowest,
highest
};
}
// === 团队负载C13 · teamLoad ===
export type WorkbenchTeamLoadLevel = 'high' | 'mid' | 'normal';
export type WorkbenchTeamLoadItemKind = 'project' | 'personal';
export interface WorkbenchTeamLoadItemSource {
/** projectId 或 'personal' */
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 {
/** 数据快照所属周(与 C12 对齐口径) */
weekStart: string;
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 {
weekStart: string;
/** 已按 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 {
weekStart: source.weekStart,
members: enriched,
scaleMax,
highCount,
midCount,
urgentTotal
};
}
export type WorkbenchHealthLevel = 'green' | 'yellow' | 'red';
@@ -502,26 +755,41 @@ export function buildWorkbenchProgressBars(source: readonly WorkbenchProgressBar
return source.map(s => ({ ...s, weekCompletionRate: Math.min(100, Math.max(0, Math.round(s.weekCompletionRate))) }));
}
export type WorkbenchFavoriteKind = 'product' | 'project' | 'requirement' | 'task';
export interface WorkbenchFavoriteItemSource {
export interface WorkbenchMyExecutionItemSource {
id: string;
kind: WorkbenchFavoriteKind;
title: string;
source: string;
executionName: string;
/** 关联项目 */
projectId: string;
projectName: string;
/** 执行状态编码projectExecution 域pending / active / paused / completed / cancelled */
statusCode: string;
/** 状态名(后端字典返回) */
statusName: string;
/** 优先级编码(取 RDMS_REQ_PRIORITY_DICT_CODE 字典) */
priority: string;
/** 计划起止 */
plannedStartDate: string | null;
plannedEndDate: string | null;
/** 实际起止 */
actualStartDate: string | null;
actualEndDate: string | null;
/** 进度0-100 整数) */
progressRate: number;
/** 关联项目需求 ID可选 */
projectRequirementId?: string;
/** 关联项目需求名称(可选) */
projectRequirementName?: string;
}
export interface WorkbenchFavoriteItem extends WorkbenchFavoriteItemSource {
kindLabel: string;
kindTone: 'sky' | 'emerald' | 'amber' | 'rose';
}
export type WorkbenchMyExecutionItem = WorkbenchMyExecutionItemSource;
export function buildWorkbenchFavoriteItems(source: readonly WorkbenchFavoriteItemSource[]): WorkbenchFavoriteItem[] {
const meta: Record<WorkbenchFavoriteKind, { label: string; tone: 'sky' | 'emerald' | 'amber' | 'rose' }> = {
product: { label: '产品', tone: 'sky' },
project: { label: '项目', tone: 'emerald' },
requirement: { label: '需求', tone: 'amber' },
task: { label: '任务', tone: 'rose' }
};
return source.map(s => ({ ...s, kindLabel: meta[s.kind].label, kindTone: meta[s.kind].tone }));
/** 过滤掉已完成 / 已取消 / 进度满 100 的执行(默认不在工作台呈现) */
export function buildWorkbenchMyExecutionItems(
source: readonly WorkbenchMyExecutionItemSource[]
): WorkbenchMyExecutionItem[] {
return source.filter(item => {
if (item.statusCode === 'completed' || item.statusCode === 'cancelled') return false;
if (item.progressRate >= 100) return false;
return true;
});
}