接入模板迁移前端预览

This commit is contained in:
WuYongJie 2026-05-28 17:26:20 +08:00
parent 0fcbd3bcb3
commit 7b9260d8d7
2 changed files with 209 additions and 11 deletions

View File

@ -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<string>('')
const [migrating, setMigrating] = useState(false)
const [migrationPreview, setMigrationPreview] = useState<MigrationPreviewResponse | null>(null)
const [migrationPreviewLoading, setMigrationPreviewLoading] = useState(false)
const [migrationPreviewError, setMigrationPreviewError] = useState<string | null>(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<MigrationPreviewResponse>(
`/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 }) {
<Modal
isOpen={migrationDialogOpen}
onClose={closeMigrationDialog}
size="medium"
size="large"
showCloseButton={true}
closeOnOverlayClick={!migrating}
closeOnEscape={!migrating}
closeOnOverlayClick={!migrating && !migrationPreviewLoading}
closeOnEscape={!migrating && !migrationPreviewLoading}
>
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg p-6 max-w-lg w-full">
<div className="bg-white dark:bg-slate-900 rounded-lg shadow-lg p-6 max-h-[84vh] overflow-y-auto">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1972,8 +2058,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
</label>
<select
value={migrationTargetTemplateId}
onChange={(e) => setMigrationTargetTemplateId(e.target.value)}
disabled={migrating}
onChange={(e) => handleMigrationTargetChange(e.target.value)}
disabled={migrating || migrationPreviewLoading}
className={cn(
'w-full px-3 py-2 rounded-lg border transition-colors',
'bg-white dark:bg-slate-800',
@ -2013,11 +2099,123 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
</p>
)}
</div>
<div className="mb-6">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
迁移预览
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
预览不会修改数据,确认字段影响后再执行迁移。
</p>
</div>
<button
type="button"
onClick={loadMigrationPreview}
disabled={migrating || migrationPreviewLoading || !migrationTargetTemplateId}
className={cn(
'px-3 py-2 rounded-lg text-sm font-medium transition-colors',
'border border-slate-300 dark:border-slate-600',
'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200',
'hover:bg-slate-50 dark:hover:bg-slate-700',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{migrationPreviewLoading ? '生成中...' : migrationPreview ? '刷新预览' : '生成预览'}
</button>
</div>
{migrationPreviewError && (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
{migrationPreviewError}
</p>
)}
{migrationPreview && (
<div className="mt-3 rounded-lg border border-slate-200 dark:border-slate-700 p-4 space-y-4">
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">可迁移素材</p>
<p className="mt-1 font-semibold text-slate-900 dark:text-white">
{migrationPreview.migratableCount}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">需备份素材</p>
<p className="mt-1 font-semibold text-slate-900 dark:text-white">
{migrationPreview.migrationBackup.materialsWithDroppedFields}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">备份字段</p>
<p className="mt-1 font-semibold text-slate-900 dark:text-white">
{migrationPreview.migrationBackup.droppedTemplateFieldCount + migrationPreview.migrationBackup.droppedStandardFieldCount}
</p>
</div>
</div>
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
迁移后当前字段
</p>
<div className="flex flex-wrap gap-2">
{migrationPreview.migrationPreview.targetCurrentFields.slice(0, 12).map((field) => (
<span
key={field.key}
className="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-700 dark:bg-slate-800 dark:text-slate-300"
>
{field.label}{field.required ? ' *' : ''}
</span>
))}
{migrationPreview.migrationPreview.targetCurrentFields.length > 12 && (
<span className="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
另有 {migrationPreview.migrationPreview.targetCurrentFields.length - 12} 个
</span>
)}
</div>
</div>
{(migrationPreview.migrationPreview.backupFields.templateFields.length > 0 ||
migrationPreview.migrationPreview.backupFields.standardFields.length > 0) ? (
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
会进入备份区的旧字段
</p>
<div className="flex flex-wrap gap-2">
{[
...migrationPreview.migrationPreview.backupFields.templateFields,
...migrationPreview.migrationPreview.backupFields.standardFields,
].slice(0, 12).map((field) => (
<span
key={`${field.source}-${field.key}`}
className="rounded-md bg-amber-50 px-2 py-1 text-xs text-amber-800 dark:bg-amber-900/20 dark:text-amber-200"
>
{field.label}({field.affectedCount || 0})
</span>
))}
</div>
{migrationPreview.migrationPreview.samples.length > 0 && (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
示例素材:{migrationPreview.migrationPreview.samples
.map((sample) => sample.productModel || sample.materialId)
.join('、')}
</p>
)}
</div>
) : (
<p className="text-sm text-slate-500 dark:text-slate-400">
目标模板已接收当前字段,本次迁移不会产生旧字段备份。
</p>
)}
</div>
)}
</div>
<div className="flex gap-3 justify-end">
<button
onClick={closeMigrationDialog}
disabled={migrating}
disabled={migrating || migrationPreviewLoading}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300',
@ -2029,7 +2227,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
</button>
<button
onClick={handleMigrateAndDelete}
disabled={migrating || !migrationTargetTemplateId}
disabled={migrating || migrationPreviewLoading || !migrationTargetTemplateId || !migrationPreview || migrationPreview.targetTemplate.id !== migrationTargetTemplateId}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
'bg-red-600 text-white',
@ -2047,7 +2245,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
迁移中...
</>
) : (
<>迁移并删除</>
<>确认迁移并删除</>
)}
</button>
</div>

View File

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