feat(版本): 优化版本列表筛选体验
关键改动: - 抽取版本列表范围树和筛选规则 - 重构版本列表页的范围选择与优先级筛选 - 配置 Next dev 忽略测试编译输出,避免刷新干扰 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user