2470 lines
74 KiB
JavaScript
2470 lines
74 KiB
JavaScript
const UPLOAD_TEMPLATE_FIELD_LIMITS = {
|
||
maxFields: 80,
|
||
maxKeyLength: 64,
|
||
maxLabelLength: 80,
|
||
maxOptions: 200,
|
||
maxOptionLength: 80,
|
||
};
|
||
|
||
const DEFAULT_MIGRATION_SAFE_LIMIT = 200;
|
||
const MAX_MIGRATION_FORCE_LIMIT = 1000;
|
||
const ALLOWED_TEMPLATE_STATUSES = new Set(['draft', 'active', 'archived']);
|
||
const ALLOWED_MATERIAL_KINDS = new Set(['IMAGE', 'VIDEO', 'AUDIO', 'OTHER']);
|
||
const ALLOWED_FIELD_TYPES = new Set([
|
||
'text',
|
||
'textarea',
|
||
'number',
|
||
'select',
|
||
'checkbox',
|
||
'radio',
|
||
'date',
|
||
'uppercase',
|
||
]);
|
||
const OPTION_FIELD_TYPES = new Set(['select', 'radio']);
|
||
const CONDITION_OPERATORS = new Set(['equals', 'notEquals', 'contains', 'notEmpty', 'isEmpty']);
|
||
const CONDITION_OPERATORS_REQUIRING_VALUE = new Set(['equals', 'notEquals', 'contains']);
|
||
const STANDARD_MATERIAL_FIELDS = new Set([
|
||
'productModel',
|
||
'brandName',
|
||
'seriesName',
|
||
'skuCode',
|
||
'materialTexture',
|
||
'categoryName',
|
||
'sceneName',
|
||
'materialType',
|
||
'materialKind',
|
||
'renderer',
|
||
'renderTime',
|
||
'imageResolution',
|
||
'colorMode',
|
||
'authorName',
|
||
'platformName',
|
||
]);
|
||
const MIGRATION_BACKUP_STANDARD_FIELDS = [
|
||
'productModel',
|
||
'brandName',
|
||
'seriesName',
|
||
'skuCode',
|
||
'materialTexture',
|
||
'categoryName',
|
||
'sceneName',
|
||
'materialType',
|
||
'materialKind',
|
||
'renderer',
|
||
'renderTime',
|
||
'imageResolution',
|
||
'colorMode',
|
||
'authorName',
|
||
'platformName',
|
||
'audioDuration',
|
||
'audioCategory',
|
||
'audioScene',
|
||
];
|
||
const CLEARABLE_NON_TARGET_FIELD_DEFAULTS = {
|
||
brandName: null,
|
||
seriesName: null,
|
||
skuCode: null,
|
||
materialTexture: null,
|
||
platformName: '',
|
||
renderTime: null,
|
||
imageResolution: null,
|
||
colorMode: null,
|
||
audioDuration: null,
|
||
audioCategory: null,
|
||
audioScene: null,
|
||
};
|
||
const AUDIO_MATERIAL_FIELDS = ['audioDuration', 'audioCategory', 'audioScene'];
|
||
const STANDARD_FIELD_LABELS = {
|
||
productModel: '产品型号',
|
||
brandName: '品牌',
|
||
seriesName: '系列',
|
||
skuCode: 'SKU',
|
||
materialTexture: '材质纹理',
|
||
categoryName: '分类',
|
||
sceneName: '场景',
|
||
materialType: '素材类型',
|
||
materialKind: '素材大类',
|
||
renderer: '渲染器',
|
||
renderTime: '渲染时间',
|
||
imageResolution: '图片分辨率',
|
||
colorMode: '色彩模式',
|
||
authorName: '作者',
|
||
platformName: '平台',
|
||
audioDuration: '音频时长',
|
||
audioCategory: '音频分类',
|
||
audioScene: '音频场景',
|
||
};
|
||
const MATERIAL_HOME_FILTER_COLUMN_FIELDS = new Set([
|
||
'categoryName',
|
||
'sceneName',
|
||
'platformName',
|
||
'materialType',
|
||
'renderer',
|
||
'brandName',
|
||
'colorMode',
|
||
'renderTime',
|
||
'imageResolution',
|
||
'seriesName',
|
||
'skuCode',
|
||
'materialTexture',
|
||
'authorName',
|
||
]);
|
||
const MATERIAL_LIST_SEARCH_COLUMN_FIELDS = [
|
||
'productModel',
|
||
'brandName',
|
||
'seriesName',
|
||
'skuCode',
|
||
'materialTexture',
|
||
'categoryName',
|
||
'sceneName',
|
||
'authorName',
|
||
];
|
||
const MATERIAL_LIST_HOME_FILTER_COLUMN_FIELDS = new Set([
|
||
'renderer',
|
||
'brandName',
|
||
'seriesName',
|
||
'skuCode',
|
||
'materialTexture',
|
||
'renderTime',
|
||
'imageResolution',
|
||
'colorMode',
|
||
'productModel',
|
||
'platformName',
|
||
'categoryName',
|
||
'sceneName',
|
||
'authorName',
|
||
]);
|
||
const departmentCollator = new Intl.Collator('zh-Hans-CN-u-co-pinyin', {
|
||
numeric: true,
|
||
sensitivity: 'base',
|
||
});
|
||
|
||
function hasOwn(value, key) {
|
||
return Object.prototype.hasOwnProperty.call(value, key);
|
||
}
|
||
|
||
function validationError(error, field, details) {
|
||
return {
|
||
valid: false,
|
||
error,
|
||
field,
|
||
details,
|
||
};
|
||
}
|
||
|
||
function parseFieldConditions(conditionsJson) {
|
||
if (!conditionsJson) return null;
|
||
if (typeof conditionsJson === 'string' && conditionsJson.trim() === '') return null;
|
||
|
||
try {
|
||
const parsed = typeof conditionsJson === 'string' ? JSON.parse(conditionsJson) : conditionsJson;
|
||
if (!parsed || typeof parsed !== 'object') return null;
|
||
if (!Array.isArray(parsed.conditions)) return null;
|
||
|
||
return {
|
||
logic: parsed.logic === 'OR' ? 'OR' : 'AND',
|
||
conditions: parsed.conditions.filter((condition) => (
|
||
condition &&
|
||
typeof condition === 'object' &&
|
||
condition.fieldKey &&
|
||
condition.operator
|
||
)),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function detectCircularDependencies(fields) {
|
||
const dependencyMap = new Map();
|
||
|
||
for (const field of fields) {
|
||
const conditions = parseFieldConditions(field.conditions);
|
||
if (!conditions) continue;
|
||
dependencyMap.set(field.key, new Set(conditions.conditions.map((condition) => condition.fieldKey)));
|
||
}
|
||
|
||
const visited = new Set();
|
||
const recursionStack = new Set();
|
||
const circularFields = [];
|
||
|
||
function dfs(fieldKey) {
|
||
if (recursionStack.has(fieldKey)) return true;
|
||
if (visited.has(fieldKey)) return false;
|
||
|
||
visited.add(fieldKey);
|
||
recursionStack.add(fieldKey);
|
||
|
||
const deps = dependencyMap.get(fieldKey);
|
||
if (deps) {
|
||
for (const dep of deps) {
|
||
if (dfs(dep)) {
|
||
circularFields.push(fieldKey);
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
recursionStack.delete(fieldKey);
|
||
return false;
|
||
}
|
||
|
||
for (const field of fields) {
|
||
if (!visited.has(field.key)) dfs(field.key);
|
||
}
|
||
|
||
return Array.from(new Set(circularFields));
|
||
}
|
||
|
||
function parseValidationRules(validationsJson) {
|
||
if (!validationsJson || String(validationsJson).trim() === '') return null;
|
||
|
||
try {
|
||
const parsed = JSON.parse(validationsJson);
|
||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||
return parsed;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function validateValidationRules(rules) {
|
||
if (typeof rules.minLength === 'number' && typeof rules.maxLength === 'number') {
|
||
if (rules.minLength > rules.maxLength) {
|
||
return { valid: false, error: '最小长度不能大于最大长度' };
|
||
}
|
||
}
|
||
|
||
if (typeof rules.min === 'number' && typeof rules.max === 'number') {
|
||
if (rules.min > rules.max) {
|
||
return { valid: false, error: '最小值不能大于最大值' };
|
||
}
|
||
}
|
||
|
||
if (rules.pattern) {
|
||
try {
|
||
new RegExp(rules.pattern);
|
||
} catch {
|
||
return { valid: false, error: '正则表达式格式无效' };
|
||
}
|
||
}
|
||
|
||
return { valid: true };
|
||
}
|
||
|
||
function getSupportedValidations(fieldType) {
|
||
const common = ['required'];
|
||
|
||
switch (fieldType) {
|
||
case 'text':
|
||
case 'textarea':
|
||
return [...common, 'minLength', 'maxLength', 'pattern'];
|
||
case 'number':
|
||
return [...common, 'min', 'max'];
|
||
case 'select':
|
||
case 'checkbox':
|
||
case 'radio':
|
||
case 'date':
|
||
case 'file':
|
||
case 'image':
|
||
default:
|
||
return common;
|
||
}
|
||
}
|
||
|
||
function normalizeStaticOptions(value) {
|
||
if (value === null || value === undefined) {
|
||
return { value: null, invalid: false };
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
const options = value.map((option) => String(option ?? '').trim()).filter(Boolean);
|
||
return {
|
||
value: options.length > 0 ? JSON.stringify(Array.from(new Set(options))) : null,
|
||
invalid: false,
|
||
};
|
||
}
|
||
|
||
if (typeof value === 'string') {
|
||
const trimmed = value.trim();
|
||
if (!trimmed) return { value: null, invalid: false };
|
||
|
||
try {
|
||
return normalizeStaticOptions(JSON.parse(trimmed));
|
||
} catch {
|
||
const options = trimmed.split('\n').map((option) => option.trim()).filter(Boolean);
|
||
return {
|
||
value: options.length > 0 ? JSON.stringify(Array.from(new Set(options))) : null,
|
||
invalid: false,
|
||
};
|
||
}
|
||
}
|
||
|
||
return { value: null, invalid: true };
|
||
}
|
||
|
||
function parseNormalizedOptions(value) {
|
||
if (!value) return [];
|
||
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
return Array.isArray(parsed)
|
||
? parsed.map((option) => String(option ?? '').trim()).filter(Boolean)
|
||
: [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function normalizeJsonValue(value) {
|
||
if (typeof value === 'string') {
|
||
const trimmed = value.trim();
|
||
if (!trimmed) return '';
|
||
try {
|
||
return JSON.parse(trimmed);
|
||
} catch {
|
||
return trimmed;
|
||
}
|
||
}
|
||
|
||
return value ?? null;
|
||
}
|
||
|
||
function stableStringify(value) {
|
||
const normalized = normalizeJsonValue(value);
|
||
|
||
if (Array.isArray(normalized)) {
|
||
return JSON.stringify(normalized.map((item) => stableStringify(item)));
|
||
}
|
||
|
||
if (normalized && typeof normalized === 'object') {
|
||
const entries = Object.entries(normalized)
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([key, item]) => [key, stableStringify(item)]);
|
||
return JSON.stringify(entries);
|
||
}
|
||
|
||
return JSON.stringify(normalized);
|
||
}
|
||
|
||
function parseStaticOptions(value) {
|
||
const normalized = normalizeJsonValue(value);
|
||
if (!Array.isArray(normalized)) return [];
|
||
|
||
return normalized
|
||
.filter((item) => typeof item === 'string')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
export function detectDangerousTemplateChanges(args) {
|
||
const existingTemplate = args?.existingTemplate;
|
||
const nextMaterialType = args?.nextMaterialType;
|
||
const nextMaterialKind = args?.nextMaterialKind;
|
||
const nextFields = args?.nextFields;
|
||
const changes = [];
|
||
|
||
if (!existingTemplate || !Array.isArray(existingTemplate.fields)) {
|
||
return changes;
|
||
}
|
||
|
||
if (nextMaterialType && nextMaterialType !== existingTemplate.materialType) {
|
||
changes.push({
|
||
type: 'materialTypeChanged',
|
||
before: existingTemplate.materialType,
|
||
after: nextMaterialType,
|
||
});
|
||
}
|
||
|
||
if (nextMaterialKind && nextMaterialKind !== existingTemplate.materialKind) {
|
||
changes.push({
|
||
type: 'materialKindChanged',
|
||
before: existingTemplate.materialKind,
|
||
after: nextMaterialKind,
|
||
});
|
||
}
|
||
|
||
if (!Array.isArray(nextFields)) {
|
||
return changes;
|
||
}
|
||
|
||
const incomingById = new Map(
|
||
nextFields
|
||
.filter((field) => field.id)
|
||
.map((field) => [field.id, field])
|
||
);
|
||
|
||
for (const existingField of existingTemplate.fields) {
|
||
const incomingField = incomingById.get(existingField.id);
|
||
|
||
if (!incomingField) {
|
||
changes.push({
|
||
type: 'fieldDeleted',
|
||
fieldId: existingField.id,
|
||
fieldKey: existingField.key,
|
||
fieldLabel: existingField.label,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
if (incomingField.key !== existingField.key) {
|
||
changes.push({
|
||
type: 'fieldKeyChanged',
|
||
fieldId: existingField.id,
|
||
before: existingField.key,
|
||
after: incomingField.key,
|
||
fieldLabel: existingField.label,
|
||
});
|
||
}
|
||
|
||
if (incomingField.type !== existingField.type) {
|
||
changes.push({
|
||
type: 'fieldTypeChanged',
|
||
fieldId: existingField.id,
|
||
fieldKey: existingField.key,
|
||
fieldLabel: existingField.label,
|
||
before: existingField.type,
|
||
after: incomingField.type,
|
||
});
|
||
}
|
||
|
||
if (existingField.addToCategory && incomingField.addToCategory === false) {
|
||
changes.push({
|
||
type: 'fieldCategoryDisabled',
|
||
fieldId: existingField.id,
|
||
fieldKey: existingField.key,
|
||
fieldLabel: existingField.label,
|
||
});
|
||
}
|
||
|
||
if (existingField.isSearchable && incomingField.isSearchable === false) {
|
||
changes.push({
|
||
type: 'fieldSearchDisabled',
|
||
fieldId: existingField.id,
|
||
fieldKey: existingField.key,
|
||
fieldLabel: existingField.label,
|
||
});
|
||
}
|
||
|
||
if (!existingField.isReadOnly && incomingField.isReadOnly === true) {
|
||
changes.push({
|
||
type: 'fieldMadeReadOnly',
|
||
fieldId: existingField.id,
|
||
fieldKey: existingField.key,
|
||
fieldLabel: existingField.label,
|
||
});
|
||
}
|
||
|
||
if (!existingField.required && incomingField.required === true) {
|
||
changes.push({
|
||
type: 'fieldMadeRequired',
|
||
fieldId: existingField.id,
|
||
fieldKey: existingField.key,
|
||
fieldLabel: existingField.label,
|
||
});
|
||
}
|
||
|
||
if (stableStringify(incomingField.validations) !== stableStringify(existingField.validations)) {
|
||
changes.push({
|
||
type: 'fieldValidationChanged',
|
||
fieldId: existingField.id,
|
||
fieldKey: existingField.key,
|
||
fieldLabel: existingField.label,
|
||
});
|
||
}
|
||
|
||
const existingOptions = parseStaticOptions(existingField.optionsStatic);
|
||
if (existingOptions.length > 0) {
|
||
const incomingOptions = new Set(parseStaticOptions(incomingField.optionsStatic));
|
||
const removedOptions = existingOptions.filter((option) => !incomingOptions.has(option));
|
||
if (removedOptions.length > 0) {
|
||
changes.push({
|
||
type: 'fieldOptionsRemoved',
|
||
fieldId: existingField.id,
|
||
fieldKey: existingField.key,
|
||
fieldLabel: existingField.label,
|
||
removedOptions,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (stableStringify(incomingField.conditions) !== stableStringify(existingField.conditions)) {
|
||
changes.push({
|
||
type: 'fieldConditionsChanged',
|
||
fieldId: existingField.id,
|
||
fieldKey: existingField.key,
|
||
fieldLabel: existingField.label,
|
||
});
|
||
}
|
||
}
|
||
|
||
return changes;
|
||
}
|
||
|
||
function normalizeValidations(value, fieldType) {
|
||
if (value === null || value === undefined) return { value: null };
|
||
if (typeof value === 'string' && value.trim() === '') return { value: null };
|
||
|
||
let raw = value;
|
||
if (typeof value === 'string') {
|
||
try {
|
||
raw = JSON.parse(value);
|
||
} catch {
|
||
return { value: null, invalid: '验证规则必须是合法 JSON 对象' };
|
||
}
|
||
}
|
||
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||
return { value: null, invalid: '验证规则必须是对象' };
|
||
}
|
||
|
||
const parsedRules = parseValidationRules(JSON.stringify(raw));
|
||
if (!parsedRules) {
|
||
return { value: null, invalid: '验证规则必须是对象' };
|
||
}
|
||
|
||
const validation = validateValidationRules(parsedRules);
|
||
if (!validation.valid) {
|
||
return { value: null, invalid: validation.error || '验证规则配置无效' };
|
||
}
|
||
|
||
const supportedValidations = new Set(getSupportedValidations(fieldType));
|
||
const unsupportedRules = [
|
||
['minLength', parsedRules.minLength],
|
||
['maxLength', parsedRules.maxLength],
|
||
['pattern', parsedRules.pattern],
|
||
['min', parsedRules.min],
|
||
['max', parsedRules.max],
|
||
]
|
||
.filter(([ruleKey, ruleValue]) => ruleValue !== undefined && !supportedValidations.has(String(ruleKey)))
|
||
.map(([ruleKey]) => String(ruleKey));
|
||
|
||
if (unsupportedRules.length > 0) {
|
||
return {
|
||
value: null,
|
||
invalid: `字段类型 ${fieldType} 不支持验证规则:${unsupportedRules.join(', ')}`,
|
||
};
|
||
}
|
||
|
||
return {
|
||
value: JSON.stringify(parsedRules),
|
||
rules: parsedRules,
|
||
};
|
||
}
|
||
|
||
function normalizeConditions(value) {
|
||
if (value === null || value === undefined) return { value: null, invalid: false };
|
||
if (typeof value === 'string' && value.trim() === '') return { value: null, invalid: false };
|
||
|
||
let raw;
|
||
try {
|
||
raw = typeof value === 'string' ? JSON.parse(value) : value;
|
||
} catch {
|
||
return { value: null, invalid: true };
|
||
}
|
||
|
||
if (!raw || typeof raw !== 'object') return { value: null, invalid: true };
|
||
if (raw.logic !== undefined && raw.logic !== 'AND' && raw.logic !== 'OR') {
|
||
return { value: null, invalid: true };
|
||
}
|
||
if (!Array.isArray(raw.conditions)) return { value: null, invalid: true };
|
||
if (raw.conditions.length === 0) return { value: null, invalid: false };
|
||
|
||
for (const condition of raw.conditions) {
|
||
if (!condition || typeof condition !== 'object') return { value: null, invalid: true };
|
||
if (!condition.fieldKey || !condition.operator) return { value: null, invalid: true };
|
||
}
|
||
|
||
const parsed = parseFieldConditions(raw);
|
||
if (!parsed || parsed.conditions.length !== raw.conditions.length) {
|
||
return { value: null, invalid: true };
|
||
}
|
||
|
||
return { value: JSON.stringify(parsed), invalid: false, conditions: parsed };
|
||
}
|
||
|
||
export function validateAndFormatUploadTemplateFields(fields) {
|
||
if (!Array.isArray(fields)) {
|
||
return {
|
||
valid: false,
|
||
error: '字段列表必须是数组',
|
||
field: 'fields',
|
||
};
|
||
}
|
||
|
||
if (fields.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields) {
|
||
return {
|
||
valid: false,
|
||
error: `单个模板最多支持 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields} 个字段,请拆分模板或减少字段数量`,
|
||
field: 'fields',
|
||
details: {
|
||
maxFields: UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields,
|
||
currentFields: fields.length,
|
||
},
|
||
};
|
||
}
|
||
|
||
const fieldKeys = new Set();
|
||
const duplicateKeys = [];
|
||
const formattedFields = [];
|
||
|
||
for (let index = 0; index < fields.length; index += 1) {
|
||
const field = fields[index] || {};
|
||
const key = String(field.key || '').trim();
|
||
|
||
if (!key) {
|
||
return {
|
||
valid: false,
|
||
error: '字段key不能为空',
|
||
field: 'fields',
|
||
};
|
||
}
|
||
|
||
if (fieldKeys.has(key)) {
|
||
duplicateKeys.push(key);
|
||
} else {
|
||
fieldKeys.add(key);
|
||
}
|
||
|
||
const label = String(field.label || '').trim() || `字段${index + 1}`;
|
||
const type = String(field.type || 'text').trim() || 'text';
|
||
const addToCategory = !!field.addToCategory;
|
||
const normalizedOptions = normalizeStaticOptions(field.optionsStatic);
|
||
const optionsStatic = normalizedOptions.value;
|
||
const normalizedValidations = normalizeValidations(field.validations, type);
|
||
const normalizedConditions = normalizeConditions(field.conditions);
|
||
const conditions = normalizedConditions.value;
|
||
|
||
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”的 key 必须以字母开头,并且只能包含字母、数字和下划线`,
|
||
field: 'fields',
|
||
details: { fieldKey: key },
|
||
};
|
||
}
|
||
|
||
if (key.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”的 key 不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength} 个字符`,
|
||
field: 'fields',
|
||
details: {
|
||
fieldKey: key,
|
||
maxKeyLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength,
|
||
},
|
||
};
|
||
}
|
||
|
||
if (label.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label.slice(0, 20)}...”的名称不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength} 个字符`,
|
||
field: 'fields',
|
||
details: {
|
||
fieldKey: key,
|
||
maxLabelLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength,
|
||
},
|
||
};
|
||
}
|
||
|
||
if (!ALLOWED_FIELD_TYPES.has(type)) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”的类型不支持:${type}`,
|
||
field: 'fields',
|
||
details: {
|
||
fieldKey: key,
|
||
fieldType: type,
|
||
allowedTypes: Array.from(ALLOWED_FIELD_TYPES),
|
||
},
|
||
};
|
||
}
|
||
|
||
if (OPTION_FIELD_TYPES.has(type)) {
|
||
if (normalizedOptions.invalid) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”的选项必须是数组或每行一个选项`,
|
||
field: 'fields',
|
||
details: { fieldKey: key },
|
||
};
|
||
}
|
||
|
||
const options = parseNormalizedOptions(optionsStatic);
|
||
if (options.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”的选项不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions} 个`,
|
||
field: 'fields',
|
||
details: {
|
||
fieldKey: key,
|
||
maxOptions: UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions,
|
||
currentOptions: options.length,
|
||
},
|
||
};
|
||
}
|
||
|
||
const tooLongOption = options.find((option) => option.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength);
|
||
if (tooLongOption) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”的单个选项不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength} 个字符`,
|
||
field: 'fields',
|
||
details: {
|
||
fieldKey: key,
|
||
maxOptionLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength,
|
||
sample: tooLongOption.slice(0, 20),
|
||
},
|
||
};
|
||
}
|
||
|
||
if (field.required && !addToCategory && options.length === 0) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”是必填${type === 'radio' ? '单选' : '下拉'}字段,至少需要配置一个选项`,
|
||
field: 'fields',
|
||
details: { fieldKey: key },
|
||
};
|
||
}
|
||
}
|
||
|
||
if (normalizedValidations.invalid) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”的验证规则不正确:${normalizedValidations.invalid}`,
|
||
field: 'fields',
|
||
details: { fieldKey: key },
|
||
};
|
||
}
|
||
|
||
if (normalizedConditions.invalid) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”的显示条件格式不正确`,
|
||
field: 'fields',
|
||
details: { fieldKey: key },
|
||
};
|
||
}
|
||
|
||
formattedFields.push({
|
||
id: field.id ?? null,
|
||
key,
|
||
label,
|
||
type,
|
||
required: !!field.required,
|
||
orderIndex: field.orderIndex ?? index,
|
||
layoutWidth: field.layoutWidth || 'full',
|
||
validations: normalizedValidations.value,
|
||
optionsSource: field.optionsSource === null || field.optionsSource === undefined ? null : String(field.optionsSource),
|
||
optionsStatic,
|
||
allowedExtensions: field.allowedExtensions === null || field.allowedExtensions === undefined ? null : String(field.allowedExtensions),
|
||
visibleOnHomeFilter: !!field.visibleOnHomeFilter,
|
||
isSearchable: !!field.isSearchable,
|
||
isReadOnly: !!field.isReadOnly,
|
||
addToCategory,
|
||
conditions,
|
||
});
|
||
}
|
||
|
||
if (duplicateKeys.length > 0) {
|
||
return {
|
||
valid: false,
|
||
error: `字段key重复:${duplicateKeys.join(', ')}`,
|
||
field: 'fields',
|
||
details: { duplicateKeys },
|
||
};
|
||
}
|
||
|
||
const availableKeys = new Set(formattedFields.map((field) => field.key));
|
||
|
||
for (const field of formattedFields) {
|
||
const conditions = parseFieldConditions(field.conditions);
|
||
if (!conditions) continue;
|
||
|
||
for (const condition of conditions.conditions) {
|
||
if (!CONDITION_OPERATORS.has(condition.operator)) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${field.label}”的显示条件包含不支持的判断方式`,
|
||
field: 'fields',
|
||
details: { fieldKey: field.key, operator: condition.operator },
|
||
};
|
||
}
|
||
|
||
if (!availableKeys.has(condition.fieldKey)) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${field.label}”的显示条件引用了不存在的字段:${condition.fieldKey}`,
|
||
field: 'fields',
|
||
details: { fieldKey: field.key, dependencyKey: condition.fieldKey },
|
||
};
|
||
}
|
||
|
||
if (condition.fieldKey === field.key) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${field.label}”的显示条件不能引用自己`,
|
||
field: 'fields',
|
||
details: { fieldKey: field.key },
|
||
};
|
||
}
|
||
|
||
if (
|
||
CONDITION_OPERATORS_REQUIRING_VALUE.has(condition.operator) &&
|
||
String(condition.value ?? '').trim() === ''
|
||
) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${field.label}”的显示条件缺少比较值`,
|
||
field: 'fields',
|
||
details: { fieldKey: field.key, operator: condition.operator },
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
const circularFields = detectCircularDependencies(formattedFields);
|
||
if (circularFields.length > 0) {
|
||
return {
|
||
valid: false,
|
||
error: `字段显示条件存在循环依赖:${circularFields.join(', ')}`,
|
||
field: 'fields',
|
||
details: { circularFields },
|
||
};
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
fields: formattedFields,
|
||
};
|
||
}
|
||
|
||
function evaluateSubmittedFieldCondition(condition, fieldValues) {
|
||
const fieldValue = fieldValues[condition.fieldKey];
|
||
const stringValue = fieldValue === null || fieldValue === undefined
|
||
? ''
|
||
: String(fieldValue).trim();
|
||
|
||
switch (condition.operator) {
|
||
case 'equals':
|
||
return stringValue === (condition.value || '');
|
||
case 'notEquals':
|
||
return stringValue !== (condition.value || '');
|
||
case 'contains':
|
||
return stringValue.includes(condition.value || '');
|
||
case 'notEmpty':
|
||
return stringValue !== '';
|
||
case 'isEmpty':
|
||
return stringValue === '';
|
||
default:
|
||
return true;
|
||
}
|
||
}
|
||
|
||
function isSubmittedFieldVisible(field, fieldValues) {
|
||
const conditions = parseFieldConditions(field.conditions);
|
||
if (!conditions || !conditions.conditions || conditions.conditions.length === 0) {
|
||
return true;
|
||
}
|
||
|
||
const results = conditions.conditions.map((condition) => evaluateSubmittedFieldCondition(condition, fieldValues));
|
||
return conditions.logic === 'OR'
|
||
? results.some((result) => result)
|
||
: results.every((result) => result);
|
||
}
|
||
|
||
function getVisibleSubmittedTemplateFields(fields, fieldValues) {
|
||
if (!fields.length) return [];
|
||
|
||
let visibleKeys = new Set();
|
||
for (let pass = 0; pass < fields.length; pass += 1) {
|
||
const activeValues = {};
|
||
for (const field of fields) {
|
||
if (visibleKeys.has(field.key)) {
|
||
activeValues[field.key] = fieldValues[field.key];
|
||
}
|
||
}
|
||
|
||
const nextVisibleKeys = new Set();
|
||
for (const field of fields) {
|
||
if (isSubmittedFieldVisible(field, activeValues)) {
|
||
nextVisibleKeys.add(field.key);
|
||
}
|
||
}
|
||
|
||
const sameSize = nextVisibleKeys.size === visibleKeys.size;
|
||
const sameKeys = sameSize && Array.from(nextVisibleKeys).every((key) => visibleKeys.has(key));
|
||
visibleKeys = nextVisibleKeys;
|
||
|
||
if (sameKeys) break;
|
||
}
|
||
|
||
return fields.filter((field) => visibleKeys.has(field.key));
|
||
}
|
||
|
||
function filterVisibleSubmittedFieldValues(fields, fieldValues) {
|
||
const visibleKeys = new Set(
|
||
getVisibleSubmittedTemplateFields(fields, fieldValues).map((field) => field.key)
|
||
);
|
||
|
||
return Object.fromEntries(
|
||
Object.entries(fieldValues).filter(([key]) => visibleKeys.has(key))
|
||
);
|
||
}
|
||
|
||
function hasSubmittedFieldValue(value, type) {
|
||
if (type === 'checkbox') {
|
||
return value !== undefined && value !== null;
|
||
}
|
||
|
||
if (value === undefined || value === null) {
|
||
return false;
|
||
}
|
||
|
||
if (typeof value === 'string') {
|
||
return value.trim().length > 0;
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
return value.length > 0;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function validateSubmittedFieldValue(value, fieldType, rules, fieldLabel) {
|
||
const errors = [];
|
||
const label = fieldLabel || '此字段';
|
||
|
||
if (!rules) {
|
||
return { valid: true, errors: [] };
|
||
}
|
||
|
||
const stringValue = value === null || value === undefined ? '' : String(value).trim();
|
||
const isEmpty = stringValue === '';
|
||
|
||
if (rules.required && isEmpty) {
|
||
errors.push(rules.requiredMessage || `${label}不能为空`);
|
||
return { valid: false, errors };
|
||
}
|
||
|
||
if (isEmpty) {
|
||
return { valid: true, errors: [] };
|
||
}
|
||
|
||
if (fieldType === 'text' || fieldType === 'textarea') {
|
||
if (typeof rules.minLength === 'number' && stringValue.length < rules.minLength) {
|
||
errors.push(rules.minLengthMessage || `${label}长度不能少于${rules.minLength}个字符`);
|
||
}
|
||
|
||
if (typeof rules.maxLength === 'number' && stringValue.length > rules.maxLength) {
|
||
errors.push(rules.maxLengthMessage || `${label}长度不能超过${rules.maxLength}个字符`);
|
||
}
|
||
}
|
||
|
||
if (fieldType === 'number') {
|
||
const numValue = Number.parseFloat(stringValue);
|
||
|
||
if (Number.isNaN(numValue)) {
|
||
errors.push(`${label}必须是有效的数字`);
|
||
} else {
|
||
if (typeof rules.min === 'number' && numValue < rules.min) {
|
||
errors.push(rules.minMessage || `${label}不能小于${rules.min}`);
|
||
}
|
||
|
||
if (typeof rules.max === 'number' && numValue > rules.max) {
|
||
errors.push(rules.maxMessage || `${label}不能大于${rules.max}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (rules.pattern) {
|
||
try {
|
||
const regex = new RegExp(rules.pattern);
|
||
if (!regex.test(stringValue)) {
|
||
errors.push(rules.patternMessage || `${label}格式不正确`);
|
||
}
|
||
} catch {
|
||
// 模板保存时已经校验过正则;运行期忽略坏规则,避免扩大故障面。
|
||
}
|
||
}
|
||
|
||
return {
|
||
valid: errors.length === 0,
|
||
errors,
|
||
};
|
||
}
|
||
|
||
export function validateSubmittedTemplateFieldsRequest(args) {
|
||
const body = args && typeof args === 'object' ? args : {};
|
||
const templateId = typeof body.templateId === 'string' ? body.templateId : null;
|
||
const templateFields = body.templateFields && typeof body.templateFields === 'object' && !Array.isArray(body.templateFields)
|
||
? body.templateFields
|
||
: {};
|
||
const allowUnknownKeys = body.allowUnknownKeys !== false;
|
||
const fields = Array.isArray(body.fields) ? body.fields : [];
|
||
const templateName = typeof body.templateName === 'string' ? body.templateName : undefined;
|
||
|
||
if (!templateId) {
|
||
return {
|
||
valid: true,
|
||
templateFields,
|
||
visibleFieldKeys: Object.keys(templateFields),
|
||
};
|
||
}
|
||
|
||
const normalizedFields = fields
|
||
.filter((field) => field && typeof field === 'object' && typeof field.key === 'string' && field.key.trim())
|
||
.map((field) => ({
|
||
key: field.key.trim(),
|
||
label: typeof field.label === 'string' ? field.label : field.key.trim(),
|
||
type: typeof field.type === 'string' && field.type ? field.type : 'text',
|
||
required: field.required === true,
|
||
conditions: field.conditions ?? null,
|
||
validations: field.validations ?? null,
|
||
}));
|
||
|
||
const fieldMap = new Map(normalizedFields.map((field) => [field.key, field]));
|
||
const visibleFields = getVisibleSubmittedTemplateFields(normalizedFields, templateFields);
|
||
const visibleTemplateFields = filterVisibleSubmittedFieldValues(normalizedFields, templateFields);
|
||
|
||
if (!allowUnknownKeys) {
|
||
const unknownKeys = Object.keys(templateFields).filter((key) => !fieldMap.has(key));
|
||
if (unknownKeys.length > 0) {
|
||
return {
|
||
valid: false,
|
||
error: `提交了模板中不存在的字段:${unknownKeys.join(', ')}`,
|
||
errorCode: 'UNKNOWN_TEMPLATE_FIELDS',
|
||
details: {
|
||
unknownKeys,
|
||
templateId,
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
const missingRequiredFields = visibleFields
|
||
.filter((field) => field.required)
|
||
.filter((field) => !hasSubmittedFieldValue(visibleTemplateFields[field.key], field.type))
|
||
.map((field) => ({
|
||
key: field.key,
|
||
label: field.label || field.key,
|
||
type: field.type || 'text',
|
||
}));
|
||
|
||
if (missingRequiredFields.length > 0) {
|
||
return {
|
||
valid: false,
|
||
error: `模板必填字段未填写:${missingRequiredFields.map((field) => field.label).join('、')}`,
|
||
errorCode: 'TEMPLATE_REQUIRED_FIELDS_MISSING',
|
||
details: {
|
||
templateId,
|
||
missingRequiredFields,
|
||
},
|
||
};
|
||
}
|
||
|
||
const invalidFields = visibleFields
|
||
.map((field) => {
|
||
const result = validateSubmittedFieldValue(
|
||
visibleTemplateFields[field.key],
|
||
field.type || 'text',
|
||
parseValidationRules(field.validations),
|
||
field.label || field.key
|
||
);
|
||
|
||
return {
|
||
key: field.key,
|
||
label: field.label || field.key,
|
||
type: field.type || 'text',
|
||
errors: result.errors,
|
||
};
|
||
})
|
||
.filter((field) => field.errors.length > 0);
|
||
|
||
if (invalidFields.length > 0) {
|
||
return {
|
||
valid: false,
|
||
error: `模板字段填写不符合规则:${invalidFields.map((field) => `${field.label}:${field.errors.join('、')}`).join(';')}`,
|
||
errorCode: 'TEMPLATE_FIELD_VALIDATION_FAILED',
|
||
details: {
|
||
templateId,
|
||
invalidFields,
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
templateName,
|
||
templateFields: visibleTemplateFields,
|
||
visibleFieldKeys: visibleFields.map((field) => field.key),
|
||
};
|
||
}
|
||
|
||
function separateSubmittedMaterialFields(templateFields) {
|
||
const standardData = {};
|
||
const customFields = {};
|
||
|
||
for (const [key, value] of Object.entries(templateFields)) {
|
||
if (STANDARD_MATERIAL_FIELDS.has(key)) {
|
||
standardData[key] = value === null || value === undefined ? '' : String(value);
|
||
} else {
|
||
customFields[key] = value;
|
||
}
|
||
}
|
||
|
||
return { standardData, customFields };
|
||
}
|
||
|
||
function parseExistingMetadata(existingMetadata) {
|
||
if (!existingMetadata) return {};
|
||
try {
|
||
const parsed = JSON.parse(existingMetadata);
|
||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function buildSubmittedMaterialMetadata(customFields, templateId, previewUrls, existingMetadata) {
|
||
const metadata = parseExistingMetadata(existingMetadata);
|
||
|
||
if (templateId) {
|
||
metadata.templateId = templateId;
|
||
}
|
||
|
||
if (Object.keys(customFields).length > 0) {
|
||
metadata.templateFields = {
|
||
...(metadata.templateFields || {}),
|
||
...customFields,
|
||
};
|
||
}
|
||
|
||
if (Array.isArray(previewUrls) && previewUrls.length > 0) {
|
||
metadata.previewUrls = previewUrls;
|
||
}
|
||
|
||
return Object.keys(metadata).length === 0 ? null : JSON.stringify(metadata);
|
||
}
|
||
|
||
function compressSubmittedMaterialMetadata(metadata) {
|
||
if (!metadata) return null;
|
||
|
||
try {
|
||
const parsed = JSON.parse(metadata);
|
||
|
||
if (parsed.templateFields) {
|
||
const cleanedFields = {};
|
||
for (const [key, value] of Object.entries(parsed.templateFields)) {
|
||
if (value !== null && value !== undefined && value !== '') {
|
||
cleanedFields[key] = value;
|
||
}
|
||
}
|
||
if (Object.keys(cleanedFields).length > 0) {
|
||
parsed.templateFields = cleanedFields;
|
||
} else {
|
||
delete parsed.templateFields;
|
||
}
|
||
}
|
||
|
||
if (Array.isArray(parsed.previewUrls) && parsed.previewUrls.length === 0) {
|
||
delete parsed.previewUrls;
|
||
}
|
||
|
||
return Object.keys(parsed).length === 0 ? null : JSON.stringify(parsed);
|
||
} catch {
|
||
return metadata;
|
||
}
|
||
}
|
||
|
||
function getUtf8Size(value) {
|
||
return value ? Buffer.byteLength(value, 'utf8') : 0;
|
||
}
|
||
|
||
function getRecordValue(value) {
|
||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||
return value;
|
||
}
|
||
return {};
|
||
}
|
||
|
||
function hasBackupValue(value) {
|
||
return value !== undefined && value !== null && value !== '';
|
||
}
|
||
|
||
function getExistingMigrationBackups(metadata) {
|
||
return Array.isArray(metadata.templateMigrationBackups)
|
||
? metadata.templateMigrationBackups
|
||
: [];
|
||
}
|
||
|
||
function buildDroppedStandardFields(standardFields, targetFieldKeys) {
|
||
const droppedFields = {};
|
||
|
||
for (const key of MIGRATION_BACKUP_STANDARD_FIELDS) {
|
||
const value = standardFields[key];
|
||
if (!targetFieldKeys.has(key) && hasBackupValue(value)) {
|
||
droppedFields[key] = value;
|
||
}
|
||
}
|
||
|
||
return droppedFields;
|
||
}
|
||
|
||
function normalizePreviewField(field) {
|
||
const item = field && typeof field === 'object' ? field : {};
|
||
const key = typeof item.key === 'string' ? item.key.trim() : '';
|
||
if (!key) return null;
|
||
|
||
return {
|
||
key,
|
||
label: typeof item.label === 'string' && item.label.trim() ? item.label.trim() : key,
|
||
type: typeof item.type === 'string' && item.type.trim() ? item.type.trim() : undefined,
|
||
required: item.required === true,
|
||
};
|
||
}
|
||
|
||
function buildPreviewFieldSummary(key, sourceTemplateFieldsByKey, source) {
|
||
const templateField = sourceTemplateFieldsByKey.get(key);
|
||
return {
|
||
key,
|
||
label: templateField?.label || STANDARD_FIELD_LABELS[key] || key,
|
||
type: templateField?.type,
|
||
source,
|
||
};
|
||
}
|
||
|
||
function addPreviewFieldImpact(impacts, field) {
|
||
const mapKey = `${field.source}:${field.key}`;
|
||
const existing = impacts.get(mapKey);
|
||
if (existing) {
|
||
existing.affectedCount += 1;
|
||
return;
|
||
}
|
||
|
||
impacts.set(mapKey, {
|
||
...field,
|
||
affectedCount: 1,
|
||
});
|
||
}
|
||
|
||
function normalizePreviewKeys(value) {
|
||
if (Array.isArray(value)) {
|
||
return Array.from(new Set(value.map((key) => String(key || '').trim()).filter(Boolean)));
|
||
}
|
||
|
||
if (value && typeof value === 'object') {
|
||
return Object.keys(value).filter(Boolean);
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
function normalizePublicTemplate(template) {
|
||
const item = template && typeof template === 'object' ? template : {};
|
||
const id = typeof item.id === 'string' ? item.id.trim() : '';
|
||
if (!id) return null;
|
||
|
||
return {
|
||
...item,
|
||
id,
|
||
name: typeof item.name === 'string' ? item.name.trim() : '',
|
||
};
|
||
}
|
||
|
||
function isBuiltinTemplate(template) {
|
||
return typeof template?.id === 'string' && template.id.startsWith('builtin-');
|
||
}
|
||
|
||
function isActiveCustomTemplate(template) {
|
||
return (
|
||
template &&
|
||
!isBuiltinTemplate(template) &&
|
||
template.status === 'active'
|
||
);
|
||
}
|
||
|
||
function normalizeBuiltinTemplates(templates) {
|
||
return (Array.isArray(templates) ? templates : [])
|
||
.map(normalizePublicTemplate)
|
||
.filter(Boolean)
|
||
.map((template) => ({ ...template, isBuiltin: true }));
|
||
}
|
||
|
||
function normalizeCustomTemplates(templates) {
|
||
return (Array.isArray(templates) ? templates : [])
|
||
.map(normalizePublicTemplate)
|
||
.filter(isActiveCustomTemplate)
|
||
.map((template) => ({ ...template, isBuiltin: false }));
|
||
}
|
||
|
||
function filterWritableTemplateFields(fields) {
|
||
return (Array.isArray(fields) ? fields : []).filter((field) => (
|
||
field &&
|
||
typeof field === 'object' &&
|
||
field.isReadOnly !== true &&
|
||
field.key !== 'materialType'
|
||
));
|
||
}
|
||
|
||
function attachMaterialCounts(templates, materialCounts) {
|
||
const counts = materialCounts && typeof materialCounts === 'object' && !Array.isArray(materialCounts)
|
||
? materialCounts
|
||
: {};
|
||
|
||
return templates.map((template) => ({
|
||
...template,
|
||
materialCount: Number.isFinite(counts[template.id]) ? counts[template.id] : 0,
|
||
}));
|
||
}
|
||
|
||
function stripTemplateMaterialSuffix(name) {
|
||
const stripped = String(name || '').trim().replace(/\s*素材模板\s*$/g, '').trim();
|
||
return stripped || String(name || '').trim();
|
||
}
|
||
|
||
function buildCustomTemplateIdentifier(template) {
|
||
const rawName = typeof template.name === 'string' ? template.name.trim() : '';
|
||
if (!rawName) return null;
|
||
|
||
const templateName = stripTemplateMaterialSuffix(rawName) || `模板-${template.id.substring(0, 8)}`;
|
||
return `template:${template.id}:${templateName}`;
|
||
}
|
||
|
||
function projectTemplateList(builtinTemplates, customTemplates) {
|
||
return [
|
||
...builtinTemplates.map((template) => ({
|
||
id: template.id,
|
||
name: template.name,
|
||
isBuiltin: true,
|
||
})),
|
||
...customTemplates.map((template) => ({
|
||
id: template.id,
|
||
name: template.name,
|
||
isBuiltin: false,
|
||
})),
|
||
];
|
||
}
|
||
|
||
function projectUploadTemplateList(builtinTemplates, customTemplates, materialCounts) {
|
||
const writableCustomTemplates = customTemplates.map((template) => ({
|
||
...template,
|
||
fields: filterWritableTemplateFields(template.fields),
|
||
}));
|
||
|
||
return attachMaterialCounts([
|
||
...builtinTemplates,
|
||
...writableCustomTemplates,
|
||
], materialCounts);
|
||
}
|
||
|
||
function projectMaterialTypes(builtinMaterialTypes, customTemplates) {
|
||
const typeSet = new Set(
|
||
(Array.isArray(builtinMaterialTypes) ? builtinMaterialTypes : [])
|
||
.map((type) => String(type || '').trim())
|
||
.filter(Boolean)
|
||
);
|
||
|
||
for (const template of customTemplates) {
|
||
const identifier = buildCustomTemplateIdentifier(template);
|
||
if (identifier) typeSet.add(identifier);
|
||
}
|
||
|
||
return Array.from(typeSet).sort();
|
||
}
|
||
|
||
function hasFilledTemplateFieldValue(value) {
|
||
if (value === null || value === undefined) return false;
|
||
if (typeof value === 'string') return value.trim() !== '';
|
||
if (Array.isArray(value)) return value.length > 0;
|
||
return true;
|
||
}
|
||
|
||
function normalizeOptionalMaterialColumnValue(value) {
|
||
if (value === undefined || value === null || value === '') {
|
||
return null;
|
||
}
|
||
return String(value);
|
||
}
|
||
|
||
export function prepareTemplateMigrationMetadataRequest(args) {
|
||
const body = args && typeof args === 'object' ? args : {};
|
||
const targetTemplateId = typeof body.targetTemplateId === 'string' && body.targetTemplateId
|
||
? body.targetTemplateId
|
||
: null;
|
||
const sourceTemplateId = typeof body.sourceTemplateId === 'string' ? body.sourceTemplateId : '';
|
||
const sourceTemplateName = typeof body.sourceTemplateName === 'string' ? body.sourceTemplateName : '';
|
||
const targetTemplateName = typeof body.targetTemplateName === 'string' ? body.targetTemplateName : '';
|
||
const migratedAt = typeof body.migratedAt === 'string' && body.migratedAt
|
||
? body.migratedAt
|
||
: new Date().toISOString();
|
||
const targetFieldKeys = new Set(
|
||
Array.isArray(body.targetFieldKeys)
|
||
? body.targetFieldKeys.filter((key) => typeof key === 'string' && key)
|
||
: []
|
||
);
|
||
const targetFields = Array.isArray(body.targetFields)
|
||
? body.targetFields
|
||
.filter((field) => field && typeof field === 'object' && typeof field.key === 'string' && field.key)
|
||
.map((field) => ({
|
||
key: field.key,
|
||
label: typeof field.label === 'string' && field.label ? field.label : field.key,
|
||
type: typeof field.type === 'string' && field.type ? field.type : 'text',
|
||
required: field.required === true,
|
||
}))
|
||
: [];
|
||
const materialFieldValues = body.materialFieldValues && typeof body.materialFieldValues === 'object' && !Array.isArray(body.materialFieldValues)
|
||
? body.materialFieldValues
|
||
: {};
|
||
const templateFields = body.templateFields && typeof body.templateFields === 'object' && !Array.isArray(body.templateFields)
|
||
? body.templateFields
|
||
: Object.fromEntries(
|
||
targetFields
|
||
.filter((field) => materialFieldValues[field.key] !== undefined)
|
||
.map((field) => [field.key, materialFieldValues[field.key]])
|
||
);
|
||
const materialStandardFields = body.materialStandardFields && typeof body.materialStandardFields === 'object' && !Array.isArray(body.materialStandardFields)
|
||
? body.materialStandardFields
|
||
: {};
|
||
const existingMetadata = typeof body.existingMetadata === 'string' ? body.existingMetadata : null;
|
||
const previewUrls = Array.isArray(body.previewUrls) ? body.previewUrls.filter((url) => typeof url === 'string') : [];
|
||
const maxSizeBytes = Number.isFinite(body.maxSizeBytes) && body.maxSizeBytes > 0
|
||
? Math.floor(body.maxSizeBytes)
|
||
: 65535;
|
||
|
||
if (!targetTemplateId) {
|
||
return {
|
||
valid: false,
|
||
error: '缺少目标模板 ID',
|
||
errorCode: 'MISSING_TARGET_TEMPLATE_ID',
|
||
details: {
|
||
sourceTemplateId,
|
||
},
|
||
};
|
||
}
|
||
|
||
const missingRequiredFields = targetFields
|
||
.filter((field) => field.required)
|
||
.filter((field) => !hasSubmittedFieldValue(templateFields[field.key], field.type))
|
||
.map((field) => field.label || field.key);
|
||
|
||
if (missingRequiredFields.length > 0) {
|
||
return {
|
||
valid: false,
|
||
error: '目标模板存在无法从旧素材自动补齐的必填字段',
|
||
errorCode: 'TARGET_TEMPLATE_FIELDS_UNRESOLVED',
|
||
details: {
|
||
missingRequiredFields,
|
||
targetTemplateId,
|
||
},
|
||
};
|
||
}
|
||
|
||
const existingMetadataObject = parseExistingMetadata(existingMetadata);
|
||
const { standardData, customFields } = separateSubmittedMaterialFields(templateFields);
|
||
for (const key of AUDIO_MATERIAL_FIELDS) {
|
||
if (hasOwn(templateFields, key)) {
|
||
standardData[key] = normalizeOptionalMaterialColumnValue(templateFields[key]);
|
||
}
|
||
}
|
||
for (const [key, emptyValue] of Object.entries(CLEARABLE_NON_TARGET_FIELD_DEFAULTS)) {
|
||
if (!targetFieldKeys.has(key)) {
|
||
standardData[key] = emptyValue;
|
||
}
|
||
}
|
||
const targetMetadata = buildSubmittedMaterialMetadata(
|
||
customFields,
|
||
targetTemplateId,
|
||
previewUrls,
|
||
null
|
||
);
|
||
const targetMetadataObject = parseExistingMetadata(targetMetadata);
|
||
const existingTemplateFields = getRecordValue(existingMetadataObject.templateFields);
|
||
const targetTemplateFields = getRecordValue(targetMetadataObject.templateFields);
|
||
const droppedTemplateFields = {};
|
||
const droppedStandardFields = buildDroppedStandardFields(materialStandardFields, targetFieldKeys);
|
||
|
||
for (const [key, value] of Object.entries(existingTemplateFields)) {
|
||
if (!targetFieldKeys.has(key) && hasBackupValue(value)) {
|
||
droppedTemplateFields[key] = value;
|
||
}
|
||
}
|
||
|
||
const nextMetadata = {
|
||
...existingMetadataObject,
|
||
...targetMetadataObject,
|
||
templateId: targetTemplateId,
|
||
};
|
||
|
||
if (Object.keys(targetTemplateFields).length > 0) {
|
||
nextMetadata.templateFields = targetTemplateFields;
|
||
} else {
|
||
delete nextMetadata.templateFields;
|
||
}
|
||
|
||
const droppedTemplateFieldCount = Object.keys(droppedTemplateFields).length;
|
||
const droppedStandardFieldCount = Object.keys(droppedStandardFields).length;
|
||
|
||
if (droppedTemplateFieldCount > 0 || droppedStandardFieldCount > 0) {
|
||
const backup = {
|
||
sourceTemplateId,
|
||
sourceTemplateName,
|
||
targetTemplateId,
|
||
targetTemplateName,
|
||
migratedAt,
|
||
};
|
||
|
||
if (droppedTemplateFieldCount > 0) {
|
||
backup.droppedTemplateFields = droppedTemplateFields;
|
||
}
|
||
|
||
if (droppedStandardFieldCount > 0) {
|
||
backup.droppedStandardFields = droppedStandardFields;
|
||
}
|
||
|
||
nextMetadata.templateMigrationBackups = [
|
||
...getExistingMigrationBackups(existingMetadataObject),
|
||
backup,
|
||
];
|
||
}
|
||
|
||
const metadata = compressSubmittedMaterialMetadata(JSON.stringify(nextMetadata));
|
||
const size = getUtf8Size(metadata);
|
||
|
||
if (size > maxSizeBytes) {
|
||
return {
|
||
valid: false,
|
||
error: `模板迁移后 metadata 数据过大,当前约 ${size} 字节,最大允许 ${maxSizeBytes} 字节。请先减少字段内容后再迁移。`,
|
||
errorCode: 'METADATA_TOO_LARGE',
|
||
details: {
|
||
size,
|
||
maxSize: maxSizeBytes,
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
standardData,
|
||
metadata,
|
||
metadataSize: {
|
||
ok: true,
|
||
size,
|
||
maxSize: maxSizeBytes,
|
||
},
|
||
templateFields,
|
||
droppedTemplateFieldCount,
|
||
droppedStandardFieldCount,
|
||
droppedTemplateFields,
|
||
droppedStandardFields,
|
||
};
|
||
}
|
||
|
||
export function buildTemplateMigrationPreviewRequest(args) {
|
||
const body = args && typeof args === 'object' ? args : {};
|
||
const totalMaterialCount = Number.isFinite(body.totalMaterialCount)
|
||
? Math.max(0, Math.floor(body.totalMaterialCount))
|
||
: 0;
|
||
const migratableCount = Number.isFinite(body.migratableCount)
|
||
? Math.max(0, Math.floor(body.migratableCount))
|
||
: 0;
|
||
const previewOnly = body.previewOnly === true;
|
||
const previewLimit = previewOnly && Number.isFinite(body.previewLimit)
|
||
? Math.max(0, Math.floor(body.previewLimit))
|
||
: undefined;
|
||
const targetFields = Array.isArray(body.targetFields)
|
||
? body.targetFields.map(normalizePreviewField).filter(Boolean)
|
||
: [];
|
||
const sourceTemplateFieldsByKey = new Map(
|
||
(Array.isArray(body.sourceTemplateFields) ? body.sourceTemplateFields : [])
|
||
.map(normalizePreviewField)
|
||
.filter(Boolean)
|
||
.map((field) => [field.key, field])
|
||
);
|
||
const impacts = Array.isArray(body.impacts) ? body.impacts : [];
|
||
|
||
let materialsWithDroppedFields = 0;
|
||
let droppedTemplateFieldCount = 0;
|
||
let droppedStandardFieldCount = 0;
|
||
const previewFieldImpacts = new Map();
|
||
const samples = [];
|
||
|
||
for (const rawImpact of impacts) {
|
||
const impact = rawImpact && typeof rawImpact === 'object' ? rawImpact : {};
|
||
const droppedTemplateKeys = normalizePreviewKeys(
|
||
impact.droppedTemplateFieldKeys || impact.droppedTemplateFields
|
||
);
|
||
const droppedStandardKeys = normalizePreviewKeys(
|
||
impact.droppedStandardFieldKeys || impact.droppedStandardFields
|
||
);
|
||
|
||
if (droppedTemplateKeys.length === 0 && droppedStandardKeys.length === 0) {
|
||
continue;
|
||
}
|
||
|
||
materialsWithDroppedFields += 1;
|
||
droppedTemplateFieldCount += droppedTemplateKeys.length;
|
||
droppedStandardFieldCount += droppedStandardKeys.length;
|
||
|
||
const droppedTemplateFields = droppedTemplateKeys.map((key) =>
|
||
buildPreviewFieldSummary(key, sourceTemplateFieldsByKey, 'template')
|
||
);
|
||
const droppedStandardFields = droppedStandardKeys.map((key) =>
|
||
buildPreviewFieldSummary(key, sourceTemplateFieldsByKey, 'standard')
|
||
);
|
||
|
||
droppedTemplateFields.forEach((field) => addPreviewFieldImpact(previewFieldImpacts, field));
|
||
droppedStandardFields.forEach((field) => addPreviewFieldImpact(previewFieldImpacts, field));
|
||
|
||
if (samples.length < 10) {
|
||
samples.push({
|
||
materialId: typeof impact.materialId === 'string' ? impact.materialId : '',
|
||
productModel:
|
||
impact.productModel === undefined || impact.productModel === null || impact.productModel === ''
|
||
? null
|
||
: String(impact.productModel),
|
||
droppedTemplateFields,
|
||
droppedStandardFields,
|
||
});
|
||
}
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
migrationBackup: {
|
||
materialsWithDroppedFields,
|
||
droppedTemplateFieldCount,
|
||
droppedStandardFieldCount,
|
||
},
|
||
migrationPreview: {
|
||
totalMaterialCount,
|
||
totalMaterials: migratableCount,
|
||
previewLimited: previewOnly && totalMaterialCount > migratableCount,
|
||
previewLimit,
|
||
targetCurrentFields: targetFields.map((field) => ({
|
||
key: field.key,
|
||
label: field.label || field.key,
|
||
type: field.type,
|
||
required: field.required,
|
||
})),
|
||
backupFields: {
|
||
templateFields: Array.from(previewFieldImpacts.values()).filter((field) => field.source === 'template'),
|
||
standardFields: Array.from(previewFieldImpacts.values()).filter((field) => field.source === 'standard'),
|
||
},
|
||
samples,
|
||
},
|
||
};
|
||
}
|
||
|
||
export function projectPublicTemplateOptionsRequest(args) {
|
||
const body = args && typeof args === 'object' ? args : {};
|
||
const builtinTemplates = normalizeBuiltinTemplates(body.builtinTemplates);
|
||
const customTemplates = normalizeCustomTemplates(body.customTemplates);
|
||
const materialCounts = body.materialCounts && typeof body.materialCounts === 'object' && !Array.isArray(body.materialCounts)
|
||
? body.materialCounts
|
||
: {};
|
||
|
||
return {
|
||
valid: true,
|
||
templates: projectTemplateList(builtinTemplates, customTemplates),
|
||
uploadTemplates: projectUploadTemplateList(builtinTemplates, customTemplates, materialCounts),
|
||
materialTypes: projectMaterialTypes(body.builtinMaterialTypes, customTemplates),
|
||
};
|
||
}
|
||
|
||
function normalizeFieldKeyList(value) {
|
||
if (!Array.isArray(value)) return [];
|
||
const keys = [];
|
||
const seen = new Set();
|
||
for (const item of value) {
|
||
const rawKey = typeof item === 'string' ? item : item?.key;
|
||
const key = typeof rawKey === 'string' ? rawKey.trim() : '';
|
||
if (!key || seen.has(key)) continue;
|
||
seen.add(key);
|
||
keys.push(key);
|
||
}
|
||
return keys;
|
||
}
|
||
|
||
export function planMaterialCustomSearchRequest(args) {
|
||
const body = args && typeof args === 'object' ? args : {};
|
||
const searchableFieldKeys = normalizeFieldKeyList(body.searchableFields);
|
||
const customFieldKeys = searchableFieldKeys.filter((key) => !STANDARD_MATERIAL_FIELDS.has(key));
|
||
const search = typeof body.search === 'string' ? body.search.trim() : '';
|
||
|
||
return {
|
||
valid: true,
|
||
search,
|
||
customFieldKeys,
|
||
};
|
||
}
|
||
|
||
export function planHomeFilterCustomFieldCountsRequest(args) {
|
||
const body = args && typeof args === 'object' ? args : {};
|
||
const fieldKeys = normalizeFieldKeyList(body.fields);
|
||
const customFieldKeys = fieldKeys.filter((key) => !MATERIAL_HOME_FILTER_COLUMN_FIELDS.has(key));
|
||
|
||
return {
|
||
valid: true,
|
||
customFieldKeys,
|
||
};
|
||
}
|
||
|
||
export function planHomeFilterFieldCountsRequest(args) {
|
||
const body = args && typeof args === 'object' ? args : {};
|
||
const fieldKeys = normalizeFieldKeyList(body.fields);
|
||
|
||
return {
|
||
valid: true,
|
||
fieldPlans: fieldKeys.map((key) => ({
|
||
key,
|
||
source: MATERIAL_HOME_FILTER_COLUMN_FIELDS.has(key) ? 'column' : 'metadata',
|
||
})),
|
||
};
|
||
}
|
||
|
||
function normalizeFilterList(value) {
|
||
if (!Array.isArray(value)) return [];
|
||
const filters = [];
|
||
const seen = new Set();
|
||
|
||
for (const item of value) {
|
||
if (!item || typeof item !== 'object') continue;
|
||
const key = typeof item.key === 'string' ? item.key.trim() : '';
|
||
const rawValue = item.value;
|
||
const filterValue = typeof rawValue === 'string' ? rawValue.trim() : '';
|
||
if (!key || !filterValue) continue;
|
||
const dedupeKey = `${key}\u0000${filterValue}`;
|
||
if (seen.has(dedupeKey)) continue;
|
||
seen.add(dedupeKey);
|
||
filters.push({ key, value: filterValue });
|
||
}
|
||
|
||
return filters;
|
||
}
|
||
|
||
export function planMaterialListQueryRequest(args) {
|
||
const body = args && typeof args === 'object' ? args : {};
|
||
const search = typeof body.search === 'string' ? body.search.trim() : '';
|
||
const searchableFieldKeys = normalizeFieldKeyList(body.searchableFields);
|
||
const enabledHomeFilterKeys = new Set(normalizeFieldKeyList(body.enabledHomeFilterFields));
|
||
const homeFieldFilters = normalizeFilterList(body.homeFieldFilters);
|
||
|
||
return {
|
||
valid: true,
|
||
search,
|
||
standardSearchFields: search ? MATERIAL_LIST_SEARCH_COLUMN_FIELDS : [],
|
||
customSearchFieldKeys: search
|
||
? searchableFieldKeys.filter((key) => !STANDARD_MATERIAL_FIELDS.has(key))
|
||
: [],
|
||
standardHomeFilters: homeFieldFilters.filter((filter) =>
|
||
MATERIAL_LIST_HOME_FILTER_COLUMN_FIELDS.has(filter.key)
|
||
),
|
||
customHomeFilters: homeFieldFilters.filter((filter) =>
|
||
!MATERIAL_LIST_HOME_FILTER_COLUMN_FIELDS.has(filter.key) && enabledHomeFilterKeys.has(filter.key)
|
||
),
|
||
};
|
||
}
|
||
|
||
function normalizeDepartmentName(name) {
|
||
return typeof name === 'string' ? name.trim() : '';
|
||
}
|
||
|
||
function buildDepartmentDescendantSet(rawDepartments) {
|
||
const names = Array.isArray(rawDepartments)
|
||
? rawDepartments
|
||
.map((department) => (
|
||
typeof department === 'string'
|
||
? normalizeDepartmentName(department)
|
||
: normalizeDepartmentName(department?.name)
|
||
))
|
||
.filter(Boolean)
|
||
: [];
|
||
const childrenMap = new Map();
|
||
|
||
const ensureNode = (name) => {
|
||
if (!childrenMap.has(name)) {
|
||
childrenMap.set(name, []);
|
||
}
|
||
};
|
||
|
||
names.forEach((name) => ensureNode(name));
|
||
names.forEach((name) => {
|
||
const lastDashIndex = name.lastIndexOf('-');
|
||
if (lastDashIndex > 0) {
|
||
const parent = name.substring(0, lastDashIndex);
|
||
ensureNode(parent);
|
||
childrenMap.get(parent).push(name);
|
||
}
|
||
});
|
||
childrenMap.forEach((children) => children.sort((a, b) => departmentCollator.compare(a, b)));
|
||
|
||
const descendantMap = new Map();
|
||
const visit = (name) => {
|
||
if (descendantMap.has(name)) return descendantMap.get(name);
|
||
const descendants = new Set([name]);
|
||
for (const child of childrenMap.get(name) || []) {
|
||
for (const descendant of visit(child)) {
|
||
descendants.add(descendant);
|
||
}
|
||
}
|
||
descendantMap.set(name, descendants);
|
||
return descendants;
|
||
};
|
||
childrenMap.forEach((_children, name) => visit(name));
|
||
return descendantMap;
|
||
}
|
||
|
||
export function planDepartmentScopeRequest(args) {
|
||
const body = args && typeof args === 'object' ? args : {};
|
||
const scopeDepartment = normalizeDepartmentName(body.scopeDepartment);
|
||
const targetDepartment = normalizeDepartmentName(body.targetDepartment);
|
||
const descendantMap = buildDepartmentDescendantSet(body.departments);
|
||
const scopedDepartments = scopeDepartment
|
||
? Array.from(descendantMap.get(scopeDepartment) || new Set([scopeDepartment]))
|
||
: [];
|
||
scopedDepartments.sort((a, b) => departmentCollator.compare(a, b));
|
||
|
||
return {
|
||
valid: true,
|
||
scopedDepartments,
|
||
withinScope: Boolean(
|
||
scopeDepartment &&
|
||
targetDepartment &&
|
||
(
|
||
targetDepartment === scopeDepartment ||
|
||
scopedDepartments.includes(targetDepartment) ||
|
||
targetDepartment.startsWith(`${scopeDepartment}-`)
|
||
)
|
||
),
|
||
};
|
||
}
|
||
|
||
function normalizeDepartmentRecords(rawDepartments) {
|
||
if (!Array.isArray(rawDepartments)) return [];
|
||
return rawDepartments
|
||
.map((department) => ({
|
||
id: typeof department?.id === 'string' ? department.id : '',
|
||
name: normalizeDepartmentName(department?.name),
|
||
}))
|
||
.filter((department) => department.id && department.name);
|
||
}
|
||
|
||
function normalizeDepartmentUsers(rawUsers) {
|
||
if (!Array.isArray(rawUsers)) return [];
|
||
return rawUsers
|
||
.map((user) => ({
|
||
id: typeof user?.id === 'string' ? user.id : '',
|
||
department: typeof user?.department === 'string' ? user.department : '',
|
||
managedDepartment:
|
||
typeof user?.managedDepartment === 'string' && user.managedDepartment
|
||
? user.managedDepartment
|
||
: null,
|
||
}))
|
||
.filter((user) => user.id);
|
||
}
|
||
|
||
function denyDepartmentStructureChange(statusCode, error, errorCode, details) {
|
||
return {
|
||
valid: true,
|
||
allowed: false,
|
||
statusCode,
|
||
error,
|
||
errorCode,
|
||
details,
|
||
};
|
||
}
|
||
|
||
function renameDepartmentValue(value, oldName, nextName) {
|
||
if (!value) return value;
|
||
const oldPrefix = `${oldName}-`;
|
||
const nextPrefix = `${nextName}-`;
|
||
if (value === oldName) return nextName;
|
||
if (value.startsWith(oldPrefix)) {
|
||
return `${nextPrefix}${value.slice(oldPrefix.length)}`;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
export function planDepartmentStructureChangeRequest(args) {
|
||
const body = args && typeof args === 'object' ? args : {};
|
||
const action = typeof body.action === 'string' ? body.action.trim() : '';
|
||
const target = body.target && typeof body.target === 'object' ? body.target : {};
|
||
const departments = normalizeDepartmentRecords(body.departments);
|
||
const users = normalizeDepartmentUsers(body.users);
|
||
const targetId = typeof target.id === 'string' ? target.id : '';
|
||
const nextName = normalizeDepartmentName(target.name);
|
||
|
||
if (!['create', 'rename', 'delete'].includes(action)) {
|
||
return denyDepartmentStructureChange(400, '部门操作类型无效', 'INVALID_DEPARTMENT_ACTION');
|
||
}
|
||
|
||
if (action === 'create') {
|
||
if (!nextName) {
|
||
return denyDepartmentStructureChange(400, '部门名称不能为空', 'DEPARTMENT_NAME_REQUIRED');
|
||
}
|
||
if (departments.some((department) => department.name === nextName)) {
|
||
return denyDepartmentStructureChange(400, '部门已存在', 'DEPARTMENT_NAME_EXISTS', {
|
||
name: nextName,
|
||
});
|
||
}
|
||
return {
|
||
valid: true,
|
||
allowed: true,
|
||
action,
|
||
departmentOps: [{ type: 'create', data: { name: nextName } }],
|
||
userOps: [],
|
||
summary: { createdName: nextName },
|
||
};
|
||
}
|
||
|
||
if (!targetId) {
|
||
return denyDepartmentStructureChange(400, '缺少部门ID', 'DEPARTMENT_ID_REQUIRED');
|
||
}
|
||
|
||
const targetDepartment = departments.find((department) => department.id === targetId);
|
||
if (!targetDepartment) {
|
||
return denyDepartmentStructureChange(404, '未找到对应的部门', 'DEPARTMENT_NOT_FOUND', {
|
||
id: targetId,
|
||
});
|
||
}
|
||
|
||
if (action === 'rename') {
|
||
if (!nextName) {
|
||
return denyDepartmentStructureChange(400, '部门名称不能为空', 'DEPARTMENT_NAME_REQUIRED');
|
||
}
|
||
if (targetDepartment.name === nextName) {
|
||
return {
|
||
valid: true,
|
||
allowed: false,
|
||
statusCode: 200,
|
||
response: targetDepartment,
|
||
};
|
||
}
|
||
|
||
const oldName = targetDepartment.name;
|
||
const oldPrefix = `${oldName}-`;
|
||
const nextPrefix = `${nextName}-`;
|
||
const renameItems = departments
|
||
.filter((department) => department.id === targetId || department.name.startsWith(oldPrefix))
|
||
.map((department) => ({
|
||
id: department.id,
|
||
oldName: department.name,
|
||
newName: department.id === targetId
|
||
? nextName
|
||
: `${nextPrefix}${department.name.slice(oldPrefix.length)}`,
|
||
}));
|
||
const renameIds = new Set(renameItems.map((item) => item.id));
|
||
const nextNames = new Set(renameItems.map((item) => item.newName));
|
||
const conflictNames = departments
|
||
.filter((department) => nextNames.has(department.name) && !renameIds.has(department.id))
|
||
.map((department) => department.name);
|
||
|
||
if (conflictNames.length > 0) {
|
||
return denyDepartmentStructureChange(400, `存在同名分支:${conflictNames.join('、')}`, 'DEPARTMENT_NAME_CONFLICT', {
|
||
conflictNames,
|
||
});
|
||
}
|
||
|
||
const userOps = users
|
||
.map((user) => {
|
||
const nextDepartment = renameDepartmentValue(user.department, oldName, nextName) || '';
|
||
const nextManagedDepartment = renameDepartmentValue(user.managedDepartment, oldName, nextName) || null;
|
||
const data = {};
|
||
if (nextDepartment !== user.department) data.department = nextDepartment;
|
||
if (nextManagedDepartment !== user.managedDepartment) data.managedDepartment = nextManagedDepartment;
|
||
return Object.keys(data).length > 0 ? { type: 'update', id: user.id, data } : null;
|
||
})
|
||
.filter(Boolean);
|
||
|
||
return {
|
||
valid: true,
|
||
allowed: true,
|
||
action,
|
||
departmentOps: renameItems.map((item) => ({
|
||
type: 'rename',
|
||
id: item.id,
|
||
oldName: item.oldName,
|
||
newName: item.newName,
|
||
})),
|
||
userOps,
|
||
summary: {
|
||
oldName,
|
||
newName: nextName,
|
||
renamedDepartmentCount: renameItems.length,
|
||
updatedUserCount: userOps.length,
|
||
},
|
||
};
|
||
}
|
||
|
||
const deletePrefix = `${targetDepartment.name}-`;
|
||
const deleteItems = departments.filter((department) =>
|
||
department.id === targetId || department.name.startsWith(deletePrefix)
|
||
);
|
||
const targetIds = deleteItems.map((department) => department.id);
|
||
const targetNames = deleteItems.map((department) => department.name);
|
||
|
||
return {
|
||
valid: true,
|
||
allowed: true,
|
||
action,
|
||
departmentOps: [{ type: 'deleteMany', ids: targetIds, names: targetNames }],
|
||
userOps: [
|
||
{ type: 'clearDepartmentIn', names: targetNames },
|
||
{ type: 'clearManagedDepartmentIn', names: targetNames },
|
||
],
|
||
summary: {
|
||
removedDepartmentCount: targetIds.length,
|
||
removedDepartments: targetNames,
|
||
},
|
||
};
|
||
}
|
||
|
||
export function calculateTemplateFieldStatsRequest(args) {
|
||
const body = args && typeof args === 'object' ? args : {};
|
||
const fields = Array.isArray(body.fields)
|
||
? body.fields
|
||
.filter((field) => field && typeof field === 'object' && typeof field.key === 'string' && field.key)
|
||
.map((field) => ({
|
||
key: field.key,
|
||
label: typeof field.label === 'string' && field.label ? field.label : field.key,
|
||
type: typeof field.type === 'string' && field.type ? field.type : 'text',
|
||
}))
|
||
: [];
|
||
const materials = Array.isArray(body.materials) ? body.materials : [];
|
||
|
||
if (materials.length === 0) {
|
||
return {
|
||
valid: true,
|
||
fieldStats: null,
|
||
};
|
||
}
|
||
|
||
const totalMaterials = materials.length;
|
||
const stats = fields.map((field) => {
|
||
let filledCount = 0;
|
||
|
||
for (const material of materials) {
|
||
const metadata = parseExistingMetadata(material?.metadata);
|
||
const templateFields = getRecordValue(metadata.templateFields);
|
||
if (hasFilledTemplateFieldValue(templateFields[field.key])) {
|
||
filledCount += 1;
|
||
}
|
||
}
|
||
|
||
const fillRate = totalMaterials > 0 ? (filledCount / totalMaterials) * 100 : 0;
|
||
|
||
return {
|
||
fieldKey: field.key,
|
||
fieldLabel: field.label,
|
||
fieldType: field.type,
|
||
filledCount,
|
||
totalCount: totalMaterials,
|
||
fillRate: Math.round(fillRate * 100) / 100,
|
||
isLowFillRate: fillRate < 30,
|
||
};
|
||
});
|
||
|
||
stats.sort((a, b) => a.fillRate - b.fillRate);
|
||
|
||
return {
|
||
valid: true,
|
||
fieldStats: {
|
||
totalMaterials,
|
||
fields: stats,
|
||
lowFillRateFields: stats.filter((field) => field.isLowFillRate),
|
||
},
|
||
};
|
||
}
|
||
|
||
export function prepareMaterialMetadataRequest(args) {
|
||
const body = args && typeof args === 'object' ? args : {};
|
||
const templateFields = body.templateFields && typeof body.templateFields === 'object' && !Array.isArray(body.templateFields)
|
||
? body.templateFields
|
||
: {};
|
||
const templateId = typeof body.templateId === 'string' && body.templateId ? body.templateId : null;
|
||
const previewUrls = Array.isArray(body.previewUrls) ? body.previewUrls.filter((url) => typeof url === 'string') : [];
|
||
const existingMetadata = typeof body.existingMetadata === 'string' ? body.existingMetadata : null;
|
||
const maxSizeBytes = Number.isFinite(body.maxSizeBytes) && body.maxSizeBytes > 0
|
||
? Math.floor(body.maxSizeBytes)
|
||
: 65535;
|
||
|
||
const { standardData, customFields } = separateSubmittedMaterialFields(templateFields);
|
||
const rawMetadata = buildSubmittedMaterialMetadata(customFields, templateId, previewUrls, existingMetadata);
|
||
const metadata = compressSubmittedMaterialMetadata(rawMetadata);
|
||
const size = getUtf8Size(metadata);
|
||
|
||
if (size > maxSizeBytes) {
|
||
return {
|
||
valid: false,
|
||
error: `自定义字段数据过大,当前约 ${size} 字节,最大允许 ${maxSizeBytes} 字节。请减少字段内容后重试。`,
|
||
errorCode: 'METADATA_TOO_LARGE',
|
||
details: {
|
||
size,
|
||
maxSize: maxSizeBytes,
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
standardData,
|
||
metadata,
|
||
metadataSize: {
|
||
ok: true,
|
||
size,
|
||
maxSize: maxSizeBytes,
|
||
},
|
||
};
|
||
}
|
||
|
||
export function normalizeUploadTemplateSaveRequest(payload, mode = 'create') {
|
||
const body = payload && typeof payload === 'object' ? payload : {};
|
||
const isCreate = mode !== 'update';
|
||
const template = {};
|
||
|
||
if (isCreate || hasOwn(body, 'name')) {
|
||
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
||
if (!name) {
|
||
return validationError('模板名称不能为空', 'name');
|
||
}
|
||
template.name = name;
|
||
}
|
||
|
||
if (hasOwn(body, 'description')) {
|
||
template.description = body.description ?? null;
|
||
} else if (isCreate) {
|
||
template.description = null;
|
||
}
|
||
|
||
const rawStatus = hasOwn(body, 'status') ? body.status : 'draft';
|
||
if (isCreate || hasOwn(body, 'status')) {
|
||
if (typeof rawStatus !== 'string' || !ALLOWED_TEMPLATE_STATUSES.has(rawStatus)) {
|
||
return validationError('模板状态必须是 draft、active 或 archived', 'status');
|
||
}
|
||
template.status = rawStatus;
|
||
}
|
||
|
||
if (isCreate || hasOwn(body, 'materialType')) {
|
||
const materialType = typeof body.materialType === 'string' ? body.materialType.trim() : '';
|
||
if (!materialType) {
|
||
return validationError('素材类型不能为空', 'materialType');
|
||
}
|
||
template.materialType = materialType;
|
||
}
|
||
|
||
if (isCreate || hasOwn(body, 'materialKind')) {
|
||
const materialKind = typeof body.materialKind === 'string' ? body.materialKind.trim().toUpperCase() : '';
|
||
if (!materialKind || !ALLOWED_MATERIAL_KINDS.has(materialKind)) {
|
||
return validationError(
|
||
`素材大类必须是以下之一:${Array.from(ALLOWED_MATERIAL_KINDS).join(', ')}`,
|
||
'materialKind'
|
||
);
|
||
}
|
||
template.materialKind = materialKind;
|
||
}
|
||
|
||
if (hasOwn(body, 'fieldGroups')) {
|
||
template.fieldGroups = body.fieldGroups ?? null;
|
||
} else if (isCreate) {
|
||
template.fieldGroups = null;
|
||
}
|
||
|
||
if (hasOwn(body, 'displayConfig')) {
|
||
template.displayConfig = body.displayConfig ?? null;
|
||
} else if (isCreate) {
|
||
template.displayConfig = null;
|
||
}
|
||
|
||
const fieldsProvided = isCreate || hasOwn(body, 'fields');
|
||
if (fieldsProvided) {
|
||
const formattedFieldsResult = validateAndFormatUploadTemplateFields(
|
||
hasOwn(body, 'fields') ? body.fields : []
|
||
);
|
||
if (!formattedFieldsResult.valid) {
|
||
return formattedFieldsResult;
|
||
}
|
||
template.fields = formattedFieldsResult.fields;
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
mode: isCreate ? 'create' : 'update',
|
||
fieldsProvided,
|
||
template,
|
||
};
|
||
}
|
||
|
||
export function planUploadTemplateDeleteRequest(payload) {
|
||
const body = payload && typeof payload === 'object' ? payload : {};
|
||
const templateName =
|
||
typeof body.templateName === 'string' && body.templateName.trim()
|
||
? body.templateName.trim()
|
||
: '未命名模板';
|
||
const materialCount = Number.isFinite(Number(body.materialCount))
|
||
? Math.max(0, Math.trunc(Number(body.materialCount)))
|
||
: 0;
|
||
|
||
if (materialCount > 0) {
|
||
return {
|
||
valid: true,
|
||
allowed: false,
|
||
error: `无法删除模板"${templateName}",因为有 ${materialCount} 个素材正在使用该模板。请先删除或迁移这些素材。`,
|
||
errorCode: 'TEMPLATE_IN_USE',
|
||
details: {
|
||
templateName,
|
||
materialCount,
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
allowed: true,
|
||
templateName,
|
||
materialCount,
|
||
};
|
||
}
|
||
|
||
function parsePositiveInteger(value) {
|
||
if (value === undefined || value === null || value === '') {
|
||
return null;
|
||
}
|
||
|
||
const parsed = Number.parseInt(String(value), 10);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
return null;
|
||
}
|
||
|
||
return parsed;
|
||
}
|
||
|
||
export function planTemplateMigrationBatchRequest(payload) {
|
||
const body = payload && typeof payload === 'object' ? payload : {};
|
||
const request = body.request && typeof body.request === 'object' ? body.request : {};
|
||
const sourceTemplateId = typeof body.sourceTemplateId === 'string' ? body.sourceTemplateId.trim() : '';
|
||
const targetTemplateId = typeof body.targetTemplateId === 'string' ? body.targetTemplateId.trim() : '';
|
||
const sourceTemplateName =
|
||
typeof body.sourceTemplateName === 'string' && body.sourceTemplateName.trim()
|
||
? body.sourceTemplateName.trim()
|
||
: '源模板';
|
||
const targetTemplateName =
|
||
typeof body.targetTemplateName === 'string' && body.targetTemplateName.trim()
|
||
? body.targetTemplateName.trim()
|
||
: '目标模板';
|
||
const totalMaterialCount = Number.isFinite(Number(body.totalMaterialCount))
|
||
? Math.max(0, Math.trunc(Number(body.totalMaterialCount)))
|
||
: 0;
|
||
const previewOnly = request.previewOnly === true;
|
||
const forceLargeMigration = request.forceLargeMigration === true;
|
||
const updateMaterialType = request.updateMaterialType !== false;
|
||
const migrationLimit = Math.min(
|
||
parsePositiveInteger(request.migrationLimit) ?? DEFAULT_MIGRATION_SAFE_LIMIT,
|
||
MAX_MIGRATION_FORCE_LIMIT
|
||
);
|
||
|
||
if (!targetTemplateId) {
|
||
return {
|
||
valid: true,
|
||
allowed: false,
|
||
statusCode: 400,
|
||
error: '请选择目标模板',
|
||
errorCode: 'MISSING_TARGET',
|
||
};
|
||
}
|
||
|
||
if (sourceTemplateId && sourceTemplateId === targetTemplateId) {
|
||
return {
|
||
valid: true,
|
||
allowed: false,
|
||
statusCode: 400,
|
||
error: '源模板和目标模板不能相同',
|
||
errorCode: 'SAME_TEMPLATE',
|
||
};
|
||
}
|
||
|
||
if (totalMaterialCount === 0) {
|
||
return {
|
||
valid: true,
|
||
allowed: false,
|
||
statusCode: 200,
|
||
response: {
|
||
success: true,
|
||
migratedCount: 0,
|
||
message: '没有需要迁移的素材',
|
||
},
|
||
};
|
||
}
|
||
|
||
if (!previewOnly && totalMaterialCount > DEFAULT_MIGRATION_SAFE_LIMIT && !forceLargeMigration) {
|
||
return {
|
||
valid: true,
|
||
allowed: false,
|
||
statusCode: 409,
|
||
error: `该模板当前有 ${totalMaterialCount} 个素材,超过默认安全迁移数量 ${DEFAULT_MIGRATION_SAFE_LIMIT}。请先预览迁移影响,确认备份后再执行。`,
|
||
errorCode: 'MIGRATION_CONFIRM_REQUIRED',
|
||
details: {
|
||
totalMaterialCount,
|
||
safeLimit: DEFAULT_MIGRATION_SAFE_LIMIT,
|
||
migrationLimit,
|
||
maxBatchLimit: MAX_MIGRATION_FORCE_LIMIT,
|
||
sourceTemplateId,
|
||
targetTemplateId,
|
||
nextStep: '使用 previewOnly=true 查看迁移影响;确认备份后传入 forceLargeMigration=true,系统会按批迁移直到完成。',
|
||
},
|
||
};
|
||
}
|
||
|
||
const batchLimited = previewOnly || (forceLargeMigration && totalMaterialCount > migrationLimit);
|
||
|
||
return {
|
||
valid: true,
|
||
allowed: true,
|
||
sourceTemplateId,
|
||
targetTemplateId,
|
||
sourceTemplateName,
|
||
targetTemplateName,
|
||
totalMaterialCount,
|
||
previewOnly,
|
||
forceLargeMigration,
|
||
updateMaterialType,
|
||
migrationLimit,
|
||
safeLimit: DEFAULT_MIGRATION_SAFE_LIMIT,
|
||
maxBatchLimit: MAX_MIGRATION_FORCE_LIMIT,
|
||
batchLimited,
|
||
materialTake: batchLimited ? migrationLimit : null,
|
||
migratedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
function buildUploadTemplateUpdateData(template, nextVersion) {
|
||
const updateData = {};
|
||
|
||
if (template.name !== undefined) updateData.name = template.name;
|
||
if (template.description !== undefined) updateData.description = template.description;
|
||
if (template.status !== undefined) updateData.status = template.status;
|
||
if (template.fieldGroups !== undefined) updateData.fieldGroups = template.fieldGroups;
|
||
if (template.displayConfig !== undefined) updateData.displayConfig = template.displayConfig;
|
||
if (template.materialType !== undefined) updateData.materialType = template.materialType;
|
||
if (template.materialKind !== undefined) updateData.materialKind = template.materialKind;
|
||
updateData.version = nextVersion;
|
||
|
||
return updateData;
|
||
}
|
||
|
||
export function planUploadTemplateUpdateRequest(payload) {
|
||
const body = payload && typeof payload === 'object' ? payload : {};
|
||
const existing = body.existingTemplate && typeof body.existingTemplate === 'object'
|
||
? body.existingTemplate
|
||
: {};
|
||
const updatePayload = body.payload && typeof body.payload === 'object' ? body.payload : {};
|
||
const normalizedResult = normalizeUploadTemplateSaveRequest(updatePayload, 'update');
|
||
|
||
if (!existing.id) {
|
||
return {
|
||
valid: false,
|
||
error: '缺少现有模板信息',
|
||
errorCode: 'EXISTING_TEMPLATE_REQUIRED',
|
||
field: 'existingTemplate',
|
||
statusCode: 400,
|
||
};
|
||
}
|
||
|
||
const existingVersion = existing.version || 1;
|
||
const clientVersion = hasOwn(updatePayload, 'version') ? updatePayload.version : undefined;
|
||
if (clientVersion !== undefined && clientVersion !== existing.version) {
|
||
return {
|
||
valid: false,
|
||
error: '模板已被其他人修改,请刷新后重试',
|
||
errorCode: 'VERSION_CONFLICT',
|
||
field: 'version',
|
||
statusCode: 409,
|
||
details: {
|
||
templateName: existing.name,
|
||
clientVersion,
|
||
serverVersion: existing.version,
|
||
lastUpdated: existing.updatedAt,
|
||
},
|
||
};
|
||
}
|
||
|
||
if (!normalizedResult.valid) {
|
||
return {
|
||
valid: false,
|
||
error: normalizedResult.error,
|
||
errorCode: 'VALIDATION_ERROR',
|
||
field: normalizedResult.field,
|
||
statusCode: 400,
|
||
details: normalizedResult.details,
|
||
};
|
||
}
|
||
|
||
const nextVersion = existingVersion + 1;
|
||
const normalized = normalizedResult.template;
|
||
const updateData = buildUploadTemplateUpdateData(normalized, nextVersion);
|
||
const formattedFields = normalizedResult.fieldsProvided ? (normalized.fields || []) : null;
|
||
|
||
if (formattedFields) {
|
||
const existingFieldIds = new Set(
|
||
(Array.isArray(existing.fields) ? existing.fields : [])
|
||
.map((field) => field && field.id)
|
||
.filter(Boolean)
|
||
);
|
||
const foreignField = formattedFields.find((field) => field.id && !existingFieldIds.has(field.id));
|
||
if (foreignField) {
|
||
return {
|
||
valid: false,
|
||
error: '字段不属于当前模板,请刷新后重试',
|
||
errorCode: 'FIELD_NOT_IN_TEMPLATE',
|
||
field: 'fields',
|
||
statusCode: 400,
|
||
details: {
|
||
fieldId: foreignField.id,
|
||
fieldKey: foreignField.key,
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
const materialCount = Number.isFinite(Number(existing.materialCount))
|
||
? Math.max(0, Math.trunc(Number(existing.materialCount)))
|
||
: 0;
|
||
if (materialCount > 0) {
|
||
const dangerousChanges = detectDangerousTemplateChanges({
|
||
existingTemplate: {
|
||
materialType: existing.materialType,
|
||
materialKind: existing.materialKind,
|
||
fields: Array.isArray(existing.fields) ? existing.fields : [],
|
||
},
|
||
nextMaterialType: updateData.materialType,
|
||
nextMaterialKind: updateData.materialKind,
|
||
nextFields: formattedFields || undefined,
|
||
});
|
||
|
||
if (dangerousChanges.length > 0) {
|
||
return {
|
||
valid: false,
|
||
error: `模板“${existing.name}”已有 ${materialCount} 个素材在使用,不能直接修改素材大类、素材类型,也不能删除字段、修改字段 key、修改字段类型、取消已加入分类的字段、关闭已启用搜索的字段、把可填写字段改为只读、把字段改为必填、修改校验规则、删除选项或修改显示条件。请新建模板后迁移素材。`,
|
||
errorCode: 'TEMPLATE_STRUCTURE_LOCKED',
|
||
field: 'template',
|
||
statusCode: 409,
|
||
details: {
|
||
templateName: existing.name,
|
||
materialCount,
|
||
dangerousChanges,
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
updateData,
|
||
fieldsProvided: normalizedResult.fieldsProvided,
|
||
fields: formattedFields,
|
||
nextVersion,
|
||
};
|
||
}
|