202 lines
9.2 KiB
TypeScript
202 lines
9.2 KiB
TypeScript
export type CategoryGroup = 'development' | 'testing' | 'implementation' | 'other';
|
|
|
|
export const DEFAULT_TEST_CATEGORY_ID = 'cat-test-functional';
|
|
|
|
export interface TaskCategory {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
group: CategoryGroup;
|
|
color?: string;
|
|
sortOrder: number;
|
|
isSystem: boolean;
|
|
}
|
|
|
|
export const CATEGORY_GROUP_LABEL: Record<CategoryGroup, string> = {
|
|
development: '开发',
|
|
testing: '测试',
|
|
implementation: '实施',
|
|
other: '其他',
|
|
};
|
|
|
|
export const PRESET_CATEGORIES: TaskCategory[] = [
|
|
{ id: 'cat-1', code: 'frontend_development', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true },
|
|
{ id: 'cat-frontend-interaction', code: 'frontend_interaction', name: '前端交互', group: 'development', color: '#0ea5e9', sortOrder: 2, isSystem: true },
|
|
{ id: 'cat-2', code: 'backend_development', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 3, isSystem: true },
|
|
{ id: 'cat-backend-api', code: 'backend_api', name: '后端接口', group: 'development', color: '#2563eb', sortOrder: 4, isSystem: true },
|
|
{ id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 5, isSystem: true },
|
|
{ id: 'cat-4', code: 'api_integration', name: '接口联调', group: 'development', color: '#06b6d4', sortOrder: 6, isSystem: true },
|
|
{ id: DEFAULT_TEST_CATEGORY_ID, code: 'test_functional', name: '功能测试', group: 'testing', color: '#22c55e', sortOrder: 20, isSystem: true },
|
|
{ id: 'cat-test-ui-interaction', code: 'test_ui_interaction', name: 'UI交互测试', group: 'testing', color: '#06b6d4', sortOrder: 21, isSystem: true },
|
|
{ id: 'cat-test-form-validation', code: 'test_form_validation', name: '表单校验测试', group: 'testing', color: '#84cc16', sortOrder: 22, isSystem: true },
|
|
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', color: '#14b8a6', sortOrder: 23, isSystem: true },
|
|
{ id: 'cat-test-data-consistency', code: 'test_data_consistency', name: '数据一致性测试', group: 'testing', color: '#10b981', sortOrder: 24, isSystem: true },
|
|
{ id: 'cat-test-permission', code: 'test_permission', name: '权限测试', group: 'testing', color: '#8b5cf6', sortOrder: 25, isSystem: true },
|
|
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', color: '#f97316', sortOrder: 26, isSystem: true },
|
|
{ id: 'cat-test-boundary', code: 'test_boundary', name: '边界值测试', group: 'testing', color: '#f59e0b', sortOrder: 27, isSystem: true },
|
|
{ id: 'cat-test-state-flow', code: 'test_state_flow', name: '状态流转测试', group: 'testing', color: '#6366f1', sortOrder: 28, isSystem: true },
|
|
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', color: '#a855f7', sortOrder: 29, isSystem: true },
|
|
{ id: 'cat-test-regression', code: 'test_regression', name: '回归测试', group: 'testing', color: '#64748b', sortOrder: 30, isSystem: true },
|
|
{ id: 'cat-5', code: 'data_processing', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 40, isSystem: true },
|
|
{ id: 'cat-6', code: 'implementation_support', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 41, isSystem: true },
|
|
{ id: 'cat-other-doc', code: 'documentation', name: '文档', group: 'other', color: '#64748b', sortOrder: 60, isSystem: true },
|
|
];
|
|
|
|
const LEGACY_CODE_BY_ID: Record<string, string> = {
|
|
'cat-1': 'frontend_development',
|
|
'cat-2': 'backend_development',
|
|
'cat-3': 'database_schema',
|
|
'cat-4': 'api_integration',
|
|
'cat-5': 'data_processing',
|
|
'cat-6': 'implementation_support',
|
|
};
|
|
|
|
function slugifyCategoryName(name: string, index: number): string {
|
|
return (
|
|
name
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_')
|
|
.replace(/^_+|_+$/g, '') || `category_${index + 1}`
|
|
);
|
|
}
|
|
|
|
function normalizeCategoryNameForMatch(name: string): string {
|
|
return name.trim().toLowerCase().replace(/\s+/g, '');
|
|
}
|
|
|
|
function hashCategoryName(name: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < name.length; i++) {
|
|
hash = (hash * 31 + name.charCodeAt(i)) | 0;
|
|
}
|
|
return Math.abs(hash).toString(36);
|
|
}
|
|
|
|
function uniqueCategoryCode(base: string, categories: TaskCategory[]): string {
|
|
if (!categories.some((category) => category.code === base)) return base;
|
|
let suffix = 2;
|
|
while (categories.some((category) => category.code === `${base}_${suffix}`)) suffix++;
|
|
return `${base}_${suffix}`;
|
|
}
|
|
|
|
function uniqueAiCategoryId(name: string, categories: TaskCategory[]): string {
|
|
const base = `cat-ai-${hashCategoryName(name)}-${categories.length + 1}`;
|
|
if (!categories.some((category) => category.id === base)) return base;
|
|
let suffix = 2;
|
|
while (categories.some((category) => category.id === `${base}-${suffix}`)) suffix++;
|
|
return `${base}-${suffix}`;
|
|
}
|
|
|
|
export function normalizeTaskCategory(category: Partial<TaskCategory> | undefined, index = 0): TaskCategory {
|
|
const preset = PRESET_CATEGORIES.find((c) => c.id === category?.id);
|
|
const name = category?.name || preset?.name || `分类 ${index + 1}`;
|
|
return {
|
|
id: category?.id || preset?.id || `cat-${index + 1}`,
|
|
code: category?.code || LEGACY_CODE_BY_ID[category?.id ?? ''] || preset?.code || slugifyCategoryName(name, index),
|
|
name,
|
|
group: category?.group || preset?.group || 'other',
|
|
color: category?.color ?? preset?.color,
|
|
sortOrder: typeof category?.sortOrder === 'number' ? category.sortOrder : preset?.sortOrder ?? index + 1,
|
|
isSystem: typeof category?.isSystem === 'boolean' ? category.isSystem : Boolean(preset?.isSystem),
|
|
};
|
|
}
|
|
|
|
export function normalizeTaskCategories(categories: Partial<TaskCategory>[] = []): TaskCategory[] {
|
|
const merged: Partial<TaskCategory>[] = Array.isArray(categories) ? [...categories] : [];
|
|
for (const preset of PRESET_CATEGORIES) {
|
|
if (!merged.some((c) => c.id === preset.id)) merged.push(preset);
|
|
}
|
|
return merged.map((category, index) => normalizeTaskCategory(category, index));
|
|
}
|
|
|
|
export function getCategoryById(categories: TaskCategory[], id: string): TaskCategory | undefined {
|
|
return categories.find((c) => c.id === id);
|
|
}
|
|
|
|
export function getCategoriesByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory[] {
|
|
return categories.filter((c) => c.group === group).sort((a, b) => a.sortOrder - b.sortOrder);
|
|
}
|
|
|
|
export function findCategoryByCode(categories: TaskCategory[], code?: string): TaskCategory | undefined {
|
|
if (!code) return undefined;
|
|
return categories.find((c) => c.code === code);
|
|
}
|
|
|
|
function findCategoryByCodeInGroup(
|
|
categories: TaskCategory[],
|
|
code: string | undefined,
|
|
group: CategoryGroup,
|
|
): TaskCategory | undefined {
|
|
if (!code) return undefined;
|
|
return categories.find((category) => category.group === group && category.code === code);
|
|
}
|
|
|
|
export function getDefaultCategoryByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory {
|
|
return getCategoriesByGroup(categories, group)[0] ?? categories[0] ?? PRESET_CATEGORIES[0];
|
|
}
|
|
|
|
export function resolveCategoryIdFromCode(categories: TaskCategory[], code: string | undefined, fallbackGroup: CategoryGroup): string {
|
|
return findCategoryByCode(categories, code)?.id ?? getDefaultCategoryByGroup(categories, fallbackGroup).id;
|
|
}
|
|
|
|
export function ensureTaskCategoryByName(
|
|
categories: TaskCategory[],
|
|
name: string | undefined,
|
|
group: CategoryGroup,
|
|
): { categories: TaskCategory[]; category: TaskCategory; created: boolean } {
|
|
return resolveAiTaskCategoryByName(categories, name, group, { allowCreate: true });
|
|
}
|
|
|
|
export function resolveAiTaskCategoryByName(
|
|
categories: TaskCategory[],
|
|
name: string | undefined,
|
|
group: CategoryGroup,
|
|
options: { allowCreate?: boolean; fallbackCode?: string } = {},
|
|
): { categories: TaskCategory[]; category: TaskCategory; created: boolean } {
|
|
const normalizedCategories = normalizeTaskCategories(categories);
|
|
const trimmedName = name?.trim() ?? '';
|
|
if (!trimmedName) {
|
|
return {
|
|
categories: normalizedCategories,
|
|
category:
|
|
findCategoryByCodeInGroup(normalizedCategories, options.fallbackCode, group) ??
|
|
getDefaultCategoryByGroup(normalizedCategories, group),
|
|
created: false,
|
|
};
|
|
}
|
|
|
|
const targetKey = normalizeCategoryNameForMatch(trimmedName);
|
|
const existing = normalizedCategories.find(
|
|
(category) => category.group === group && normalizeCategoryNameForMatch(category.name) === targetKey,
|
|
);
|
|
if (existing) {
|
|
return { categories: normalizedCategories, category: existing, created: false };
|
|
}
|
|
if (options.allowCreate === false) {
|
|
return {
|
|
categories: normalizedCategories,
|
|
category:
|
|
findCategoryByCodeInGroup(normalizedCategories, options.fallbackCode, group) ??
|
|
getDefaultCategoryByGroup(normalizedCategories, group),
|
|
created: false,
|
|
};
|
|
}
|
|
|
|
const codeBase = slugifyCategoryName(trimmedName, normalizedCategories.length);
|
|
const category: TaskCategory = {
|
|
id: uniqueAiCategoryId(trimmedName, normalizedCategories),
|
|
code: uniqueCategoryCode(codeBase, normalizedCategories),
|
|
name: trimmedName,
|
|
group,
|
|
sortOrder: Math.max(0, ...normalizedCategories.map((item) => item.sortOrder)) + 1,
|
|
isSystem: false,
|
|
};
|
|
|
|
return {
|
|
categories: [...normalizedCategories, category],
|
|
category,
|
|
created: true,
|
|
};
|
|
}
|