feat(版本): 记录计划需求进展活动
关键改动: - 记录产品/UI 计划需求覆盖进展,并写入工作活动与实体日志 - 日报和小宝预警补充需求进展活动证据 - 调整版本列表与需求覆盖面板展示,并补充测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: '进入自测',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', '张三');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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: '风险上升',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user