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 { RouteGuard } from '@/components/auth/Guard';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { useProductStore } from '@/stores/useProductStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||||
@@ -11,15 +11,15 @@ import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
|||||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { flattenVersions, flattenProjects } from '@/lib/derive';
|
import { flattenVersions } from '@/lib/derive';
|
||||||
import type { VersionWithContext } 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 { VersionStatus } from '@/lib/version-status';
|
||||||
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 { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||||
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
import { getRiskScoreTone } from '@/lib/xiaobao-warning-view';
|
import { getRiskScoreTone } from '@/lib/xiaobao-warning-view';
|
||||||
|
import { buildVersionScopeTree, filterVersionsForList, type VersionListScope, type VersionProductNode } from '@/lib/version-list';
|
||||||
import {
|
import {
|
||||||
VERSION_DEVELOPMENT_TYPE_OPTIONS,
|
VERSION_DEVELOPMENT_TYPE_OPTIONS,
|
||||||
canSubmitNewVersionForm,
|
canSubmitNewVersionForm,
|
||||||
@@ -60,24 +60,6 @@ const RISK_SCORE_DOT_CLASS = {
|
|||||||
ok: 'bg-emerald-500',
|
ok: 'bg-emerald-500',
|
||||||
} as const;
|
} 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[] {
|
function sortVersions(versions: VersionWithContext[]): VersionWithContext[] {
|
||||||
return [...versions].sort((a, b) => {
|
return [...versions].sort((a, b) => {
|
||||||
// Sort by priority desc (P0 first)
|
// 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() {
|
export default function VersionsPage() {
|
||||||
return (
|
return (
|
||||||
<RouteGuard permission="version:view">
|
<RouteGuard permission="version:view">
|
||||||
@@ -122,10 +82,10 @@ export default function VersionsPage() {
|
|||||||
function VersionsPageContent() {
|
function VersionsPageContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { overview, fetchOverview, createVersion, updateVersion, deleteVersion } = useProductStore();
|
const { overview, fetchOverview, createVersion, updateVersion, deleteVersion } = useProductStore();
|
||||||
const [search, setSearch] = useState('');
|
const [versionKeyword, setVersionKeyword] = useState('');
|
||||||
const [projectFilter, setProjectFilter] = useState('all');
|
const [treeKeyword, setTreeKeyword] = useState('');
|
||||||
|
const [selectedScope, setSelectedScope] = useState<VersionListScope>({ type: 'all' });
|
||||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||||
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false);
|
|
||||||
const [priorityDropdownOpen, setPriorityDropdownOpen] = useState(false);
|
const [priorityDropdownOpen, setPriorityDropdownOpen] = useState(false);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||||
@@ -164,7 +124,7 @@ function VersionsPageContent() {
|
|||||||
if (!v.members || v.members.length === 0) return true;
|
if (!v.members || v.members.length === 0) return true;
|
||||||
return v.members.some((m) => m.name === currentUserName);
|
return v.members.some((m) => m.name === currentUserName);
|
||||||
}), [allVersionsRaw, currentUserName, isSuperAdmin]);
|
}), [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
|
// Compute overall progress per version from actual data
|
||||||
const versionProgressMap = useMemo(
|
const versionProgressMap = useMemo(
|
||||||
@@ -173,21 +133,27 @@ function VersionsPageContent() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let list = allVersions.filter((v) => {
|
const list = filterVersionsForList(allVersions, {
|
||||||
if (search && !v.name.toLowerCase().includes(search.toLowerCase())) return false;
|
scope: selectedScope,
|
||||||
if (projectFilter !== 'all' && v.projectName !== projectFilter) return false;
|
keyword: versionKeyword,
|
||||||
return true;
|
priority: priorityFilter as Priority | 'all',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (priorityFilter !== 'all') {
|
|
||||||
list = list.filter(v => (v.priority ?? 'P2') === priorityFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortVersions(list);
|
return sortVersions(list);
|
||||||
}, [allVersions, search, projectFilter, priorityFilter]);
|
}, [allVersions, selectedScope, versionKeyword, priorityFilter]);
|
||||||
|
|
||||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
|
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') => {
|
const handleAction = async (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => {
|
||||||
setOpenMenuId(null);
|
setOpenMenuId(null);
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
@@ -207,116 +173,112 @@ function VersionsPageContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full overflow-hidden">
|
||||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
<VersionScopeSidebar
|
||||||
<div className="flex items-center gap-2.5">
|
tree={versionTree}
|
||||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">版本</h1>
|
totalCount={allVersions.length}
|
||||||
<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>
|
keyword={treeKeyword}
|
||||||
</div>
|
selectedScope={selectedScope}
|
||||||
<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">
|
onKeywordChange={setTreeKeyword}
|
||||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
onSelectScope={(scope) => {
|
||||||
新建版本
|
setSelectedScope(scope);
|
||||||
</button>
|
setPage(1);
|
||||||
</header>
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Filters */}
|
<section className="flex min-w-0 flex-1 flex-col bg-[var(--bg)]">
|
||||||
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||||
<div className="relative">
|
<div className="min-w-0">
|
||||||
<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} />
|
<div className="flex items-center gap-2.5">
|
||||||
<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)]" />
|
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">版本列表</h1>
|
||||||
</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)]">{filtered.length}</span>
|
||||||
|
</div>
|
||||||
{/* Project dropdown */}
|
<p className="mt-0.5 truncate text-[11px] text-[var(--ink-muted)]">{selectedScopeLabel}</p>
|
||||||
<div className="relative">
|
</div>
|
||||||
<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">
|
<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)]">
|
||||||
{projectFilter === 'all' ? '全部项目' : projectFilter}
|
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||||
<ChevronDown className="h-3 w-3" strokeWidth={2} />
|
新建版本
|
||||||
</button>
|
</button>
|
||||||
{projectDropdownOpen && (
|
</header>
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setProjectDropdownOpen(false)} />
|
<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="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)]">
|
<div className="relative">
|
||||||
<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)]'}`}>
|
<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
|
||||||
</button>
|
value={versionKeyword}
|
||||||
{allProjects.map((p) => (
|
onChange={(event) => { setVersionKeyword(event.target.value); setPage(1); }}
|
||||||
<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)]'}`}>
|
placeholder="搜索版本号"
|
||||||
{p.name}
|
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>
|
</button>
|
||||||
))}
|
{(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((priority) => (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Priority dropdown */}
|
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
||||||
<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">
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
|
<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="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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||||
<table className="w-full text-left text-[13px]">
|
<div className="overflow-x-auto">
|
||||||
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
<table className="min-w-[920px] w-full text-left text-[13px]">
|
||||||
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
|
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
||||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">版本号</th>
|
<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>
|
<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>
|
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">操作</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{paged.map((v) => (
|
<tbody>
|
||||||
<VersionRow
|
{paged.map((version) => (
|
||||||
key={v.id}
|
<VersionRow
|
||||||
version={v}
|
key={version.id}
|
||||||
overallProgress={versionProgressMap[v.id] ?? 0}
|
version={version}
|
||||||
xiaobaoRisk={xiaobaoRiskMap.get(v.id)}
|
overallProgress={versionProgressMap[version.id] ?? 0}
|
||||||
openMenuId={openMenuId}
|
xiaobaoRisk={xiaobaoRiskMap.get(version.id)}
|
||||||
setOpenMenuId={setOpenMenuId}
|
openMenuId={openMenuId}
|
||||||
onNavigate={() => router.push(`/versions/${v.id}`)}
|
setOpenMenuId={setOpenMenuId}
|
||||||
onAction={handleAction}
|
onNavigate={() => router.push(`/versions/${version.id}`)}
|
||||||
/>
|
onAction={handleAction}
|
||||||
))}
|
/>
|
||||||
</tbody>
|
))}
|
||||||
</table>
|
</tbody>
|
||||||
</div>
|
</table>
|
||||||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
</div>
|
||||||
|
</div>
|
||||||
|
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{showModal && <NewVersionModal overview={overview} onClose={() => setShowModal(false)} onCreate={createVersion} currentUserName={currentUserName} />}
|
{showModal && <NewVersionModal overview={overview} onClose={() => setShowModal(false)} onCreate={createVersion} currentUserName={currentUserName} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -333,6 +295,93 @@ interface VersionRowProps {
|
|||||||
onAction: (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => void;
|
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) {
|
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 riskScoreTone = xiaobaoRisk ? getRiskScoreTone(xiaobaoRisk.riskScore) : null;
|
const riskScoreTone = xiaobaoRisk ? getRiskScoreTone(xiaobaoRisk.riskScore) : null;
|
||||||
|
|||||||
22
apps/web/lib/next-config-watch.test.ts
Normal file
22
apps/web/lib/next-config-watch.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const nextConfig = require('../../next.config.js');
|
||||||
|
|
||||||
|
test('next dev ignores test compiler output to avoid route refresh interruptions', () => {
|
||||||
|
assert.equal(typeof nextConfig.webpack, 'function');
|
||||||
|
|
||||||
|
const config = nextConfig.webpack({ watchOptions: { ignored: ['**/node_modules/**'] } }, { dev: true });
|
||||||
|
const ignored = config.watchOptions?.ignored;
|
||||||
|
const ignoredList = Array.isArray(ignored) ? ignored : [ignored];
|
||||||
|
|
||||||
|
assert.ok(ignoredList.includes('**/.tmp-test/**'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('next dev keeps watch ignored entries compatible with webpack schema', () => {
|
||||||
|
const config = nextConfig.webpack({ watchOptions: { ignored: [/node_modules/] } }, { dev: true });
|
||||||
|
const ignored = config.watchOptions?.ignored;
|
||||||
|
const ignoredList = Array.isArray(ignored) ? ignored : [ignored];
|
||||||
|
|
||||||
|
assert.deepEqual(ignoredList, ['**/.tmp-test/**']);
|
||||||
|
});
|
||||||
46
apps/web/lib/version-list.test.ts
Normal file
46
apps/web/lib/version-list.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildVersionScopeTree, filterVersionsForList } from './version-list';
|
||||||
|
import type { VersionWithContext } from './derive';
|
||||||
|
|
||||||
|
function version(input: Partial<VersionWithContext> & Pick<VersionWithContext, 'id' | 'name' | 'productId' | 'productName' | 'projectId' | 'projectName'>): VersionWithContext {
|
||||||
|
return {
|
||||||
|
status: 'developing',
|
||||||
|
releaseDate: null,
|
||||||
|
createdAt: '2026-07-01T00:00:00.000Z',
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = [
|
||||||
|
version({ id: 'v-a', name: 'AlphaV1.0', productId: 'prod-a', productName: '产品A', projectId: 'proj-a', projectName: 'Alpha', priority: 'P1' }),
|
||||||
|
version({ id: 'v-b', name: 'AlphaV1.1', productId: 'prod-a', productName: '产品A', projectId: 'proj-a', projectName: 'Alpha', priority: 'P2' }),
|
||||||
|
version({ id: 'v-c', name: 'BetaV1.0', productId: 'prod-a', productName: '产品A', projectId: 'proj-b', projectName: 'Beta', priority: 'P0' }),
|
||||||
|
version({ id: 'v-d', name: 'GammaV1.0', productId: 'prod-b', productName: '产品B', projectId: 'proj-c', projectName: 'Gamma', priority: 'P3' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
test('buildVersionScopeTree groups versions by product and project with counts', () => {
|
||||||
|
const tree = buildVersionScopeTree(versions, '');
|
||||||
|
|
||||||
|
assert.equal(tree.length, 2);
|
||||||
|
assert.equal(tree[0].count, 3);
|
||||||
|
assert.equal(tree[0].projects[0].count, 2);
|
||||||
|
assert.equal(tree[0].projects[1].count, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildVersionScopeTree filters the tree by product project or version keyword', () => {
|
||||||
|
assert.deepEqual(buildVersionScopeTree(versions, 'Beta').map((node) => node.projects.map((project) => project.projectName)), [['Beta']]);
|
||||||
|
assert.deepEqual(buildVersionScopeTree(versions, 'GammaV1.0').map((node) => node.productName), ['产品B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterVersionsForList applies scope keyword and priority filters', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
filterVersionsForList(versions, { scope: { type: 'project', projectId: 'proj-a' }, keyword: '1.1', priority: 'all' }).map((item) => item.id),
|
||||||
|
['v-b'],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
filterVersionsForList(versions, { scope: { type: 'product', productId: 'prod-a' }, keyword: '', priority: 'P0' }).map((item) => item.id),
|
||||||
|
['v-c'],
|
||||||
|
);
|
||||||
|
});
|
||||||
86
apps/web/lib/version-list.ts
Normal file
86
apps/web/lib/version-list.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Priority, VersionWithContext } from './derive';
|
||||||
|
|
||||||
|
export type VersionListScope =
|
||||||
|
| { type: 'all' }
|
||||||
|
| { type: 'product'; productId: string }
|
||||||
|
| { type: 'project'; projectId: string };
|
||||||
|
|
||||||
|
export interface VersionProjectNode {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionProductNode {
|
||||||
|
productId: string;
|
||||||
|
productName: string;
|
||||||
|
count: number;
|
||||||
|
projects: VersionProjectNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionListFilter {
|
||||||
|
scope: VersionListScope;
|
||||||
|
keyword: string;
|
||||||
|
priority: Priority | 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesKeyword(values: Array<string | undefined | null>, keyword: string): boolean {
|
||||||
|
const normalized = keyword.trim().toLowerCase();
|
||||||
|
if (!normalized) return true;
|
||||||
|
return values.some((value) => value?.toLowerCase().includes(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVersionScopeTree(versions: VersionWithContext[], keyword: string): VersionProductNode[] {
|
||||||
|
const normalized = keyword.trim().toLowerCase();
|
||||||
|
const productMap = new Map<string, {
|
||||||
|
productId: string;
|
||||||
|
productName: string;
|
||||||
|
projects: Map<string, VersionProjectNode>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const version of versions) {
|
||||||
|
const productMatches = matchesKeyword([version.productName], normalized);
|
||||||
|
const projectMatches = matchesKeyword([version.projectName], normalized);
|
||||||
|
const versionMatches = matchesKeyword([version.name], normalized);
|
||||||
|
if (normalized && !productMatches && !projectMatches && !versionMatches) continue;
|
||||||
|
|
||||||
|
let product = productMap.get(version.productId);
|
||||||
|
if (!product) {
|
||||||
|
product = {
|
||||||
|
productId: version.productId,
|
||||||
|
productName: version.productName,
|
||||||
|
projects: new Map(),
|
||||||
|
};
|
||||||
|
productMap.set(version.productId, product);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = product.projects.get(version.projectId) ?? {
|
||||||
|
projectId: version.projectId,
|
||||||
|
projectName: version.projectName,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
project.count += 1;
|
||||||
|
product.projects.set(version.projectId, project);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(productMap.values()).map((product) => {
|
||||||
|
const projects = Array.from(product.projects.values()).sort((a, b) => a.projectName.localeCompare(b.projectName, 'zh-Hans-CN'));
|
||||||
|
return {
|
||||||
|
productId: product.productId,
|
||||||
|
productName: product.productName,
|
||||||
|
count: projects.reduce((sum, project) => sum + project.count, 0),
|
||||||
|
projects,
|
||||||
|
};
|
||||||
|
}).sort((a, b) => a.productName.localeCompare(b.productName, 'zh-Hans-CN'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterVersionsForList(versions: VersionWithContext[], filter: VersionListFilter): VersionWithContext[] {
|
||||||
|
const filtered = versions.filter((version) => {
|
||||||
|
if (filter.scope.type === 'product' && version.productId !== filter.scope.productId) return false;
|
||||||
|
if (filter.scope.type === 'project' && version.projectId !== filter.scope.projectId) return false;
|
||||||
|
if (filter.priority !== 'all' && (version.priority ?? 'P2') !== filter.priority) return false;
|
||||||
|
return matchesKeyword([version.name], filter.keyword);
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
@@ -1,6 +1,24 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
const TEST_OUTPUT_GLOB = '**/.tmp-test/**';
|
||||||
|
|
||||||
|
function toIgnoredList(ignored) {
|
||||||
|
if (!ignored) return [];
|
||||||
|
const entries = Array.isArray(ignored) ? ignored : [ignored];
|
||||||
|
return entries.filter((entry) => typeof entry === 'string' && entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
transpilePackages: ['@ftb/shared'],
|
transpilePackages: ['@ftb/shared'],
|
||||||
|
webpack(config, { dev }) {
|
||||||
|
if (dev) {
|
||||||
|
const ignored = toIgnoredList(config.watchOptions?.ignored);
|
||||||
|
config.watchOptions = {
|
||||||
|
...config.watchOptions,
|
||||||
|
ignored: ignored.includes(TEST_OUTPUT_GLOB) ? ignored : [...ignored, TEST_OUTPUT_GLOB],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
Reference in New Issue
Block a user