From 06b4aad7a48f18ff4597f0e198fbadeab60bcce9 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Wed, 1 Jul 2026 14:32:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=89=88=E6=9C=AC):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=88=97=E8=A1=A8=E7=AD=9B=E9=80=89=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关键改动: - 抽取版本列表范围树和筛选规则 - 重构版本列表页的范围选择与优先级筛选 - 配置 Next dev 忽略测试编译输出,避免刷新干扰 Co-Authored-By: Codex GPT-5 --- apps/web/app/versions/page.tsx | 357 ++++++++++++++----------- apps/web/lib/next-config-watch.test.ts | 22 ++ apps/web/lib/version-list.test.ts | 46 ++++ apps/web/lib/version-list.ts | 86 ++++++ apps/web/next.config.js | 18 ++ 5 files changed, 375 insertions(+), 154 deletions(-) create mode 100644 apps/web/lib/next-config-watch.test.ts create mode 100644 apps/web/lib/version-list.test.ts create mode 100644 apps/web/lib/version-list.ts diff --git a/apps/web/app/versions/page.tsx b/apps/web/app/versions/page.tsx index 04144ea..5450341 100644 --- a/apps/web/app/versions/page.tsx +++ b/apps/web/app/versions/page.tsx @@ -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 = { - 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 ( @@ -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({ 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(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 ( -
-
-
-

版本

- {allVersions.length} -
- -
+
+ { + setSelectedScope(scope); + setPage(1); + }} + /> - {/* Filters */} -
-
- - 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)]" /> -
- - {/* Project dropdown */} -
- - {projectDropdownOpen && ( - <> -
setProjectDropdownOpen(false)} /> -
- - {allProjects.map((p) => ( - + {priorityDropdownOpen && ( + <> +
setPriorityDropdownOpen(false)} /> +
+ - ))} -
- - )} + {(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((priority) => ( + + ))} +
+ + )} +
- {/* Priority dropdown */} -
- - {priorityDropdownOpen && ( - <> -
setPriorityDropdownOpen(false)} /> -
- - {(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((p) => ( - - ))} -
- - )} -
-
- - {/* Content */} -
-
+
{filtered.length === 0 ? (

没有找到匹配的版本

-

尝试调整筛选条件

+

尝试调整左侧范围或右侧筛选

) : ( <> -
- - - - - - - - - - - - - - {paged.map((v) => ( - router.push(`/versions/${v.id}`)} - onAction={handleAction} - /> - ))} - -
版本号优先级整体进度风险分截止日期负责人操作
-
- +
+
+ + + + + + + + + + + + + + {paged.map((version) => ( + router.push(`/versions/${version.id}`)} + onAction={handleAction} + /> + ))} + +
版本号优先级整体进度风险分期望发版负责人操作
+
+
+ )}
-
+ {showModal && setShowModal(false)} onCreate={createVersion} currentUserName={currentUserName} />}
@@ -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 ( + + ); +} + function VersionRow({ version, overallProgress, xiaobaoRisk, openMenuId, setOpenMenuId, onNavigate, onAction }: VersionRowProps) { const priority = (version.priority ?? 'P2') as Priority; const riskScoreTone = xiaobaoRisk ? getRiskScoreTone(xiaobaoRisk.riskScore) : null; diff --git a/apps/web/lib/next-config-watch.test.ts b/apps/web/lib/next-config-watch.test.ts new file mode 100644 index 0000000..51a60ae --- /dev/null +++ b/apps/web/lib/next-config-watch.test.ts @@ -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/**']); +}); diff --git a/apps/web/lib/version-list.test.ts b/apps/web/lib/version-list.test.ts new file mode 100644 index 0000000..c1a72fe --- /dev/null +++ b/apps/web/lib/version-list.test.ts @@ -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 & Pick): 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'], + ); +}); diff --git a/apps/web/lib/version-list.ts b/apps/web/lib/version-list.ts new file mode 100644 index 0000000..0e39e4f --- /dev/null +++ b/apps/web/lib/version-list.ts @@ -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, 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; + }>(); + + 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; +} diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 6a74afd..54b4278 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,6 +1,24 @@ /** @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 = { 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;