refactor: 压平数据模型 — 版本为执行主线,需求降为语义标签
核心改动: - TestCase 主归属 versionId,requirementId 改为可选语义标签 - Bug 新增 versionId(直接挂版本)+ requirementId(可选追溯) - 新增 calcVersionExecutionStatus() 版本执行态推导函数 规则:DevTask全部提测→已提测,TestCase全部通过+Bug全关→可发布 - 概览页展示推导出的执行态标签 - BugTab 改为直接 versionId 查询(不再走 testCase→req 链路) - 统计卡片"参与人员"替换为"未关闭Bug" 心智模型: 版本(执行线)→ DevTask/TestCase/Bug 需求(语义线)→ 解释为什么做,不驱动流程 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
||||
import { getVersionDetail } from '@/lib/derive';
|
||||
import { STAGES } from '@/lib/stage';
|
||||
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status';
|
||||
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG, getVersionDisplayStatus, calcVersionExecutionStatus, EXECUTION_STATUS_LABEL, EXECUTION_STATUS_COLOR } from '@/lib/version-status';
|
||||
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||
import { MemberChips } from '@/components/version/MemberChips';
|
||||
import { HealthTrend, generateMockTrend } from '@/components/version/HealthTrend';
|
||||
@@ -194,8 +194,17 @@ export default function VersionDetailPage() {
|
||||
const devTaskTodo = versionDevTasks.filter((t) => t.status === 'todo').length;
|
||||
const devTaskInProgress = versionDevTasks.filter((t) => t.status === 'in_progress').length;
|
||||
const devTaskBlocked = versionDevTasks.filter((t) => t.isBlocked).length;
|
||||
const mockBugTotal = Math.max(Math.floor(versionReqs.length * 1.5), 2);
|
||||
const mockBugOpen = Math.max(Math.floor(mockBugTotal * 0.3), 1);
|
||||
const versionBugs = bugs.filter((b) => b.versionId === version.id);
|
||||
const bugOpenCount = versionBugs.filter((b) => b.status === 'open' || b.status === 'fixing').length;
|
||||
|
||||
// 版本执行态推导
|
||||
const versionTCs = testCases.filter((c) => c.versionId === version.id);
|
||||
const executionStatus = calcVersionExecutionStatus({
|
||||
manualStatus: version.status,
|
||||
devTasks: versionDevTasks,
|
||||
testCases: versionTCs,
|
||||
bugs: versionBugs,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -209,6 +218,9 @@ export default function VersionDetailPage() {
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full ${VERSION_STATUS_BG[version.status]}`}>
|
||||
{getVersionDisplayStatus(version.status, version.currentStage)}
|
||||
</span>
|
||||
<span className={`text-[11px] font-medium px-2 py-0.5 rounded-full ${EXECUTION_STATUS_COLOR[executionStatus]}`}>
|
||||
{EXECUTION_STATUS_LABEL[executionStatus]}
|
||||
</span>
|
||||
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold tabular-nums px-2 py-0.5 rounded-full ${healthLevel === 'critical' ? 'bg-red-50' : healthLevel === 'risk' ? 'bg-orange-50' : healthLevel === 'attention' ? 'bg-amber-50' : 'bg-emerald-50'} ${HEALTH_LEVEL_COLOR[healthLevel]}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} />
|
||||
{healthScore} {HEALTH_LEVEL_LABEL[healthLevel]}
|
||||
@@ -230,7 +242,7 @@ export default function VersionDetailPage() {
|
||||
<StatCard label="待开发" value={devTaskTodo} />
|
||||
<StatCard label="开发中" value={devTaskInProgress} accent />
|
||||
<StatCard label="阻塞中" value={devTaskBlocked} warn={devTaskBlocked > 0} />
|
||||
<StatCard label="参与人员" value={version.members?.length ?? 0} />
|
||||
<StatCard label="未关闭Bug" value={bugOpenCount} warn={bugOpenCount > 0} />
|
||||
</div>
|
||||
|
||||
{/* 风险详情(胶囊条上方) */}
|
||||
@@ -308,7 +320,7 @@ export default function VersionDetailPage() {
|
||||
}
|
||||
|
||||
// 测试阶段进度由 TestCase 完成率驱动
|
||||
const versionTestCases = testCases.filter((c) => versionReqs.some((r) => r.id === c.requirementId));
|
||||
const versionTestCases = testCases.filter((c) => c.versionId === version.id);
|
||||
if (versionTestCases.length > 0) {
|
||||
const executed = versionTestCases.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
|
||||
const testPercent = Math.round((executed / versionTestCases.length) * 100);
|
||||
|
||||
@@ -48,7 +48,9 @@ export function BugCreateModal({ testCaseId, onClose }: Props) {
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit) return;
|
||||
createBug({
|
||||
versionId: tc?.versionId || '',
|
||||
testCaseId,
|
||||
requirementId: tc?.requirementId || undefined,
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
severity,
|
||||
|
||||
@@ -26,15 +26,10 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||
|
||||
// 该版本的所有 Bug(通过测试用例 → 需求 关联)
|
||||
const versionCases = useMemo(
|
||||
() => testCases.filter((c) => requirementIds.includes(c.requirementId)),
|
||||
[testCases, requirementIds],
|
||||
);
|
||||
const versionCaseIds = useMemo(() => new Set(versionCases.map((c) => c.id)), [versionCases]);
|
||||
// 该版本的所有 Bug(直接通过 versionId 查询)
|
||||
const versionBugs = useMemo(
|
||||
() => bugs.filter((b) => versionCaseIds.has(b.testCaseId)),
|
||||
[bugs, versionCaseIds],
|
||||
() => bugs.filter((b) => b.versionId === versionId),
|
||||
[bugs, versionId],
|
||||
);
|
||||
|
||||
// 统计
|
||||
|
||||
@@ -9,11 +9,12 @@ import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import type { Priority } from '@/lib/derive';
|
||||
|
||||
interface Props {
|
||||
versionId: string;
|
||||
requirementIds: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TestCaseCreateModal({ requirementIds, onClose }: Props) {
|
||||
export function TestCaseCreateModal({ versionId, requirementIds, onClose }: Props) {
|
||||
const { createTestCase } = useTestCaseStore();
|
||||
const { requirements } = useRequirementStore();
|
||||
const { members } = useMemberStore();
|
||||
@@ -30,12 +31,13 @@ export function TestCaseCreateModal({ requirementIds, onClose }: Props) {
|
||||
const [assigneeId, setAssigneeId] = useState(user?.name || '');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const canSubmit = title.trim() && requirementId;
|
||||
const canSubmit = title.trim();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit) return;
|
||||
createTestCase({
|
||||
requirementId,
|
||||
versionId,
|
||||
requirementId: requirementId || undefined,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
priority,
|
||||
@@ -58,8 +60,9 @@ export function TestCaseCreateModal({ requirementIds, onClose }: Props) {
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" placeholder="例如:正常排班" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">所属需求 *</label>
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">关联需求(可选)</label>
|
||||
<select value={requirementId} onChange={(e) => { setRequirementId(e.target.value); const r = versionReqs.find((x) => x.id === e.target.value); if (r) setPriority(r.priority); }} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
||||
<option value="">不关联需求</option>
|
||||
{versionReqs.map((r) => <option key={r.id} value={r.id}>{r.code} {r.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||
|
||||
const versionCases = useMemo(
|
||||
() => testCases.filter((c) => requirementIds.includes(c.requirementId)),
|
||||
() => testCases.filter((c) => c.versionId === versionId),
|
||||
[testCases, requirementIds],
|
||||
);
|
||||
|
||||
@@ -52,13 +52,14 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||
const [bugForCaseId, setBugForCaseId] = useState<string | null>(null);
|
||||
|
||||
// 按需求分组
|
||||
// 按需求分组(无需求的归为"通用")
|
||||
const groupedByReq = useMemo(() => {
|
||||
const map = new Map<string, typeof paged>();
|
||||
for (const c of paged) {
|
||||
const list = map.get(c.requirementId) || [];
|
||||
const key = c.requirementId || '__none__';
|
||||
const list = map.get(key) || [];
|
||||
list.push(c);
|
||||
map.set(c.requirementId, list);
|
||||
map.set(key, list);
|
||||
}
|
||||
return map;
|
||||
}, [paged]);
|
||||
@@ -133,7 +134,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
|
||||
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
||||
|
||||
{showCreate && <TestCaseCreateModal requirementIds={requirementIds} onClose={() => setShowCreate(false)} />}
|
||||
{showCreate && <TestCaseCreateModal versionId={versionId} requirementIds={requirementIds} onClose={() => setShowCreate(false)} />}
|
||||
{selectedCaseId && <TestCaseDetailDrawer testCaseId={selectedCaseId} onClose={() => setSelectedCaseId(null)} onCreateBug={(id) => { setBugForCaseId(id); }} />}
|
||||
{bugForCaseId && <BugCreateModal testCaseId={bugForCaseId} onClose={() => setBugForCaseId(null)} />}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,9 @@ export type BugSeverity = 'critical' | 'major' | 'minor' | 'trivial';
|
||||
export interface Bug {
|
||||
id: string;
|
||||
bugNo: string;
|
||||
versionId: string;
|
||||
testCaseId: string;
|
||||
requirementId?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
severity: BugSeverity;
|
||||
|
||||
@@ -5,7 +5,8 @@ export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'bloc
|
||||
export interface TestCase {
|
||||
id: string;
|
||||
caseNo: string;
|
||||
requirementId: string;
|
||||
versionId: string;
|
||||
requirementId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority: Priority;
|
||||
|
||||
@@ -40,3 +40,66 @@ export function getVersionDisplayStatus(status: VersionStatus, currentStage?: st
|
||||
}
|
||||
return VERSION_STATUS_LABEL[status];
|
||||
}
|
||||
|
||||
// === 版本执行态推导 ===
|
||||
|
||||
export type VersionExecutionStatus = 'planning' | 'developing' | 'submitted' | 'testing' | 'ready' | 'released';
|
||||
|
||||
export const EXECUTION_STATUS_LABEL: Record<VersionExecutionStatus, string> = {
|
||||
planning: '规划中',
|
||||
developing: '开发中',
|
||||
submitted: '已提测',
|
||||
testing: '测试中',
|
||||
ready: '可发布',
|
||||
released: '已发布',
|
||||
};
|
||||
|
||||
export const EXECUTION_STATUS_COLOR: Record<VersionExecutionStatus, string> = {
|
||||
planning: 'bg-zinc-100 text-zinc-600',
|
||||
developing: 'bg-blue-50 text-blue-600',
|
||||
submitted: 'bg-orange-50 text-orange-600',
|
||||
testing: 'bg-purple-50 text-purple-600',
|
||||
ready: 'bg-emerald-50 text-emerald-600',
|
||||
released: 'bg-zinc-100 text-zinc-500',
|
||||
};
|
||||
|
||||
export interface ExecutionInput {
|
||||
manualStatus?: VersionStatus;
|
||||
devTasks: { status: string }[];
|
||||
testCases: { status: string }[];
|
||||
bugs: { status: string }[];
|
||||
}
|
||||
|
||||
export function calcVersionExecutionStatus(input: ExecutionInput): VersionExecutionStatus {
|
||||
const { manualStatus, devTasks, testCases, bugs } = input;
|
||||
|
||||
if (manualStatus === 'released') return 'released';
|
||||
if (manualStatus === 'planned' || manualStatus === 'paused' || manualStatus === 'closed') return 'planning';
|
||||
|
||||
// fallback: 没有 DevTask 则停留 planning
|
||||
if (devTasks.length === 0) return 'planning';
|
||||
|
||||
const allDevDone = devTasks.every((t) => t.status === 'done');
|
||||
const allDevSubmitted = devTasks.every((t) => t.status === 'submitted' || t.status === 'done');
|
||||
|
||||
// 判断是否可发布
|
||||
if (testCases.length > 0) {
|
||||
const allPassed = testCases.every((c) => c.status === 'passed');
|
||||
const allBugsClosed = bugs.every((b) => b.status === 'closed' || b.status === 'rejected');
|
||||
if (allPassed && allBugsClosed) return 'ready';
|
||||
}
|
||||
|
||||
// 测试中(有用例在执行)
|
||||
if (allDevSubmitted && testCases.length > 0) {
|
||||
const hasRunning = testCases.some((c) => c.status === 'running' || c.status === 'passed' || c.status === 'failed' || c.status === 'blocked');
|
||||
if (hasRunning) return 'testing';
|
||||
}
|
||||
|
||||
// 已提测
|
||||
if (allDevSubmitted) return 'submitted';
|
||||
|
||||
// 开发完成但未全部提测
|
||||
if (allDevDone) return 'submitted';
|
||||
|
||||
return 'developing';
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ interface BugState {
|
||||
deleteBug: (id: string) => void;
|
||||
changeStatus: (id: string, to: BugStatus, extra?: { resolution?: string }) => { ok: boolean; message?: string };
|
||||
getByTestCase: (caseId: string) => Bug[];
|
||||
getByVersion: (versionId: string) => Bug[];
|
||||
getByAssignee: (assigneeId: string) => Bug[];
|
||||
}
|
||||
|
||||
@@ -86,6 +87,10 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
return get().bugs.filter((b) => b.testCaseId === caseId);
|
||||
},
|
||||
|
||||
getByVersion: (versionId) => {
|
||||
return get().bugs.filter((b) => b.versionId === versionId);
|
||||
},
|
||||
|
||||
getByAssignee: (assigneeId) => {
|
||||
return get().bugs.filter((b) => b.assigneeId === assigneeId);
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ interface TestCaseState {
|
||||
deleteTestCase: (id: string) => void;
|
||||
changeStatus: (id: string, to: TestCaseStatus, extra?: { failReason?: string; blockReason?: string }) => { ok: boolean; message?: string };
|
||||
getByRequirement: (reqId: string) => TestCase[];
|
||||
getByVersion: (versionId: string) => TestCase[];
|
||||
getByAssignee: (assigneeId: string) => TestCase[];
|
||||
}
|
||||
|
||||
@@ -89,6 +90,10 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
return get().testCases.filter((c) => c.requirementId === reqId);
|
||||
},
|
||||
|
||||
getByVersion: (versionId) => {
|
||||
return get().testCases.filter((c) => c.versionId === versionId);
|
||||
},
|
||||
|
||||
getByAssignee: (assigneeId) => {
|
||||
return get().testCases.filter((c) => c.assigneeId === assigneeId);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user