feat(版本): 优化概览与只读状态

关键改动:

- 增加需求排序和版本只读状态规则及测试

- 完善版本概览阶段耗时、项目页和工作台展示

- 优化小宝预警请求节流、建议状态和风险过滤

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 18:18:18 +08:00
parent 3d3d56697a
commit eef3c8f000
32 changed files with 1072 additions and 289 deletions

View File

@@ -27,8 +27,12 @@ import { formatShortTime, formatWorkHours } from '@/lib/work-hours';
import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case';
import { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
import { getWorkspaceDailyReport } from '@/lib/workspace-daily-report';
import { VERSION_STATUS_BG, VERSION_STATUS_LABEL, getVersionReadonlyNotice, isVersionReadonly, type VersionStatus } from '@/lib/version-status';
type TabKey = 'all' | 'plan_research' | 'plan_product' | 'plan_ui' | 'devTask' | 'testCase' | 'bug';
type WorkspaceVersionContext = { id: string; name: string; productName: string; projectName: string; status: VersionStatus };
type TreeVersion = { id: string; name: string; status: VersionStatus; pendingCount: number };
type ProductTree = Map<string, { name: string; projects: Map<string, { name: string; versions: TreeVersion[] }> }>;
const TABS: { key: TabKey; label: string; icon: any }[] = [
{ key: 'all', label: '全部', icon: ClipboardList },
@@ -77,8 +81,8 @@ export default function WorkspacePage() {
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const versionMap = useMemo(() => {
const map = new Map<string, { id: string; name: string; productName: string; projectName: string }>();
allVersions.forEach((v) => map.set(v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName }));
const map = new Map<string, WorkspaceVersionContext>();
allVersions.forEach((v) => map.set(v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName, status: v.status }));
return map;
}, [allVersions]);
@@ -96,7 +100,7 @@ export default function WorkspacePage() {
// 构建树:只显示跟自己有关的产品/项目/版本
const tree = useMemo(() => {
const myVersionIds = new Set(workItems.map((i) => i.versionId).filter(Boolean));
const productMap = new Map<string, { name: string; projects: Map<string, { name: string; versions: { id: string; name: string; pendingCount: number }[] }> }>();
const productMap: ProductTree = new Map();
allVersions.forEach((v) => {
if (!myVersionIds.has(v.id)) return;
@@ -106,7 +110,7 @@ export default function WorkspacePage() {
const proj = prod.projects.get(v.projectName)!;
const pending = workItems.filter((i) => i.versionId === v.id && !i.completed).length;
if (!proj.versions.find((ver) => ver.id === v.id)) {
proj.versions.push({ id: v.id, name: v.name, pendingCount: pending });
proj.versions.push({ id: v.id, name: v.name, status: v.status, pendingCount: pending });
}
});
@@ -150,6 +154,9 @@ export default function WorkspacePage() {
const pendingCount = pendingByTab.all;
const completedCount = (selectedVersionId ? workItems.filter((i) => i.versionId === selectedVersionId) : workItems).filter((i) => i.completed).length;
const selectedVersion = selectedVersionId ? versionMap.get(selectedVersionId) : undefined;
const drawerVersionStatus = drawerItem ? versionMap.get(drawerItem.versionId)?.status : undefined;
const drawerReadOnly = drawerVersionStatus ? isVersionReadonly(drawerVersionStatus) : false;
return (
<div className="flex h-full">
@@ -216,9 +223,10 @@ export default function WorkspacePage() {
<header className="flex h-14 shrink-0 items-center border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<h2 className="text-[14px] font-semibold text-[var(--ink)]">{TABS.find((t) => t.key === activeTab)?.label}</h2>
<span className="ml-2 text-[12px] text-[var(--ink-muted)]">{filteredItems.length} </span>
{selectedVersionId && (
<span className="ml-3 text-[11px] text-[var(--accent)] bg-[var(--accent-soft)] px-2 py-0.5 rounded-full">
{versionMap.get(selectedVersionId)?.name}
{selectedVersion && (
<span className="ml-3 inline-flex items-center gap-1.5 rounded-full bg-[var(--accent-soft)] px-2 py-0.5 text-[11px] text-[var(--accent)]">
<span>{selectedVersion.name}</span>
<VersionStatusTag status={selectedVersion.status} />
</span>
)}
</header>
@@ -234,6 +242,7 @@ export default function WorkspacePage() {
<WorkItemCard
key={item.id}
item={item}
versionStatus={versionMap.get(item.versionId)?.status}
onNavigate={() => item.versionId && router.push(`/versions/${item.versionId}`)}
onClick={() => setDrawerItem(item)}
/>
@@ -247,16 +256,16 @@ export default function WorkspacePage() {
{/* Detail Drawers */}
{drawerItem && (drawerItem.type === 'plan_research' || drawerItem.type === 'plan_product' || drawerItem.type === 'plan_ui') && (
<PlanDetailDrawer planId={drawerItem.id} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
<PlanDetailDrawer planId={drawerItem.id} readOnly={drawerReadOnly} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
)}
{drawerItem && drawerItem.type === 'devTask' && (
<DevTaskDetailDrawer taskId={drawerItem.id} allTaskIds={devTasks.map((t) => t.id)} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
<DevTaskDetailDrawer taskId={drawerItem.id} allTaskIds={devTasks.map((t) => t.id)} readOnly={drawerReadOnly} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
)}
{drawerItem && drawerItem.type === 'testCase' && (
<TestCaseDetailDrawer testCaseId={drawerItem.id} onClose={() => setDrawerItem(null)} onCreateBug={(tcId) => { setDrawerItem(null); setBugFromTestCaseId(tcId); }} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
<TestCaseDetailDrawer testCaseId={drawerItem.id} readOnly={drawerReadOnly} onClose={() => setDrawerItem(null)} onCreateBug={(tcId) => { if (!drawerReadOnly) { setDrawerItem(null); setBugFromTestCaseId(tcId); } }} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
)}
{drawerItem && drawerItem.type === 'bug' && (
<BugDetailDrawer bugId={drawerItem.id} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
<BugDetailDrawer bugId={drawerItem.id} readOnly={drawerReadOnly} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
)}
{bugFromTestCaseId && (
<BugCreateModal testCaseId={bugFromTestCaseId} onClose={() => setBugFromTestCaseId(null)} />
@@ -267,7 +276,7 @@ export default function WorkspacePage() {
function ProductNode({ name, prod, selectedVersionId, onSelect }: {
name: string;
prod: { name: string; projects: Map<string, { name: string; versions: { id: string; name: string; pendingCount: number }[] }> };
prod: { name: string; projects: Map<string, { name: string; versions: TreeVersion[] }> };
selectedVersionId: string | null;
onSelect: (id: string | null) => void;
}) {
@@ -287,7 +296,7 @@ function ProductNode({ name, prod, selectedVersionId, onSelect }: {
function ProjectNode({ name, versions, selectedVersionId, onSelect }: {
name: string;
versions: { id: string; name: string; pendingCount: number }[];
versions: TreeVersion[];
selectedVersionId: string | null;
onSelect: (id: string | null) => void;
}) {
@@ -306,6 +315,7 @@ function ProjectNode({ name, versions, selectedVersionId, onSelect }: {
className={`w-full flex items-center gap-1.5 ml-5 px-2 py-1 rounded text-[11px] transition-colors ${selectedVersionId === v.id ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
<span className="flex-1 text-left truncate">{v.name}</span>
<VersionStatusTag status={v.status} readonlyOnly />
{v.pendingCount > 0 && (
<span className="text-[9px] bg-red-500 text-white rounded-full px-1.5 min-w-[16px] text-center">{v.pendingCount}</span>
)}
@@ -315,7 +325,27 @@ function ProjectNode({ name, versions, selectedVersionId, onSelect }: {
);
}
function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigate: () => void; onClick: () => void }) {
function VersionStatusTag({ status, readonlyOnly = false }: { status?: VersionStatus; readonlyOnly?: boolean }) {
if (!status) return null;
const readonlyNotice = getVersionReadonlyNotice(status);
if (readonlyOnly && !readonlyNotice) return null;
return (
<span
className={`shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-medium leading-none ${VERSION_STATUS_BG[status]}`}
title={readonlyNotice ?? VERSION_STATUS_LABEL[status]}
>
{VERSION_STATUS_LABEL[status]}
</span>
);
}
function WorkItemCard({ item, versionStatus, onNavigate, onClick }: {
item: WorkItem;
versionStatus?: VersionStatus;
onNavigate: () => void;
onClick: () => void;
}) {
const statusBadge = getStatusBadge(item);
const isDevTask = item.type === 'devTask';
const devTaskRaw = isDevTask ? (item.raw as any) : null;
@@ -358,6 +388,7 @@ function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigat
<span>{item.projectName}</span>
<span className="text-[var(--line)]">/</span>
<button onClick={(e) => { e.stopPropagation(); onNavigate(); }} className="text-[var(--accent)] hover:underline">{item.versionName}</button>
<VersionStatusTag status={versionStatus} readonlyOnly />
{isDevTask && devTimeRange && (
<>
<span className="ml-2 tabular-nums">{devTimeRange}</span>