feat(版本): 记录计划需求进展活动

关键改动:

- 记录产品/UI 计划需求覆盖进展,并写入工作活动与实体日志

- 日报和小宝预警补充需求进展活动证据

- 调整版本列表与需求覆盖面板展示,并补充测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 15:35:36 +08:00
parent f82ec1ea6f
commit 04520bd8c5
13 changed files with 173 additions and 82 deletions

View File

@@ -10,7 +10,6 @@ import { getVersionDetail } from '@/lib/derive';
import type { Role } from '@/lib/stage'; import type { Role } from '@/lib/stage';
import { CapsuleStages } from '@/components/version/CapsuleStages'; import { CapsuleStages } from '@/components/version/CapsuleStages';
import { FilterSelect } from '@/components/FilterSelect'; import { FilterSelect } from '@/components/FilterSelect';
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health';
import { CHANGE_REASON_LABEL } from '@/lib/requirement'; import { CHANGE_REASON_LABEL } from '@/lib/requirement';
import { OVERTIME_REASON_LABEL } from '@/lib/overtime'; import { OVERTIME_REASON_LABEL } from '@/lib/overtime';
import { VersionRequirementsTab } from '@/components/version/VersionRequirementsTab'; 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 recommendationVersionReqs = requirements.filter((requirement) => requirement.versionId === version.id);
const recommendationVersionReqIds = new Set(recommendationVersionReqs.map((requirement) => requirement.id)); const recommendationVersionReqIds = new Set(recommendationVersionReqs.map((requirement) => requirement.id));
const recommendationVersionDevTasks = devTasks.filter((task) => recommendationVersionReqIds.has(task.requirementId)); const recommendationVersionDevTasks = devTasks.filter((task) => recommendationVersionReqIds.has(task.requirementId));
@@ -325,30 +321,7 @@ export default function VersionDetailPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 1. 风险详情 - 最上方 */} {/* 阶段概览 */}
{riskTags.length > 0 && (
<div className="rounded-xl border border-orange-200 bg-orange-50/40 p-4 max-h-[200px] overflow-y-auto">
<div className="flex items-center gap-2 mb-3">
<span className="text-[12px] font-semibold text-orange-700"></span>
<span className="text-[10px] text-orange-600"> {healthScore} · {HEALTH_LEVEL_LABEL[healthLevel]}</span>
</div>
<div className="space-y-2">
{riskTags.map((tag) => (
<div key={tag.key} className="flex gap-2.5 pb-2 border-b border-orange-100 last:border-b-0 last:pb-0">
<span className={`shrink-0 inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium h-fit mt-0.5 ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
<div className="flex-1 space-y-0.5 text-[11px]">
{tag.reason && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.reason}</div>}
{tag.suggestion && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.suggestion}</div>}
</div>
</div>
))}
</div>
</div>
)}
{/* 2. 阶段概览 */}
{(() => { {(() => {
const researchPlans = versionPlans.filter((p) => p.type === 'research'); const researchPlans = versionPlans.filter((p) => p.type === 'research');
const productPlans = versionPlans.filter((p) => p.type === 'product'); const productPlans = versionPlans.filter((p) => p.type === 'product');
@@ -843,12 +816,6 @@ export default function VersionDetailPage() {
); );
})()} })()}
</div> </div>
{/* 健康趋势 - 暂时不显示 */}
{/* <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 max-w-[320px]">
<span className="text-[11px] font-medium text-[var(--ink-muted)] mb-2 block">健康趋势</span>
<HealthTrend data={generateMockTrend(healthScore, 7)} />
</div> */}
</div> </div>
); );
})() })()

View File

@@ -17,7 +17,9 @@ import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS
import { STAGES } from '@/lib/stage'; import { STAGES } from '@/lib/stage';
import { ROLE_LABEL } from '@/lib/stage'; import { ROLE_LABEL } from '@/lib/stage';
import { buildVersionProgressMap } from '@/lib/version-progress'; 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'; import { Pagination, usePagination } from '@/components/Pagination';
type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4'; type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
@@ -38,6 +40,18 @@ const PRIORITY_ORDER: Record<Priority, number> = {
P4: 4, 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[] { function parseVersionNumber(name: string): number[] {
const match = name.match(/V([\d.]+)$/i); const match = name.match(/V([\d.]+)$/i);
if (!match) return [0]; if (!match) return [0];
@@ -131,6 +145,11 @@ function VersionsPageContent() {
}, [roles, user?.roleId]); }, [roles, user?.roleId]);
const allVersionsRaw = useMemo(() => flattenVersions(overview), [overview]); const allVersionsRaw = useMemo(() => flattenVersions(overview), [overview]);
const { risks: xiaobaoRisks } = useXiaobaoWarningRisks();
const xiaobaoRiskMap = useMemo(
() => new Map(xiaobaoRisks.map((risk) => [risk.versionId, risk])),
[xiaobaoRisks],
);
// 只显示当前用户参与的版本members为空时所有人可见超管可见全部 // 只显示当前用户参与的版本members为空时所有人可见超管可见全部
const allVersions = useMemo(() => allVersionsRaw.filter((v) => { const allVersions = useMemo(() => allVersionsRaw.filter((v) => {
if (isSuperAdmin) return true; if (isSuperAdmin) return true;
@@ -263,8 +282,7 @@ function VersionsPageContent() {
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th> <th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th> <th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th> <th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th> <th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th> <th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th> <th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th> <th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
@@ -276,6 +294,7 @@ function VersionsPageContent() {
key={v.id} key={v.id}
version={v} version={v}
overallProgress={versionProgressMap[v.id] ?? 0} overallProgress={versionProgressMap[v.id] ?? 0}
xiaobaoRisk={xiaobaoRiskMap.get(v.id)}
openMenuId={openMenuId} openMenuId={openMenuId}
setOpenMenuId={setOpenMenuId} setOpenMenuId={setOpenMenuId}
onNavigate={() => router.push(`/versions/${v.id}`)} onNavigate={() => router.push(`/versions/${v.id}`)}
@@ -299,17 +318,16 @@ function VersionsPageContent() {
interface VersionRowProps { interface VersionRowProps {
version: VersionWithContext; version: VersionWithContext;
overallProgress: number; overallProgress: number;
xiaobaoRisk?: XiaobaoVersionRisk;
openMenuId: string | null; openMenuId: string | null;
setOpenMenuId: (id: string | null) => void; setOpenMenuId: (id: string | null) => void;
onNavigate: () => void; onNavigate: () => void;
onAction: (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => 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 priority = (version.priority ?? 'P2') as Priority;
const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress); const riskScoreTone = xiaobaoRisk ? getRiskScoreTone(xiaobaoRisk.riskScore) : null;
const healthLevel = getHealthLevel(healthScore);
const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members);
const isMenuOpen = openMenuId === version.id; const isMenuOpen = openMenuId === version.id;
// Members: show first 2 with role label, then +N // Members: show first 2 with role label, then +N
@@ -347,29 +365,15 @@ function VersionRow({ version, overallProgress, openMenuId, setOpenMenuId, onNav
</div> </div>
</td> </td>
{/* 健康度 */} {/* 风险分 */}
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 text-[12px] font-semibold tabular-nums ${HEALTH_LEVEL_COLOR[healthLevel]}`}> {xiaobaoRisk && riskScoreTone ? (
<span className={`h-2 w-2 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} /> <span className={`inline-flex items-center gap-1.5 text-[12px] font-semibold tabular-nums ${RISK_SCORE_TEXT_CLASS[riskScoreTone]}`}>
{healthScore} <span className={`h-2 w-2 rounded-full ${RISK_SCORE_DOT_CLASS[riskScoreTone]}`} />
{xiaobaoRisk.riskScore}
</span> </span>
</td>
{/* 风险标签 */}
<td className="px-4 py-3">
{riskTags.length === 0 ? (
<span className="text-[11px] text-[var(--ink-muted)]">-</span>
) : ( ) : (
<div className="flex flex-wrap items-center gap-1"> <span className="text-[11px] text-[var(--ink-muted)]">-</span>
{riskTags.slice(0, 3).map((tag) => (
<span key={tag.key} className={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
))}
{riskTags.length > 3 && (
<span className="text-[10px] text-[var(--ink-muted)]">+{riskTags.length - 3}</span>
)}
</div>
)} )}
</td> </td>

View File

@@ -7,6 +7,7 @@ import type { Requirement } from '@/lib/requirement';
import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog, VersionPlanLogView } from '@/lib/version-plan'; import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog, VersionPlanLogView } from '@/lib/version-plan';
import { import {
canSaveRequirementCoverageDraft, canSaveRequirementCoverageDraft,
canOpenRequirementCoverageRecord,
getRequirementCoverage, getRequirementCoverage,
getRequirementCoverageStatus, getRequirementCoverageStatus,
getRequirementCoverageSummary, getRequirementCoverageSummary,
@@ -139,6 +140,7 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
const status = getRequirementCoverageStatus(plan, req.id); const status = getRequirementCoverageStatus(plan, req.id);
const coverage = getRequirementCoverage(plan, req.id); const coverage = getRequirementCoverage(plan, req.id);
const isEditing = editingRequirementId === req.id; const isEditing = editingRequirementId === req.id;
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit);
return ( return (
<div key={req.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]"> <div key={req.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
<div className="flex min-w-0 items-start gap-2"> <div className="flex min-w-0 items-start gap-2">
@@ -158,14 +160,16 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
</div> </div>
)} )}
</div> </div>
{canEdit && ( {canEdit && (canOpenRecord || isEditing) && (
<div className="flex shrink-0 items-center gap-1.5">
<button <button
type="button" type="button"
onClick={() => isEditing ? closeEditor() : openEditor(req)} onClick={() => isEditing ? closeEditor() : openEditor(req)}
className="shrink-0 rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]" className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
> >
{isEditing ? '收起' : '记录'} {isEditing ? '收起' : '记录'}
</button> </button>
</div>
)} )}
</div> </div>
{isEditing && ( {isEditing && (

View File

@@ -63,17 +63,28 @@ export function DailyReportPanel({ report }: Props) {
<span className="text-[10px] text-[var(--ink-muted)]">{items.length} </span> <span className="text-[10px] text-[var(--ink-muted)]">{items.length} </span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{items.map((item) => ( {items.map((item) => {
const completedContent = typeof item.metadata?.completedContent === 'string' ? item.metadata.completedContent : '';
const remainingContent = typeof item.metadata?.remainingContent === 'string' ? item.metadata.remainingContent : '';
return (
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-3"> <div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 break-words text-[12px] font-medium leading-5 text-[var(--ink)]">{item.summary}</p> <p className="min-w-0 flex-1 break-words text-[12px] font-medium leading-5 text-[var(--ink)]">{item.summary}</p>
<span className="shrink-0 text-[10px] text-[var(--ink-muted)]">{formatDateTimeShort(item.occurredAt)}</span> <span className="shrink-0 text-[10px] text-[var(--ink-muted)]">{formatDateTimeShort(item.occurredAt)}</span>
</div> </div>
{(completedContent || remainingContent) && (
<div className="mt-2 space-y-1 border-l-2 border-amber-200 pl-2 text-[11px] leading-5 text-[var(--ink-soft)]">
{completedContent && <div className="break-words">{completedContent}</div>}
{remainingContent && <div className="break-words text-amber-700">{remainingContent}</div>}
</div>
)}
{item.context && ( {item.context && (
<p className="mt-1 truncate text-[10px] text-[var(--ink-muted)]" title={item.context}>{item.context}</p> <p className="mt-1 truncate text-[10px] text-[var(--ink-muted)]" title={item.context}>{item.context}</p>
)} )}
</div> </div>
))} );
})}
</div> </div>
</section> </section>
); );

View File

@@ -12,6 +12,7 @@ export const WORK_ACTIVITY_ACTION_LABEL: Record<WorkActivityAction, string> = {
version_plan_created: '新建计划', version_plan_created: '新建计划',
version_plan_started: '开始计划', version_plan_started: '开始计划',
version_plan_completed: '完成计划', version_plan_completed: '完成计划',
version_plan_requirement_progress: '需求进度',
dev_task_created: '新建开发任务', dev_task_created: '新建开发任务',
dev_task_started: '开始开发', dev_task_started: '开始开发',
dev_task_self_testing: '进入自测', dev_task_self_testing: '进入自测',

View File

@@ -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]?.type, 'requirement_progress');
assert.equal(next.logs?.[0]?.actor, 'PM'); assert.equal(next.logs?.[0]?.actor, 'PM');
assert.equal(next.logs?.[0]?.requirementCode, 'QY0001'); 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', () => { 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!('partial', '完成主流程', '补充异常状态'), true);
assert.equal(canSaveRequirementCoverageDraft!('not_started'), false); 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);
});

View File

@@ -67,6 +67,8 @@ export interface VersionPlanLog {
requirementCode?: string; requirementCode?: string;
requirementTitle?: string; requirementTitle?: string;
coverageStatus?: RequirementCoverageStatus; coverageStatus?: RequirementCoverageStatus;
completedContent?: string;
remainingContent?: string;
aiTarget?: AgentDecomposeTarget; aiTarget?: AgentDecomposeTarget;
aiStatus?: AiDecomposeLogStatus; aiStatus?: AiDecomposeLogStatus;
} }
@@ -194,6 +196,13 @@ export function canSaveRequirementCoverageDraft(
return false; return false;
} }
export function canOpenRequirementCoverageRecord(
status: RequirementCoverageStatus,
canEdit: boolean,
): boolean {
return canEdit && status !== 'completed';
}
export function appendPlanLog(plan: VersionPlan, draft: PlanLogDraft): VersionPlanLog[] { export function appendPlanLog(plan: VersionPlan, draft: PlanLogDraft): VersionPlanLog[] {
const createdAt = draft.createdAt ?? new Date().toISOString(); const createdAt = draft.createdAt ?? new Date().toISOString();
const log: VersionPlanLog = { const log: VersionPlanLog = {
@@ -260,6 +269,8 @@ export function updateRequirementCoverage(
requirementCode: input.requirementCode, requirementCode: input.requirementCode,
requirementTitle: input.requirementTitle, requirementTitle: input.requirementTitle,
coverageStatus: input.status, coverageStatus: input.status,
completedContent: nextCoverage.completedContent,
remainingContent: nextCoverage.remainingContent,
}), }),
}; };
} }

View File

@@ -10,6 +10,7 @@ import {
makeDevTaskStatusActivity, makeDevTaskStatusActivity,
makeTestCaseStatusActivity, makeTestCaseStatusActivity,
makeVersionPlanCompletedActivity, makeVersionPlanCompletedActivity,
makeVersionPlanRequirementProgressActivity,
} from './work-activity-factory'; } from './work-activity-factory';
function devTask(patch: Partial<DevTask> = {}): DevTask { function devTask(patch: Partial<DevTask> = {}): DevTask {
@@ -105,6 +106,32 @@ test('makeVersionPlanCompletedActivity marks product plan completion as delivery
assert.equal(activity.summary, '完成产品方案:提交产品方案'); 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', () => { test('makeBugStatusActivity maps fixing and fixed actions', () => {
const fixing = makeBugStatusActivity(bug({ status: 'open' }), 'open', 'fixing', '张三'); const fixing = makeBugStatusActivity(bug({ status: 'open' }), 'open', 'fixing', '张三');
const fixed = makeBugStatusActivity(bug(), 'fixing', 'fixed', '张三'); const fixed = makeBugStatusActivity(bug(), 'fixing', 'fixed', '张三');

View File

@@ -1,7 +1,7 @@
import type { Bug, BugStatus } from './bug'; import type { Bug, BugStatus } from './bug';
import type { DevTask, DevTaskStatus } from './dev-task'; import type { DevTask, DevTaskStatus } from './dev-task';
import type { TestCase, TestCaseStatus } from './test-case'; 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'; import type { WorkActivityDraft } from './work-activity';
const PLAN_TYPE_LABEL: Record<VersionPlan['type'], string> = { const PLAN_TYPE_LABEL: Record<VersionPlan['type'], string> = {
@@ -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 { export function makeDevTaskCreatedActivity(task: DevTask, actorId: string): WorkActivityDraft {
return { return {
actorId, actorId,

View File

@@ -6,6 +6,7 @@ export type WorkActivityAction =
| 'version_plan_created' | 'version_plan_created'
| 'version_plan_started' | 'version_plan_started'
| 'version_plan_completed' | 'version_plan_completed'
| 'version_plan_requirement_progress'
| 'dev_task_created' | 'dev_task_created'
| 'dev_task_started' | 'dev_task_started'
| 'dev_task_self_testing' | 'dev_task_self_testing'

View File

@@ -6,6 +6,7 @@ import {
filterXiaobaoRiskWarnings, filterXiaobaoRiskWarnings,
filterXiaobaoWarningVersions, filterXiaobaoWarningVersions,
formatRemainingWork, formatRemainingWork,
getRiskScoreTone,
getXiaobaoWarningRiskCount, getXiaobaoWarningRiskCount,
sanitizeRiskInsight, sanitizeRiskInsight,
} from './xiaobao-warning-view'; } 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天'); 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', () => { test('sanitizeRiskInsight filters invalid page refresh suggested actions', () => {
const result = sanitizeRiskInsight({ const result = sanitizeRiskInsight({
summary: '风险上升', summary: '风险上升',

View File

@@ -26,6 +26,8 @@ export interface XiaobaoWarningRiskListFilter {
projectId?: string; projectId?: string;
} }
export type RiskScoreTone = 'danger' | 'warn' | 'ok';
export function filterXiaobaoWarningVersions( export function filterXiaobaoWarningVersions(
versions: VersionWithContext[], versions: VersionWithContext[],
filter: XiaobaoWarningVersionFilter, filter: XiaobaoWarningVersionFilter,
@@ -56,6 +58,12 @@ export function getXiaobaoWarningRiskCount(risks: XiaobaoVersionRisk[]): number
return filterXiaobaoRiskWarnings(risks).length; 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 { export function formatRemainingWork(hours: number): string {
const safeHours = Number.isFinite(hours) && hours > 0 ? hours : 0; const safeHours = Number.isFinite(hours) && hours > 0 ? hours : 0;
const days = safeHours / WORK_HOURS.hoursPerDay; const days = safeHours / WORK_HOURS.hoursPerDay;

View File

@@ -7,8 +7,10 @@ import type { PlanResultPayload } from '@/lib/version-plan-workflow';
import { import {
makeVersionPlanCompletedActivity, makeVersionPlanCompletedActivity,
makeVersionPlanCreatedActivity, makeVersionPlanCreatedActivity,
makeVersionPlanRequirementProgressActivity,
makeVersionPlanStartedActivity, makeVersionPlanStartedActivity,
} from '@/lib/work-activity-factory'; } from '@/lib/work-activity-factory';
import type { WorkActivityDraft } from '@/lib/work-activity';
import { useWorkActivityStore } from './useWorkActivityStore'; import { useWorkActivityStore } from './useWorkActivityStore';
const MOCK_PLANS: VersionPlan[] = []; const MOCK_PLANS: VersionPlan[] = [];
@@ -51,7 +53,7 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
updatePlan: (id, data) => { updatePlan: (id, data) => {
const now = new Date().toISOString(); const now = new Date().toISOString();
const activities: ReturnType<typeof makeVersionPlanStartedActivity>[] = []; const activities: WorkActivityDraft[] = [];
const plans = get().plans.map((p) => { const plans = get().plans.map((p) => {
if (p.id !== id) return p; if (p.id !== id) return p;
const patch = { ...data }; const patch = { ...data };
@@ -68,6 +70,12 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
if (p.status !== 'completed' && next.status === 'completed') { if (p.status !== 'completed' && next.status === 'completed') {
activities.push(makeVersionPlanCompletedActivity(next, next.owner)); 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; return next;
}); });
set({ plans }); set({ plans });