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 });