1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
384 lines
10 KiB
TypeScript
384 lines
10 KiB
TypeScript
/**
|
||
* 字段迁移工具
|
||
*
|
||
* 功能:
|
||
* - 字段key修改的数据迁移
|
||
* - 字段类型转换验证
|
||
* - 批量更新素材字段结构
|
||
* - 字段恢复功能
|
||
*/
|
||
|
||
import { parseMetadata, buildMetadata, isStandardField, FieldValue } from './field-data-storage'
|
||
|
||
/**
|
||
* 字段迁移操作类型
|
||
*/
|
||
export type MigrationOperation =
|
||
| { type: 'rename'; oldKey: string; newKey: string }
|
||
| { type: 'delete'; key: string }
|
||
| { type: 'restore'; key: string; defaultValue?: string }
|
||
| { type: 'typeChange'; key: string; oldType: string; newType: string }
|
||
|
||
/**
|
||
* 迁移结果
|
||
*/
|
||
export interface MigrationResult {
|
||
success: boolean
|
||
materialId: string
|
||
operation: MigrationOperation
|
||
oldValue?: unknown
|
||
newValue?: unknown
|
||
error?: string
|
||
}
|
||
|
||
/**
|
||
* 批量迁移结果
|
||
*/
|
||
export interface BatchMigrationResult {
|
||
totalCount: number
|
||
successCount: number
|
||
failedCount: number
|
||
skippedCount: number
|
||
results: MigrationResult[]
|
||
}
|
||
|
||
/**
|
||
* 字段类型兼容性检查
|
||
*/
|
||
export function checkTypeCompatibility(
|
||
oldType: string,
|
||
newType: string,
|
||
sampleValue?: unknown
|
||
): { compatible: boolean; warning?: string; canConvert: boolean } {
|
||
// 相同类型总是兼容
|
||
if (oldType === newType) {
|
||
return { compatible: true, canConvert: true }
|
||
}
|
||
|
||
// 定义类型转换规则
|
||
const conversionRules: Record<string, Record<string, { compatible: boolean; warning?: string }>> = {
|
||
text: {
|
||
textarea: { compatible: true },
|
||
number: { compatible: false, warning: '文本可能无法转换为数字' },
|
||
select: { compatible: true, warning: '文本值可能不在选项列表中' },
|
||
checkbox: { compatible: false, warning: '文本无法转换为布尔值' },
|
||
date: { compatible: false, warning: '文本格式可能不是有效日期' },
|
||
},
|
||
textarea: {
|
||
text: { compatible: true, warning: '多行文本将被截断为单行' },
|
||
number: { compatible: false, warning: '文本可能无法转换为数字' },
|
||
select: { compatible: true, warning: '文本值可能不在选项列表中' },
|
||
},
|
||
number: {
|
||
text: { compatible: true },
|
||
textarea: { compatible: true },
|
||
select: { compatible: true, warning: '数字值可能不在选项列表中' },
|
||
},
|
||
select: {
|
||
text: { compatible: true },
|
||
textarea: { compatible: true },
|
||
number: { compatible: false, warning: '选项值可能无法转换为数字' },
|
||
},
|
||
checkbox: {
|
||
text: { compatible: true },
|
||
select: { compatible: true, warning: '布尔值将转换为 "true" 或 "false"' },
|
||
},
|
||
date: {
|
||
text: { compatible: true },
|
||
textarea: { compatible: true },
|
||
},
|
||
}
|
||
|
||
const rules = conversionRules[oldType]?.[newType]
|
||
if (rules) {
|
||
return { ...rules, canConvert: rules.compatible }
|
||
}
|
||
|
||
// 默认不兼容
|
||
return {
|
||
compatible: false,
|
||
warning: `不支持从 ${oldType} 转换到 ${newType}`,
|
||
canConvert: false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 转换字段值类型
|
||
*/
|
||
export function convertFieldValue(
|
||
value: unknown,
|
||
fromType: string,
|
||
toType: string
|
||
): { success: boolean; value: unknown; error?: string } {
|
||
if (value === null || value === undefined) {
|
||
return { success: true, value: null }
|
||
}
|
||
|
||
const stringValue = String(value)
|
||
|
||
try {
|
||
switch (toType) {
|
||
case 'text':
|
||
case 'textarea':
|
||
return { success: true, value: stringValue }
|
||
|
||
case 'number':
|
||
const num = parseFloat(stringValue)
|
||
if (isNaN(num)) {
|
||
return { success: false, value: null, error: `无法将 "${stringValue}" 转换为数字` }
|
||
}
|
||
return { success: true, value: num }
|
||
|
||
case 'checkbox':
|
||
const boolValue = stringValue.toLowerCase() === 'true' || stringValue === '1'
|
||
return { success: true, value: boolValue }
|
||
|
||
case 'date':
|
||
const date = new Date(stringValue)
|
||
if (isNaN(date.getTime())) {
|
||
return { success: false, value: null, error: `无法将 "${stringValue}" 转换为日期` }
|
||
}
|
||
return { success: true, value: date.toISOString().split('T')[0] }
|
||
|
||
case 'select':
|
||
// select类型保持原值,由调用方验证是否在选项列表中
|
||
return { success: true, value: stringValue }
|
||
|
||
default:
|
||
return { success: true, value: stringValue }
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
value: null,
|
||
error: `转换失败: ${error instanceof Error ? error.message : String(error)}`
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重命名metadata中的字段key
|
||
*/
|
||
export function renameFieldInMetadata(
|
||
metadata: string | null,
|
||
oldKey: string,
|
||
newKey: string
|
||
): { metadata: string | null; oldValue: unknown; migrated: boolean } {
|
||
if (!metadata) {
|
||
return { metadata: null, oldValue: undefined, migrated: false }
|
||
}
|
||
|
||
const parsed = parseMetadata(metadata)
|
||
const templateFields = parsed.templateFields
|
||
|
||
if (!(oldKey in templateFields)) {
|
||
return { metadata, oldValue: undefined, migrated: false }
|
||
}
|
||
|
||
const oldValue = templateFields[oldKey]
|
||
delete templateFields[oldKey]
|
||
templateFields[newKey] = oldValue
|
||
|
||
const newMetadata = buildMetadata(
|
||
templateFields,
|
||
parsed.templateId,
|
||
parsed.previewUrls
|
||
)
|
||
|
||
return { metadata: newMetadata, oldValue, migrated: true }
|
||
}
|
||
|
||
/**
|
||
* 从metadata中删除字段
|
||
*/
|
||
export function deleteFieldFromMetadata(
|
||
metadata: string | null,
|
||
key: string
|
||
): { metadata: string | null; oldValue: unknown; deleted: boolean } {
|
||
if (!metadata) {
|
||
return { metadata: null, oldValue: undefined, deleted: false }
|
||
}
|
||
|
||
const parsed = parseMetadata(metadata)
|
||
const templateFields = parsed.templateFields
|
||
|
||
if (!(key in templateFields)) {
|
||
return { metadata, oldValue: undefined, deleted: false }
|
||
}
|
||
|
||
const oldValue = templateFields[key]
|
||
delete templateFields[key]
|
||
|
||
const newMetadata = buildMetadata(
|
||
templateFields,
|
||
parsed.templateId,
|
||
parsed.previewUrls
|
||
)
|
||
|
||
return { metadata: newMetadata, oldValue, deleted: true }
|
||
}
|
||
|
||
/**
|
||
* 恢复metadata中的字段(从备份或默认值)
|
||
*/
|
||
export function restoreFieldInMetadata(
|
||
metadata: string | null,
|
||
key: string,
|
||
value: FieldValue
|
||
): { metadata: string | null; restored: boolean } {
|
||
const parsed = parseMetadata(metadata)
|
||
const templateFields = parsed.templateFields
|
||
|
||
// 如果字段已存在,不覆盖
|
||
if (key in templateFields) {
|
||
return { metadata, restored: false }
|
||
}
|
||
|
||
templateFields[key] = value
|
||
|
||
const newMetadata = buildMetadata(
|
||
templateFields,
|
||
parsed.templateId,
|
||
parsed.previewUrls
|
||
)
|
||
|
||
return { metadata: newMetadata, restored: true }
|
||
}
|
||
|
||
/**
|
||
* 验证迁移操作
|
||
*/
|
||
export function validateMigrationOperation(
|
||
operation: MigrationOperation
|
||
): { valid: boolean; error?: string } {
|
||
switch (operation.type) {
|
||
case 'rename':
|
||
if (!operation.oldKey || !operation.newKey) {
|
||
return { valid: false, error: '重命名操作需要提供旧键名和新键名' }
|
||
}
|
||
if (operation.oldKey === operation.newKey) {
|
||
return { valid: false, error: '新键名不能与旧键名相同' }
|
||
}
|
||
// 检查是否尝试重命名标准字段
|
||
if (isStandardField(operation.oldKey)) {
|
||
return { valid: false, error: `不能重命名标准字段 "${operation.oldKey}"` }
|
||
}
|
||
break
|
||
|
||
case 'delete':
|
||
if (!operation.key) {
|
||
return { valid: false, error: '删除操作需要提供键名' }
|
||
}
|
||
if (isStandardField(operation.key)) {
|
||
return { valid: false, error: `不能删除标准字段 "${operation.key}"` }
|
||
}
|
||
break
|
||
|
||
case 'restore':
|
||
if (!operation.key) {
|
||
return { valid: false, error: '恢复操作需要提供键名' }
|
||
}
|
||
break
|
||
|
||
case 'typeChange':
|
||
if (!operation.key || !operation.oldType || !operation.newType) {
|
||
return { valid: false, error: '类型转换操作需要提供键名、旧类型和新类型' }
|
||
}
|
||
const compatibility = checkTypeCompatibility(operation.oldType, operation.newType)
|
||
if (!compatibility.canConvert) {
|
||
return { valid: false, error: compatibility.warning || '类型不兼容' }
|
||
}
|
||
break
|
||
}
|
||
|
||
return { valid: true }
|
||
}
|
||
|
||
/**
|
||
* 生成迁移预览
|
||
*/
|
||
export function generateMigrationPreview(
|
||
materials: Array<{ id: string; metadata: string | null }>,
|
||
operation: MigrationOperation
|
||
): {
|
||
affectedCount: number
|
||
sampleChanges: Array<{ materialId: string; before: unknown; after: unknown }>
|
||
} {
|
||
const affectedMaterials: Array<{ materialId: string; before: unknown; after: unknown }> = []
|
||
|
||
for (const material of materials) {
|
||
const parsed = parseMetadata(material.metadata)
|
||
const templateFields = parsed.templateFields
|
||
|
||
switch (operation.type) {
|
||
case 'rename':
|
||
if (operation.oldKey in templateFields) {
|
||
affectedMaterials.push({
|
||
materialId: material.id,
|
||
before: { [operation.oldKey]: templateFields[operation.oldKey] },
|
||
after: { [operation.newKey]: templateFields[operation.oldKey] },
|
||
})
|
||
}
|
||
break
|
||
|
||
case 'delete':
|
||
if (operation.key in templateFields) {
|
||
affectedMaterials.push({
|
||
materialId: material.id,
|
||
before: { [operation.key]: templateFields[operation.key] },
|
||
after: { [operation.key]: undefined },
|
||
})
|
||
}
|
||
break
|
||
|
||
case 'restore':
|
||
if (!(operation.key in templateFields)) {
|
||
affectedMaterials.push({
|
||
materialId: material.id,
|
||
before: { [operation.key]: undefined },
|
||
after: { [operation.key]: operation.defaultValue },
|
||
})
|
||
}
|
||
break
|
||
|
||
case 'typeChange':
|
||
if (operation.key in templateFields) {
|
||
const oldValue = templateFields[operation.key]
|
||
const converted = convertFieldValue(oldValue, operation.oldType, operation.newType)
|
||
affectedMaterials.push({
|
||
materialId: material.id,
|
||
before: { [operation.key]: oldValue, type: operation.oldType },
|
||
after: { [operation.key]: converted.value, type: operation.newType },
|
||
})
|
||
}
|
||
break
|
||
}
|
||
}
|
||
|
||
return {
|
||
affectedCount: affectedMaterials.length,
|
||
sampleChanges: affectedMaterials.slice(0, 5), // 只返回前5个示例
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建迁移回滚数据
|
||
*/
|
||
export function createRollbackData(
|
||
materialId: string,
|
||
operation: MigrationOperation,
|
||
originalMetadata: string | null
|
||
): {
|
||
materialId: string
|
||
operation: MigrationOperation
|
||
originalMetadata: string | null
|
||
timestamp: string
|
||
} {
|
||
return {
|
||
materialId,
|
||
operation,
|
||
originalMetadata,
|
||
timestamp: new Date().toISOString(),
|
||
}
|
||
}
|