diff --git a/app/admin/_components/TemplateManager.tsx b/app/admin/_components/TemplateManager.tsx index fd3edd1..caf7a41 100644 --- a/app/admin/_components/TemplateManager.tsx +++ b/app/admin/_components/TemplateManager.tsx @@ -39,6 +39,47 @@ const STATUS_OPTIONS = [ const MATERIAL_TYPE_OPTIONS = ['3DMAX', 'PSD', '音频', '视频', '模特', '其他'] +type MigrationPreviewField = { + key: string + label: string + type?: string + source: 'template' | 'standard' + affectedCount?: number +} + +type MigrationPreviewResponse = { + success: boolean + previewOnly: true + migratableCount: number + message: string + sourceTemplate: { id: string; name: string } + targetTemplate: { id: string; name: string } + migrationBackup: { + materialsWithDroppedFields: number + droppedTemplateFieldCount: number + droppedStandardFieldCount: number + } + migrationPreview: { + totalMaterials: number + targetCurrentFields: Array<{ + key: string + label: string + type: string + required: boolean + }> + backupFields: { + templateFields: MigrationPreviewField[] + standardFields: MigrationPreviewField[] + } + samples: Array<{ + materialId: string + productModel: string | null + droppedTemplateFields: MigrationPreviewField[] + droppedStandardFields: MigrationPreviewField[] + }> + } +} + // 默认需要进入分类管理的字段集合,可被后台实时配置覆盖 const DEFAULT_CATEGORY_FIELDS = [ 'categoryName', @@ -484,6 +525,9 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { const [migrationSourceTemplate, setMigrationSourceTemplate] = useState<{ id: string; name: string; materialCount: number } | null>(null) const [migrationTargetTemplateId, setMigrationTargetTemplateId] = useState('') const [migrating, setMigrating] = useState(false) + const [migrationPreview, setMigrationPreview] = useState(null) + const [migrationPreviewLoading, setMigrationPreviewLoading] = useState(false) + const [migrationPreviewError, setMigrationPreviewError] = useState(null) const syncCategoryFieldSet = useCallback((keys: string[]) => { GLOBAL_CATEGORY_FIELDS.clear() @@ -1649,6 +1693,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { materialCount: templateDetail.materialCount, }) setMigrationTargetTemplateId('') + setMigrationPreview(null) + setMigrationPreviewError(null) setMigrationDialogOpen(true) return } @@ -1673,10 +1719,46 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { setDeletingId(null) } } + + const handleMigrationTargetChange = (targetTemplateId: string) => { + setMigrationTargetTemplateId(targetTemplateId) + setMigrationPreview(null) + setMigrationPreviewError(null) + } + + const loadMigrationPreview = async () => { + if (!migrationSourceTemplate || !migrationTargetTemplateId || migrationPreviewLoading) return + + setMigrationPreviewLoading(true) + setMigrationPreviewError(null) + try { + const preview = await requestJson( + `/api/admin/upload-templates/${migrationSourceTemplate.id}/migrate`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + targetTemplateId: migrationTargetTemplateId, + previewOnly: true, + }), + } + ) + setMigrationPreview(preview) + } catch (error: any) { + setMigrationPreview(null) + setMigrationPreviewError(error.message || '生成迁移预览失败') + } finally { + setMigrationPreviewLoading(false) + } + } // 执行素材迁移并删除模板 const handleMigrateAndDelete = async () => { if (!migrationSourceTemplate || !migrationTargetTemplateId) return + if (!migrationPreview || migrationPreview.targetTemplate.id !== migrationTargetTemplateId) { + setMigrationPreviewError('请先生成迁移预览,确认字段影响后再执行迁移。') + return + } setMigrating(true) try { @@ -1701,6 +1783,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { setMigrationDialogOpen(false) setMigrationSourceTemplate(null) setMigrationTargetTemplateId('') + setMigrationPreview(null) + setMigrationPreviewError(null) // 通知首页刷新模板列表 notifyTemplatesUpdate() @@ -1713,10 +1797,12 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { // 关闭迁移弹窗 const closeMigrationDialog = () => { - if (migrating) return + if (migrating || migrationPreviewLoading) return setMigrationDialogOpen(false) setMigrationSourceTemplate(null) setMigrationTargetTemplateId('') + setMigrationPreview(null) + setMigrationPreviewError(null) } // 获取可选的目标模板列表(排除源模板) @@ -1933,12 +2019,12 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { -
+
@@ -1972,8 +2058,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
+ +
+
+
+

+ 迁移预览 +

+

+ 预览不会修改数据,确认字段影响后再执行迁移。 +

+
+ +
+ + {migrationPreviewError && ( +

+ {migrationPreviewError} +

+ )} + + {migrationPreview && ( +
+
+
+

可迁移素材

+

+ {migrationPreview.migratableCount} +

+
+
+

需备份素材

+

+ {migrationPreview.migrationBackup.materialsWithDroppedFields} +

+
+
+

备份字段

+

+ {migrationPreview.migrationBackup.droppedTemplateFieldCount + migrationPreview.migrationBackup.droppedStandardFieldCount} +

+
+
+ +
+

+ 迁移后当前字段 +

+
+ {migrationPreview.migrationPreview.targetCurrentFields.slice(0, 12).map((field) => ( + + {field.label}{field.required ? ' *' : ''} + + ))} + {migrationPreview.migrationPreview.targetCurrentFields.length > 12 && ( + + 另有 {migrationPreview.migrationPreview.targetCurrentFields.length - 12} 个 + + )} +
+
+ + {(migrationPreview.migrationPreview.backupFields.templateFields.length > 0 || + migrationPreview.migrationPreview.backupFields.standardFields.length > 0) ? ( +
+

+ 会进入备份区的旧字段 +

+
+ {[ + ...migrationPreview.migrationPreview.backupFields.templateFields, + ...migrationPreview.migrationPreview.backupFields.standardFields, + ].slice(0, 12).map((field) => ( + + {field.label}({field.affectedCount || 0}) + + ))} +
+ {migrationPreview.migrationPreview.samples.length > 0 && ( +

+ 示例素材:{migrationPreview.migrationPreview.samples + .map((sample) => sample.productModel || sample.materialId) + .join('、')} +

+ )} +
+ ) : ( +

+ 目标模板已接收当前字段,本次迁移不会产生旧字段备份。 +

+ )} +
+ )} +
diff --git a/docs/商业化地基排查问题清单_20260524.md b/docs/商业化地基排查问题清单_20260524.md index cd1663d..b49c9ad 100644 --- a/docs/商业化地基排查问题清单_20260524.md +++ b/docs/商业化地基排查问题清单_20260524.md @@ -676,7 +676,7 @@ NAS 断电、容器重启、升级重启后,正在处理的视频可能永久 - 已完成:默认保留旧字段到备份命名空间,避免历史字段无声丢失。 - 已完成:迁移前可通过 `previewOnly: true` 获取预览报告,列出哪些字段会进入备份区,哪些字段会成为目标模板当前字段。 -- 仍需继续:前端模板管理页还需要把迁移预览接入正式确认流程,避免管理员直接盲点迁移按钮。 +- 已完成:前端模板管理页的迁移删除弹窗已接入预览确认流程;管理员必须先生成当前目标模板的迁移预览,确认字段影响后才能点击“确认迁移并删除”。 ### P1-14 上传会截断 metadata,可能截坏 JSON(已修复上传、编辑和模板迁移入口)