feat(版本): 优化版本列表筛选体验
关键改动: - 抽取版本列表范围树和筛选规则 - 重构版本列表页的范围选择与优先级筛选 - 配置 Next dev 忽略测试编译输出,避免刷新干扰 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, Tag, Plus, X, ChevronDown, MoreHorizontal, Pause, Play, XCircle, Trash2 } from 'lucide-react';
|
||||
import { Search, Tag, Plus, X, ChevronDown, MoreHorizontal, Pause, Play, XCircle, Trash2, Layers, Package, FolderKanban } from 'lucide-react';
|
||||
import { useProductStore } from '@/stores/useProductStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
@@ -11,15 +11,15 @@ import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { flattenVersions, flattenProjects } from '@/lib/derive';
|
||||
import { flattenVersions } from '@/lib/derive';
|
||||
import type { VersionWithContext } from '@/lib/derive';
|
||||
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status';
|
||||
import { STAGES } from '@/lib/stage';
|
||||
import { VersionStatus } from '@/lib/version-status';
|
||||
import { ROLE_LABEL } from '@/lib/stage';
|
||||
import { buildVersionProgressMap } from '@/lib/version-progress';
|
||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||
import { getRiskScoreTone } from '@/lib/xiaobao-warning-view';
|
||||
import { buildVersionScopeTree, filterVersionsForList, type VersionListScope, type VersionProductNode } from '@/lib/version-list';
|
||||
import {
|
||||
VERSION_DEVELOPMENT_TYPE_OPTIONS,
|
||||
canSubmitNewVersionForm,
|
||||
@@ -60,24 +60,6 @@ const RISK_SCORE_DOT_CLASS = {
|
||||
ok: 'bg-emerald-500',
|
||||
} as const;
|
||||
|
||||
function parseVersionNumber(name: string): number[] {
|
||||
const match = name.match(/V([\d.]+)$/i);
|
||||
if (!match) return [0];
|
||||
return match[1].split('.').map(Number);
|
||||
}
|
||||
|
||||
function compareVersionsDesc(a: string, b: string): number {
|
||||
const va = parseVersionNumber(a);
|
||||
const vb = parseVersionNumber(b);
|
||||
const len = Math.max(va.length, vb.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const na = va[i] ?? 0;
|
||||
const nb = vb[i] ?? 0;
|
||||
if (nb !== na) return nb - na;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sortVersions(versions: VersionWithContext[]): VersionWithContext[] {
|
||||
return [...versions].sort((a, b) => {
|
||||
// Sort by priority desc (P0 first)
|
||||
@@ -89,28 +71,6 @@ function sortVersions(versions: VersionWithContext[]): VersionWithContext[] {
|
||||
});
|
||||
}
|
||||
|
||||
function getStageLabel(version: VersionWithContext): string {
|
||||
return getVersionDisplayStatus(version.status as VersionStatus, version.currentStage);
|
||||
}
|
||||
|
||||
const STAGE_ROLE_MAP: Record<string, string[]> = {
|
||||
requirement: ['product'],
|
||||
product_design: ['product'],
|
||||
ui_design: ['ui'],
|
||||
dev: ['frontend', 'backend'],
|
||||
testing: ['testing'],
|
||||
released: [],
|
||||
};
|
||||
|
||||
function getStageProgress(version: VersionWithContext): number {
|
||||
if (!version.currentStage || !version.progress) return 0;
|
||||
const roles = STAGE_ROLE_MAP[version.currentStage] || [];
|
||||
if (roles.length === 0) return 0;
|
||||
const items = roles.map((r) => version.progress!.find((p) => p.role === r)).filter(Boolean);
|
||||
if (items.length === 0) return 0;
|
||||
return Math.round(items.reduce((s, i) => s + i!.percent, 0) / items.length);
|
||||
}
|
||||
|
||||
export default function VersionsPage() {
|
||||
return (
|
||||
<RouteGuard permission="version:view">
|
||||
@@ -122,10 +82,10 @@ export default function VersionsPage() {
|
||||
function VersionsPageContent() {
|
||||
const router = useRouter();
|
||||
const { overview, fetchOverview, createVersion, updateVersion, deleteVersion } = useProductStore();
|
||||
const [search, setSearch] = useState('');
|
||||
const [projectFilter, setProjectFilter] = useState('all');
|
||||
const [versionKeyword, setVersionKeyword] = useState('');
|
||||
const [treeKeyword, setTreeKeyword] = useState('');
|
||||
const [selectedScope, setSelectedScope] = useState<VersionListScope>({ type: 'all' });
|
||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false);
|
||||
const [priorityDropdownOpen, setPriorityDropdownOpen] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
@@ -164,7 +124,7 @@ function VersionsPageContent() {
|
||||
if (!v.members || v.members.length === 0) return true;
|
||||
return v.members.some((m) => m.name === currentUserName);
|
||||
}), [allVersionsRaw, currentUserName, isSuperAdmin]);
|
||||
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
||||
const versionTree = useMemo(() => buildVersionScopeTree(allVersions, treeKeyword), [allVersions, treeKeyword]);
|
||||
|
||||
// Compute overall progress per version from actual data
|
||||
const versionProgressMap = useMemo(
|
||||
@@ -173,21 +133,27 @@ function VersionsPageContent() {
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = allVersions.filter((v) => {
|
||||
if (search && !v.name.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (projectFilter !== 'all' && v.projectName !== projectFilter) return false;
|
||||
return true;
|
||||
const list = filterVersionsForList(allVersions, {
|
||||
scope: selectedScope,
|
||||
keyword: versionKeyword,
|
||||
priority: priorityFilter as Priority | 'all',
|
||||
});
|
||||
|
||||
if (priorityFilter !== 'all') {
|
||||
list = list.filter(v => (v.priority ?? 'P2') === priorityFilter);
|
||||
}
|
||||
|
||||
return sortVersions(list);
|
||||
}, [allVersions, search, projectFilter, priorityFilter]);
|
||||
}, [allVersions, selectedScope, versionKeyword, priorityFilter]);
|
||||
|
||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
|
||||
|
||||
const selectedScopeLabel = useMemo(() => {
|
||||
if (selectedScope.type === 'product') {
|
||||
return allVersions.find((version) => version.productId === selectedScope.productId)?.productName ?? '产品';
|
||||
}
|
||||
if (selectedScope.type === 'project') {
|
||||
const found = allVersions.find((version) => version.projectId === selectedScope.projectId);
|
||||
return found ? `${found.productName} / ${found.projectName}` : '项目';
|
||||
}
|
||||
return '全部版本';
|
||||
}, [allVersions, selectedScope]);
|
||||
|
||||
const handleAction = async (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => {
|
||||
setOpenMenuId(null);
|
||||
if (action === 'delete') {
|
||||
@@ -207,116 +173,112 @@ function VersionsPageContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">版本</h1>
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{allVersions.length}</span>
|
||||
</div>
|
||||
<button onClick={() => setShowModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
新建版本
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<VersionScopeSidebar
|
||||
tree={versionTree}
|
||||
totalCount={allVersions.length}
|
||||
keyword={treeKeyword}
|
||||
selectedScope={selectedScope}
|
||||
onKeywordChange={setTreeKeyword}
|
||||
onSelectScope={(scope) => {
|
||||
setSelectedScope(scope);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
||||
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索版本号" className="h-8 w-64 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" />
|
||||
</div>
|
||||
|
||||
{/* Project dropdown */}
|
||||
<div className="relative">
|
||||
<button onClick={() => setProjectDropdownOpen(!projectDropdownOpen)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
|
||||
{projectFilter === 'all' ? '全部项目' : projectFilter}
|
||||
<ChevronDown className="h-3 w-3" strokeWidth={2} />
|
||||
<section className="flex min-w-0 flex-1 flex-col bg-[var(--bg)]">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">版本列表</h1>
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{filtered.length}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[11px] text-[var(--ink-muted)]">{selectedScopeLabel}</p>
|
||||
</div>
|
||||
<button onClick={() => setShowModal(true)} className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--accent-hover)]">
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
新建版本
|
||||
</button>
|
||||
{projectDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setProjectDropdownOpen(false)} />
|
||||
<div className="absolute left-0 top-full z-20 mt-1 min-w-[140px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||
<button onClick={() => { setProjectFilter('all'); setProjectDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${projectFilter === 'all' ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
|
||||
全部项目
|
||||
</button>
|
||||
{allProjects.map((p) => (
|
||||
<button key={p.id} onClick={() => { setProjectFilter(p.name); setProjectDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${projectFilter === p.name ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
|
||||
{p.name}
|
||||
</header>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
||||
<input
|
||||
value={versionKeyword}
|
||||
onChange={(event) => { setVersionKeyword(event.target.value); setPage(1); }}
|
||||
placeholder="搜索版本号"
|
||||
className="h-8 w-72 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button onClick={() => setPriorityDropdownOpen(!priorityDropdownOpen)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]">
|
||||
{priorityFilter === 'all' ? '全部优先级' : priorityFilter}
|
||||
<ChevronDown className="h-3 w-3" strokeWidth={2} />
|
||||
</button>
|
||||
{priorityDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setPriorityDropdownOpen(false)} />
|
||||
<div className="absolute left-0 top-full z-20 mt-1 min-w-[100px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||
<button onClick={() => { setPriorityFilter('all'); setPriorityDropdownOpen(false); setPage(1); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${priorityFilter === 'all' ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
|
||||
全部优先级
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((priority) => (
|
||||
<button key={priority} onClick={() => { setPriorityFilter(priority); setPriorityDropdownOpen(false); setPage(1); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${priorityFilter === priority ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
|
||||
{priority}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority dropdown */}
|
||||
<div className="relative">
|
||||
<button onClick={() => setPriorityDropdownOpen(!priorityDropdownOpen)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
|
||||
{priorityFilter === 'all' ? '全部优先级' : priorityFilter}
|
||||
<ChevronDown className="h-3 w-3" strokeWidth={2} />
|
||||
</button>
|
||||
{priorityDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setPriorityDropdownOpen(false)} />
|
||||
<div className="absolute left-0 top-full z-20 mt-1 min-w-[100px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||
<button onClick={() => { setPriorityFilter('all'); setPriorityDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${priorityFilter === 'all' ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
|
||||
全部优先级
|
||||
</button>
|
||||
{(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((p) => (
|
||||
<button key={p} onClick={() => { setPriorityFilter(p); setPriorityDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${priorityFilter === p ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto bg-[var(--bg)]">
|
||||
<div className="px-5 py-4">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
|
||||
<p className="text-[13px] font-medium text-[var(--ink-soft)]">没有找到匹配的版本</p>
|
||||
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]">尝试调整筛选条件</p>
|
||||
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]">尝试调整左侧范围或右侧筛选</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<table className="w-full text-left text-[13px]">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
||||
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((v) => (
|
||||
<VersionRow
|
||||
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}`)}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-[920px] w-full text-left text-[13px]">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
||||
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((version) => (
|
||||
<VersionRow
|
||||
key={version.id}
|
||||
version={version}
|
||||
overallProgress={versionProgressMap[version.id] ?? 0}
|
||||
xiaobaoRisk={xiaobaoRiskMap.get(version.id)}
|
||||
openMenuId={openMenuId}
|
||||
setOpenMenuId={setOpenMenuId}
|
||||
onNavigate={() => router.push(`/versions/${version.id}`)}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{showModal && <NewVersionModal overview={overview} onClose={() => setShowModal(false)} onCreate={createVersion} currentUserName={currentUserName} />}
|
||||
</div>
|
||||
@@ -333,6 +295,93 @@ interface VersionRowProps {
|
||||
onAction: (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => void;
|
||||
}
|
||||
|
||||
function VersionScopeSidebar({ tree, totalCount, keyword, selectedScope, onKeywordChange, onSelectScope }: {
|
||||
tree: VersionProductNode[];
|
||||
totalCount: number;
|
||||
keyword: string;
|
||||
selectedScope: VersionListScope;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onSelectScope: (scope: VersionListScope) => void;
|
||||
}) {
|
||||
const allActive = selectedScope.type === 'all';
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-72 shrink-0 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] px-4">
|
||||
<div>
|
||||
<h2 className="text-[14px] font-semibold text-[var(--ink)]">版本</h2>
|
||||
<p className="mt-0.5 text-[11px] text-[var(--ink-muted)]">按产品 / 项目查看版本</p>
|
||||
</div>
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{totalCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-b border-[var(--line)] p-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => onKeywordChange(event.target.value)}
|
||||
placeholder="搜索产品/项目"
|
||||
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-7 text-[12px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
{keyword && (
|
||||
<button onClick={() => onKeywordChange('')} className="absolute right-1.5 top-1/2 rounded p-0.5 -translate-y-1/2 hover:bg-[var(--bg-subtle)]" title="清除">
|
||||
<X className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<button
|
||||
onClick={() => onSelectScope({ type: 'all' })}
|
||||
className={`mb-1 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[12px] transition-colors ${allActive ? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
<span className="min-w-0 flex-1 truncate text-left">全部版本</span>
|
||||
<span className="shrink-0 rounded-full bg-[var(--bg-subtle)] px-1.5 text-[10px] tabular-nums text-[var(--ink-muted)]">{totalCount}</span>
|
||||
</button>
|
||||
|
||||
{tree.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-[var(--line)] px-3 py-8 text-center text-[12px] text-[var(--ink-muted)]">无匹配范围</div>
|
||||
) : (
|
||||
tree.map((product) => {
|
||||
const productActive = selectedScope.type === 'product' && selectedScope.productId === product.productId;
|
||||
return (
|
||||
<div key={product.productId} className="mb-1">
|
||||
<button
|
||||
onClick={() => onSelectScope({ type: 'product', productId: product.productId })}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[12px] transition-colors ${productActive ? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||
>
|
||||
<Package className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-left" title={product.productName}>{product.productName}</span>
|
||||
<span className="shrink-0 rounded-full bg-[var(--bg-subtle)] px-1.5 text-[10px] tabular-nums text-[var(--ink-muted)]">{product.count}</span>
|
||||
</button>
|
||||
<div className="mt-0.5 space-y-0.5 pl-4">
|
||||
{product.projects.map((project) => {
|
||||
const projectActive = selectedScope.type === 'project' && selectedScope.projectId === project.projectId;
|
||||
return (
|
||||
<button
|
||||
key={project.projectId}
|
||||
onClick={() => onSelectScope({ type: 'project', projectId: project.projectId })}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-[12px] transition-colors ${projectActive ? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]' : 'text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink-soft)]'}`}
|
||||
>
|
||||
<FolderKanban className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-left" title={project.projectName}>{project.projectName}</span>
|
||||
<span className="shrink-0 rounded-full bg-[var(--bg-subtle)] px-1.5 text-[10px] tabular-nums text-[var(--ink-muted)]">{project.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionRow({ version, overallProgress, xiaobaoRisk, openMenuId, setOpenMenuId, onNavigate, onAction }: VersionRowProps) {
|
||||
const priority = (version.priority ?? 'P2') as Priority;
|
||||
const riskScoreTone = xiaobaoRisk ? getRiskScoreTone(xiaobaoRisk.riskScore) : null;
|
||||
|
||||
Reference in New Issue
Block a user