修复模板分类批量导入格式识别

- 修复模板库批量导入只使用严格 JSON.parse 导致合法 colors/names 内容被误判非法的问题。

- 支持 [[colors], [names]] 数组格式和 {colors, names} 对象格式,并兼容带“批量导入分类:”前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容。

- 增加导入数据校验:分类名称不能为空,颜色必须是 0-255 的 RGB 三元组,缺失颜色继续使用默认灰色并保留预览提示。

- 补充 TemplateRegistry 测试,覆盖对象格式、数组格式和常见粘贴格式;同步 AGENTS 和 doc 中的模板导入说明与测试计划。

- 已验证 TemplateRegistry 组件测试、TypeScript 类型检查、生产构建、服务重启、前后端健康检查和 git diff 检查。
This commit is contained in:
2026-05-03 17:45:49 +08:00
parent 0b4e10209a
commit 7f8722410c
7 changed files with 111 additions and 17 deletions

View File

@@ -107,6 +107,45 @@ describe('TemplateRegistry', () => {
expect(screen.getByText('分类A')).toBeInTheDocument();
});
it('imports the array colors/names format into the edit modal', async () => {
apiMock.getTemplates.mockResolvedValueOnce([]);
render(<TemplateRegistry />);
fireEvent.click(screen.getByText('新建方案'));
fireEvent.click(screen.getByText('批量导入'));
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
target: { value: '[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]' },
});
expect(screen.getByText(/将导入 2 个分类maskid 从 1 开始分配/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '导入' }));
expect(screen.getByText('分类A')).toBeInTheDocument();
expect(screen.getByText('分类B')).toBeInTheDocument();
});
it('imports pasted color/name content with a label prefix and loose object keys', async () => {
apiMock.getTemplates.mockResolvedValueOnce([]);
render(<TemplateRegistry />);
fireEvent.click(screen.getByText('新建方案'));
fireEvent.click(screen.getByText('批量导入'));
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
target: {
value: `批量导入分类:{
colors: [[25500], [02550],],
names: ['分类A', '分类B',],
}`,
},
});
expect(screen.getByText(/将导入 2 个分类maskid 从 1 开始分配/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '导入' }));
expect(screen.getByText('分类A')).toBeInTheDocument();
expect(screen.getByText('分类B')).toBeInTheDocument();
});
it('shows JSON import errors inline instead of blocking alerts', async () => {
apiMock.getTemplates.mockResolvedValueOnce([]);

View File

@@ -24,6 +24,56 @@ function generateColor(index: number, total: number): string {
return hslToHex(hue, 75, 55);
}
const extractImportJsonText = (raw: string): string => {
let text = raw.trim();
const fenceMatch = text.match(/^```(?:json|javascript|js)?\s*([\s\S]*?)\s*```$/i);
if (fenceMatch) text = fenceMatch[1].trim();
const firstObject = text.indexOf('{');
const firstArray = text.indexOf('[');
const starts = [firstObject, firstArray].filter((index) => index >= 0);
if (starts.length > 0) {
const start = Math.min(...starts);
const end = Math.max(text.lastIndexOf('}'), text.lastIndexOf(']'));
if (end >= start) text = text.slice(start, end + 1);
}
return text;
};
const parseImportJson = (raw: string): any => {
const text = extractImportJsonText(raw);
const attempts = [
text,
text
.replace(/[]/g, ',')
.replace(/[]/g, ':')
.replace(/([{,]\s*)(colors|names|color|name)(\s*:)/gi, '$1"$2"$3')
.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, (_match, inner) => `"${inner.replace(/"/g, '\\"')}"`)
.replace(/,\s*([}\]])/g, '$1'),
];
for (const candidate of attempts) {
try {
return JSON.parse(candidate);
} catch {
// Try the next normalized candidate.
}
}
throw new Error('JSON 解析失败');
};
const normalizeImportRgb = (value: unknown, index: number): [number, number, number] => {
if (value === undefined) return [100, 100, 100];
if (!Array.isArray(value) || value.length < 3) {
throw new Error(`${index + 1} 个颜色不是 [R,G,B] 三元组`);
}
const rgb = value.slice(0, 3).map((part) => Number(part));
if (rgb.some((part) => !Number.isFinite(part) || part < 0 || part > 255)) {
throw new Error(`${index + 1} 个颜色值必须在 0-255 之间`);
}
return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2])];
};
export function TemplateRegistry() {
const templates = useStore((state) => state.templates);
const setTemplates = useStore((state) => state.setTemplates);
@@ -300,14 +350,9 @@ export function TemplateRegistry() {
};
const parseImportClasses = () => {
let data: any;
try {
data = JSON.parse(importText);
} catch {
throw new Error('JSON 解析失败');
}
let colors: number[][] = [];
let names: string[] = [];
const data = parseImportJson(importText);
let colors: unknown[] = [];
let names: unknown[] = [];
if (Array.isArray(data) && data.length === 2 && Array.isArray(data[0]) && Array.isArray(data[1])) {
colors = data[0];
@@ -315,17 +360,27 @@ export function TemplateRegistry() {
} else if (Array.isArray(data.colors) && Array.isArray(data.names)) {
colors = data.colors;
names = data.names;
} else if (Array.isArray(data.color) && Array.isArray(data.name)) {
colors = data.color;
names = data.name;
} else {
throw new Error('格式错误:请提供 [[colors...], [names...]] 或 {colors, names}');
}
if (names.length === 0) {
throw new Error('names 至少需要包含 1 个分类名称');
}
const firstMaskId = nextClassMaskId(editClasses);
const classes: TemplateClass[] = names.map((name: string, i: number) => {
const rgb = colors[i] || [100, 100, 100];
const classes: TemplateClass[] = names.map((name, i: number) => {
const normalizedName = String(name ?? '').trim();
if (!normalizedName) {
throw new Error(`${i + 1} 个分类名称为空`);
}
const rgb = normalizeImportRgb(colors[i], i);
const hex = `#${rgb[0].toString(16).padStart(2, '0')}${rgb[1].toString(16).padStart(2, '0')}${rgb[2].toString(16).padStart(2, '0')}`;
return {
id: `cls-import-${Date.now()}-${i}`,
name,
name: normalizedName,
color: hex,
zIndex: (names.length - i) * 10,
maskId: firstMaskId + i,