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 { 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 (
<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 productPlans = versionPlans.filter((p) => p.type === 'product');
@@ -843,12 +816,6 @@ export default function VersionDetailPage() {
);
})()}
</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>
);
})()

View File

@@ -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<Priority, number> = {
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() {
<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}
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
</div>
</td>
{/* 健康度 */}
{/* 风险分 */}
<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]}`}>
<span className={`h-2 w-2 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} />
{healthScore}
</span>
</td>
{/* 风险标签 */}
<td className="px-4 py-3">
{riskTags.length === 0 ? (
<span className="text-[11px] text-[var(--ink-muted)]">-</span>
{xiaobaoRisk && riskScoreTone ? (
<span className={`inline-flex items-center gap-1.5 text-[12px] font-semibold tabular-nums ${RISK_SCORE_TEXT_CLASS[riskScoreTone]}`}>
<span className={`h-2 w-2 rounded-full ${RISK_SCORE_DOT_CLASS[riskScoreTone]}`} />
{xiaobaoRisk.riskScore}
</span>
) : (
<div className="flex flex-wrap items-center gap-1">
{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>
<span className="text-[11px] text-[var(--ink-muted)]">-</span>
)}
</td>