diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index 1235f58..08e22a2 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -10,7 +10,6 @@ import { getVersionDetail } from '@/lib/derive'; import type { Role } from '@/lib/stage'; import { CapsuleStages } from '@/components/version/CapsuleStages'; import { FilterSelect } from '@/components/FilterSelect'; -import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health'; import { CHANGE_REASON_LABEL } from '@/lib/requirement'; import { OVERTIME_REASON_LABEL } from '@/lib/overtime'; import { VersionRequirementsTab } from '@/components/version/VersionRequirementsTab'; @@ -168,9 +167,6 @@ export default function VersionDetailPage() { ); } - const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress); - const healthLevel = getHealthLevel(healthScore); - const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members); const recommendationVersionReqs = requirements.filter((requirement) => requirement.versionId === version.id); const recommendationVersionReqIds = new Set(recommendationVersionReqs.map((requirement) => requirement.id)); const recommendationVersionDevTasks = devTasks.filter((task) => recommendationVersionReqIds.has(task.requirementId)); @@ -325,30 +321,7 @@ export default function VersionDetailPage() { return (
- {/* 1. 风险详情 - 最上方 */} - {riskTags.length > 0 && ( -
-
- 风险详情 - 健康度 {healthScore} · {HEALTH_LEVEL_LABEL[healthLevel]} -
-
- {riskTags.map((tag) => ( -
- - {tag.label} - -
- {tag.reason &&
原因:{tag.reason}
} - {tag.suggestion &&
建议:{tag.suggestion}
} -
-
- ))} -
-
- )} - - {/* 2. 阶段概览 */} + {/* 阶段概览 */} {(() => { const researchPlans = versionPlans.filter((p) => p.type === 'research'); const productPlans = versionPlans.filter((p) => p.type === 'product'); @@ -843,12 +816,6 @@ export default function VersionDetailPage() { ); })()}
- - {/* 健康趋势 - 暂时不显示 */} - {/*
- 健康趋势 - -
*/} ); })() diff --git a/apps/web/app/versions/page.tsx b/apps/web/app/versions/page.tsx index 2988c22..1c91623 100644 --- a/apps/web/app/versions/page.tsx +++ b/apps/web/app/versions/page.tsx @@ -17,7 +17,9 @@ import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS import { STAGES } from '@/lib/stage'; import { ROLE_LABEL } from '@/lib/stage'; import { buildVersionProgressMap } from '@/lib/version-progress'; -import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, getTagStyle } from '@/lib/health'; +import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; +import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; +import { getRiskScoreTone } from '@/lib/xiaobao-warning-view'; import { Pagination, usePagination } from '@/components/Pagination'; type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4'; @@ -38,6 +40,18 @@ const PRIORITY_ORDER: Record = { P4: 4, }; +const RISK_SCORE_TEXT_CLASS = { + danger: 'text-red-600', + warn: 'text-orange-600', + ok: 'text-emerald-600', +} as const; + +const RISK_SCORE_DOT_CLASS = { + danger: 'bg-red-500', + warn: 'bg-orange-500', + ok: 'bg-emerald-500', +} as const; + function parseVersionNumber(name: string): number[] { const match = name.match(/V([\d.]+)$/i); if (!match) return [0]; @@ -131,6 +145,11 @@ function VersionsPageContent() { }, [roles, user?.roleId]); const allVersionsRaw = useMemo(() => flattenVersions(overview), [overview]); + const { risks: xiaobaoRisks } = useXiaobaoWarningRisks(); + const xiaobaoRiskMap = useMemo( + () => new Map(xiaobaoRisks.map((risk) => [risk.versionId, risk])), + [xiaobaoRisks], + ); // 只显示当前用户参与的版本(members为空时所有人可见;超管可见全部) const allVersions = useMemo(() => allVersionsRaw.filter((v) => { if (isSuperAdmin) return true; @@ -263,8 +282,7 @@ function VersionsPageContent() { 版本号 优先级 整体进度 - 健康度 - 风险标签 + 风险分 截止日期 负责人 操作 @@ -276,6 +294,7 @@ function VersionsPageContent() { key={v.id} version={v} overallProgress={versionProgressMap[v.id] ?? 0} + xiaobaoRisk={xiaobaoRiskMap.get(v.id)} openMenuId={openMenuId} setOpenMenuId={setOpenMenuId} onNavigate={() => router.push(`/versions/${v.id}`)} @@ -299,17 +318,16 @@ function VersionsPageContent() { interface VersionRowProps { version: VersionWithContext; overallProgress: number; + xiaobaoRisk?: XiaobaoVersionRisk; openMenuId: string | null; setOpenMenuId: (id: string | null) => void; onNavigate: () => void; onAction: (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => void; } -function VersionRow({ version, overallProgress, openMenuId, setOpenMenuId, onNavigate, onAction }: VersionRowProps) { +function VersionRow({ version, overallProgress, xiaobaoRisk, openMenuId, setOpenMenuId, onNavigate, onAction }: VersionRowProps) { const priority = (version.priority ?? 'P2') as Priority; - const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress); - const healthLevel = getHealthLevel(healthScore); - const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members); + const riskScoreTone = xiaobaoRisk ? getRiskScoreTone(xiaobaoRisk.riskScore) : null; const isMenuOpen = openMenuId === version.id; // Members: show first 2 with role label, then +N @@ -347,29 +365,15 @@ function VersionRow({ version, overallProgress, openMenuId, setOpenMenuId, onNav - {/* 健康度 */} + {/* 风险分 */} - - - {healthScore} - - - - {/* 风险标签 */} - - {riskTags.length === 0 ? ( - - + {xiaobaoRisk && riskScoreTone ? ( + + + {xiaobaoRisk.riskScore} + ) : ( -
- {riskTags.slice(0, 3).map((tag) => ( - - {tag.label} - - ))} - {riskTags.length > 3 && ( - +{riskTags.length - 3} - )} -
+ - )} diff --git a/apps/web/components/version/PlanRequirementCoveragePanel.tsx b/apps/web/components/version/PlanRequirementCoveragePanel.tsx index adf797d..305674f 100644 --- a/apps/web/components/version/PlanRequirementCoveragePanel.tsx +++ b/apps/web/components/version/PlanRequirementCoveragePanel.tsx @@ -7,6 +7,7 @@ import type { Requirement } from '@/lib/requirement'; import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog, VersionPlanLogView } from '@/lib/version-plan'; import { canSaveRequirementCoverageDraft, + canOpenRequirementCoverageRecord, getRequirementCoverage, getRequirementCoverageStatus, getRequirementCoverageSummary, @@ -139,6 +140,7 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr const status = getRequirementCoverageStatus(plan, req.id); const coverage = getRequirementCoverage(plan, req.id); const isEditing = editingRequirementId === req.id; + const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit); return (
@@ -158,14 +160,16 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
)}
- {canEdit && ( - + {canEdit && (canOpenRecord || isEditing) && ( +
+ +
)} {isEditing && ( diff --git a/apps/web/components/workspace/DailyReportPanel.tsx b/apps/web/components/workspace/DailyReportPanel.tsx index baf2a3f..964bd80 100644 --- a/apps/web/components/workspace/DailyReportPanel.tsx +++ b/apps/web/components/workspace/DailyReportPanel.tsx @@ -63,17 +63,28 @@ export function DailyReportPanel({ report }: Props) { {items.length} 条
- {items.map((item) => ( -
-
-

{item.summary}

- {formatDateTimeShort(item.occurredAt)} + {items.map((item) => { + const completedContent = typeof item.metadata?.completedContent === 'string' ? item.metadata.completedContent : ''; + const remainingContent = typeof item.metadata?.remainingContent === 'string' ? item.metadata.remainingContent : ''; + + return ( +
+
+

{item.summary}

+ {formatDateTimeShort(item.occurredAt)} +
+ {(completedContent || remainingContent) && ( +
+ {completedContent &&
已完成:{completedContent}
} + {remainingContent &&
剩余:{remainingContent}
} +
+ )} + {item.context && ( +

{item.context}

+ )}
- {item.context && ( -

{item.context}

- )} -
- ))} + ); + })}
); diff --git a/apps/web/lib/entity-activity-log.ts b/apps/web/lib/entity-activity-log.ts index 86b3879..909d2ba 100644 --- a/apps/web/lib/entity-activity-log.ts +++ b/apps/web/lib/entity-activity-log.ts @@ -12,6 +12,7 @@ export const WORK_ACTIVITY_ACTION_LABEL: Record = { version_plan_created: '新建计划', version_plan_started: '开始计划', version_plan_completed: '完成计划', + version_plan_requirement_progress: '需求进度', dev_task_created: '新建开发任务', dev_task_started: '开始开发', dev_task_self_testing: '进入自测', diff --git a/apps/web/lib/version-plan.test.ts b/apps/web/lib/version-plan.test.ts index b2fc021..26782ab 100644 --- a/apps/web/lib/version-plan.test.ts +++ b/apps/web/lib/version-plan.test.ts @@ -163,6 +163,8 @@ test('updates requirement coverage, syncs legacy completed ids, and creates a pl assert.equal(next.logs?.[0]?.type, 'requirement_progress'); assert.equal(next.logs?.[0]?.actor, 'PM'); assert.equal(next.logs?.[0]?.requirementCode, 'QY0001'); + assert.equal(next.logs?.[0]?.completedContent, '完成移动端主流程'); + assert.equal(next.logs?.[0]?.remainingContent, 'PC 端筛选规则未完成'); }); test('validates requirement coverage record drafts by status', () => { @@ -178,3 +180,16 @@ test('validates requirement coverage record drafts by status', () => { assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '补充异常状态'), true); assert.equal(canSaveRequirementCoverageDraft!('not_started'), false); }); + +test('hides coverage record action after a requirement is completed', () => { + const canOpenRequirementCoverageRecord = (versionPlan as any).canOpenRequirementCoverageRecord as undefined | (( + status: string, + canEdit: boolean, + ) => boolean); + assert.equal(typeof canOpenRequirementCoverageRecord, 'function'); + + assert.equal(canOpenRequirementCoverageRecord!('not_started', true), true); + assert.equal(canOpenRequirementCoverageRecord!('partial', true), true); + assert.equal(canOpenRequirementCoverageRecord!('completed', true), false); + assert.equal(canOpenRequirementCoverageRecord!('partial', false), false); +}); diff --git a/apps/web/lib/version-plan.ts b/apps/web/lib/version-plan.ts index 80b35a5..aee1b0c 100644 --- a/apps/web/lib/version-plan.ts +++ b/apps/web/lib/version-plan.ts @@ -67,6 +67,8 @@ export interface VersionPlanLog { requirementCode?: string; requirementTitle?: string; coverageStatus?: RequirementCoverageStatus; + completedContent?: string; + remainingContent?: string; aiTarget?: AgentDecomposeTarget; aiStatus?: AiDecomposeLogStatus; } @@ -194,6 +196,13 @@ export function canSaveRequirementCoverageDraft( return false; } +export function canOpenRequirementCoverageRecord( + status: RequirementCoverageStatus, + canEdit: boolean, +): boolean { + return canEdit && status !== 'completed'; +} + export function appendPlanLog(plan: VersionPlan, draft: PlanLogDraft): VersionPlanLog[] { const createdAt = draft.createdAt ?? new Date().toISOString(); const log: VersionPlanLog = { @@ -260,6 +269,8 @@ export function updateRequirementCoverage( requirementCode: input.requirementCode, requirementTitle: input.requirementTitle, coverageStatus: input.status, + completedContent: nextCoverage.completedContent, + remainingContent: nextCoverage.remainingContent, }), }; } diff --git a/apps/web/lib/work-activity-factory.test.ts b/apps/web/lib/work-activity-factory.test.ts index 5651980..c51d2c5 100644 --- a/apps/web/lib/work-activity-factory.test.ts +++ b/apps/web/lib/work-activity-factory.test.ts @@ -10,6 +10,7 @@ import { makeDevTaskStatusActivity, makeTestCaseStatusActivity, makeVersionPlanCompletedActivity, + makeVersionPlanRequirementProgressActivity, } from './work-activity-factory'; function devTask(patch: Partial = {}): DevTask { @@ -105,6 +106,32 @@ test('makeVersionPlanCompletedActivity marks product plan completion as delivery assert.equal(activity.summary, '完成产品方案:提交产品方案'); }); +test('makeVersionPlanRequirementProgressActivity records partial requirement coverage details', () => { + const activity = makeVersionPlanRequirementProgressActivity(plan({ type: 'ui', title: '绘制个人中心' }), { + id: 'log-1', + type: 'requirement_progress', + createdAt: '2026-06-30T03:00:00.000Z', + actor: '张三', + title: 'QY-001 个人中心更新为部分完成', + detail: '已完成:头像和基本信息\n剩余:安全设置入口', + requirementId: 'req-1', + requirementCode: 'QY-001', + requirementTitle: '个人中心', + coverageStatus: 'partial', + completedContent: '头像和基本信息', + remainingContent: '安全设置入口', + }); + + assert.equal(activity?.action, 'version_plan_requirement_progress'); + assert.equal(activity?.category, 'progress'); + assert.equal(activity?.sourceType, 'version_plan'); + assert.equal(activity?.sourceId, 'plan-1'); + assert.equal(activity?.occurredAt, '2026-06-30T03:00:00.000Z'); + assert.equal(activity?.summary, '推进UI设计需求:QY-001 个人中心(部分完成)'); + assert.equal(activity?.metadata?.completedContent, '头像和基本信息'); + assert.equal(activity?.metadata?.remainingContent, '安全设置入口'); +}); + test('makeBugStatusActivity maps fixing and fixed actions', () => { const fixing = makeBugStatusActivity(bug({ status: 'open' }), 'open', 'fixing', '张三'); const fixed = makeBugStatusActivity(bug(), 'fixing', 'fixed', '张三'); diff --git a/apps/web/lib/work-activity-factory.ts b/apps/web/lib/work-activity-factory.ts index 3eb88ae..dfad30b 100644 --- a/apps/web/lib/work-activity-factory.ts +++ b/apps/web/lib/work-activity-factory.ts @@ -1,7 +1,7 @@ import type { Bug, BugStatus } from './bug'; import type { DevTask, DevTaskStatus } from './dev-task'; import type { TestCase, TestCaseStatus } from './test-case'; -import type { VersionPlan } from './version-plan'; +import { REQUIREMENT_COVERAGE_LABEL, type VersionPlan, type VersionPlanLog } from './version-plan'; import type { WorkActivityDraft } from './work-activity'; const PLAN_TYPE_LABEL: Record = { @@ -46,6 +46,33 @@ export function makeVersionPlanCompletedActivity(plan: VersionPlan, actorId: str }; } +export function makeVersionPlanRequirementProgressActivity(plan: VersionPlan, log: VersionPlanLog): WorkActivityDraft | undefined { + if (log.type !== 'requirement_progress') return undefined; + if (plan.type !== 'product' && plan.type !== 'ui') return undefined; + + const requirementLabel = [log.requirementCode, log.requirementTitle].filter(Boolean).join(' ') || '需求'; + const statusLabel = log.coverageStatus ? REQUIREMENT_COVERAGE_LABEL[log.coverageStatus] : '进度更新'; + + return { + actorId: log.actor, + sourceType: 'version_plan', + sourceId: plan.id, + action: 'version_plan_requirement_progress', + category: 'progress', + title: plan.title, + summary: `推进${PLAN_TYPE_LABEL[plan.type]}需求:${requirementLabel}(${statusLabel})`, + occurredAt: log.createdAt, + metadata: { + requirementId: log.requirementId, + requirementCode: log.requirementCode, + requirementTitle: log.requirementTitle, + coverageStatus: log.coverageStatus, + completedContent: log.completedContent, + remainingContent: log.remainingContent, + }, + }; +} + export function makeDevTaskCreatedActivity(task: DevTask, actorId: string): WorkActivityDraft { return { actorId, diff --git a/apps/web/lib/work-activity.ts b/apps/web/lib/work-activity.ts index 2b546d6..9bbdd1f 100644 --- a/apps/web/lib/work-activity.ts +++ b/apps/web/lib/work-activity.ts @@ -6,6 +6,7 @@ export type WorkActivityAction = | 'version_plan_created' | 'version_plan_started' | 'version_plan_completed' + | 'version_plan_requirement_progress' | 'dev_task_created' | 'dev_task_started' | 'dev_task_self_testing' diff --git a/apps/web/lib/xiaobao-warning-view.test.ts b/apps/web/lib/xiaobao-warning-view.test.ts index 7d918d4..ce76375 100644 --- a/apps/web/lib/xiaobao-warning-view.test.ts +++ b/apps/web/lib/xiaobao-warning-view.test.ts @@ -6,6 +6,7 @@ import { filterXiaobaoRiskWarnings, filterXiaobaoWarningVersions, formatRemainingWork, + getRiskScoreTone, getXiaobaoWarningRiskCount, sanitizeRiskInsight, } from './xiaobao-warning-view'; @@ -135,6 +136,12 @@ test('formatRemainingWork keeps hours and adds work-day conversion', () => { assert.equal(formatRemainingWork(0.5), '0.5h / 0.1天'); }); +test('getRiskScoreTone follows Xiaobao warning score thresholds', () => { + assert.equal(getRiskScoreTone(75), 'danger'); + assert.equal(getRiskScoreTone(55), 'warn'); + assert.equal(getRiskScoreTone(54), 'ok'); +}); + test('sanitizeRiskInsight filters invalid page refresh suggested actions', () => { const result = sanitizeRiskInsight({ summary: '风险上升', diff --git a/apps/web/lib/xiaobao-warning-view.ts b/apps/web/lib/xiaobao-warning-view.ts index d7cd459..7b51d40 100644 --- a/apps/web/lib/xiaobao-warning-view.ts +++ b/apps/web/lib/xiaobao-warning-view.ts @@ -26,6 +26,8 @@ export interface XiaobaoWarningRiskListFilter { projectId?: string; } +export type RiskScoreTone = 'danger' | 'warn' | 'ok'; + export function filterXiaobaoWarningVersions( versions: VersionWithContext[], filter: XiaobaoWarningVersionFilter, @@ -56,6 +58,12 @@ export function getXiaobaoWarningRiskCount(risks: XiaobaoVersionRisk[]): number return filterXiaobaoRiskWarnings(risks).length; } +export function getRiskScoreTone(score: number): RiskScoreTone { + if (score >= 75) return 'danger'; + if (score >= 55) return 'warn'; + return 'ok'; +} + export function formatRemainingWork(hours: number): string { const safeHours = Number.isFinite(hours) && hours > 0 ? hours : 0; const days = safeHours / WORK_HOURS.hoursPerDay; diff --git a/apps/web/stores/useVersionPlanStore.ts b/apps/web/stores/useVersionPlanStore.ts index eb133d2..6f8e2a6 100644 --- a/apps/web/stores/useVersionPlanStore.ts +++ b/apps/web/stores/useVersionPlanStore.ts @@ -7,8 +7,10 @@ import type { PlanResultPayload } from '@/lib/version-plan-workflow'; import { makeVersionPlanCompletedActivity, makeVersionPlanCreatedActivity, + makeVersionPlanRequirementProgressActivity, makeVersionPlanStartedActivity, } from '@/lib/work-activity-factory'; +import type { WorkActivityDraft } from '@/lib/work-activity'; import { useWorkActivityStore } from './useWorkActivityStore'; const MOCK_PLANS: VersionPlan[] = []; @@ -51,7 +53,7 @@ export const useVersionPlanStore = create((set, get) => ({ updatePlan: (id, data) => { const now = new Date().toISOString(); - const activities: ReturnType[] = []; + const activities: WorkActivityDraft[] = []; const plans = get().plans.map((p) => { if (p.id !== id) return p; const patch = { ...data }; @@ -68,6 +70,12 @@ export const useVersionPlanStore = create((set, get) => ({ if (p.status !== 'completed' && next.status === 'completed') { activities.push(makeVersionPlanCompletedActivity(next, next.owner)); } + const previousLogIds = new Set((p.logs ?? []).map((log) => log.id)); + for (const log of next.logs ?? []) { + if (previousLogIds.has(log.id)) continue; + const activity = makeVersionPlanRequirementProgressActivity(next, log); + if (activity) activities.push(activity); + } return next; }); set({ plans });