1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
485 lines
12 KiB
TypeScript
485 lines
12 KiB
TypeScript
/**
|
||
* 分类管理与模板的双向同步
|
||
*
|
||
* 功能:
|
||
* - 分类管理修改选项时同步到模板
|
||
* - 模板修改选项时同步到字段池
|
||
* - 删除字段时警告影响范围
|
||
* - 使用版本号检测同步冲突
|
||
* - 同步失败时回滚
|
||
* - 记录同步历史日志
|
||
*
|
||
* @created 2024-12-13
|
||
*/
|
||
|
||
import { prisma } from '@/lib/prisma'
|
||
|
||
// 类型定义
|
||
export interface SyncResult {
|
||
success: boolean
|
||
syncedCount: number
|
||
conflictCount: number
|
||
errors: Array<{ source: string; target: string; error: string }>
|
||
rollbackPerformed: boolean
|
||
}
|
||
|
||
export interface SyncLogEntry {
|
||
id: string
|
||
timestamp: Date
|
||
operation: 'add' | 'update' | 'delete' | 'sync'
|
||
source: 'category' | 'template'
|
||
fieldKey: string
|
||
optionValue?: string
|
||
details: string
|
||
success: boolean
|
||
error?: string
|
||
}
|
||
|
||
// 内存中的同步日志(生产环境应该持久化到数据库)
|
||
const syncLogs: SyncLogEntry[] = []
|
||
const MAX_LOG_ENTRIES = 1000
|
||
|
||
/**
|
||
* 记录同步日志
|
||
*/
|
||
function logSync(entry: Omit<SyncLogEntry, 'id' | 'timestamp'>): void {
|
||
const logEntry: SyncLogEntry = {
|
||
id: `sync_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||
timestamp: new Date(),
|
||
...entry,
|
||
}
|
||
|
||
syncLogs.unshift(logEntry)
|
||
|
||
// 限制日志数量
|
||
if (syncLogs.length > MAX_LOG_ENTRIES) {
|
||
syncLogs.pop()
|
||
}
|
||
|
||
// 输出到控制台(开发环境)
|
||
if (process.env.NODE_ENV === 'development') {
|
||
console.log(`[CategorySync] ${entry.operation} ${entry.source}:${entry.fieldKey}`, entry.details)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取同步日志
|
||
*/
|
||
export function getSyncLogs(limit = 100): SyncLogEntry[] {
|
||
return syncLogs.slice(0, limit)
|
||
}
|
||
|
||
/**
|
||
* 清除同步日志
|
||
*/
|
||
export function clearSyncLogs(): void {
|
||
syncLogs.length = 0
|
||
}
|
||
|
||
/**
|
||
* 从分类管理同步选项到模板
|
||
* 当在分类管理中添加/修改/删除选项时调用
|
||
*/
|
||
export async function syncOptionToTemplates(
|
||
fieldKey: string,
|
||
operation: 'add' | 'update' | 'delete',
|
||
optionValue: string,
|
||
newValue?: string
|
||
): Promise<SyncResult> {
|
||
const result: SyncResult = {
|
||
success: true,
|
||
syncedCount: 0,
|
||
conflictCount: 0,
|
||
errors: [],
|
||
rollbackPerformed: false,
|
||
}
|
||
|
||
try {
|
||
// 获取所有使用该字段的模板字段
|
||
const templateFields = await prisma.uploadField.findMany({
|
||
where: { key: fieldKey },
|
||
include: {
|
||
template: true,
|
||
},
|
||
})
|
||
|
||
if (templateFields.length === 0) {
|
||
logSync({
|
||
operation,
|
||
source: 'category',
|
||
fieldKey,
|
||
optionValue,
|
||
details: '没有找到使用该字段的模板',
|
||
success: true,
|
||
})
|
||
return result
|
||
}
|
||
|
||
for (const field of templateFields) {
|
||
try {
|
||
// 解析现有选项
|
||
let options: string[] = []
|
||
if (field.optionsStatic) {
|
||
try {
|
||
const parsed = JSON.parse(field.optionsStatic)
|
||
options = Array.isArray(parsed) ? parsed : []
|
||
} catch {
|
||
options = []
|
||
}
|
||
}
|
||
|
||
let updated = false
|
||
let newOptions = [...options]
|
||
|
||
switch (operation) {
|
||
case 'add':
|
||
if (!options.includes(optionValue)) {
|
||
newOptions.push(optionValue)
|
||
updated = true
|
||
}
|
||
break
|
||
|
||
case 'update':
|
||
if (newValue && options.includes(optionValue)) {
|
||
newOptions = options.map(opt => opt === optionValue ? newValue : opt)
|
||
updated = true
|
||
}
|
||
break
|
||
|
||
case 'delete':
|
||
if (options.includes(optionValue)) {
|
||
newOptions = options.filter(opt => opt !== optionValue)
|
||
updated = true
|
||
}
|
||
break
|
||
}
|
||
|
||
if (updated) {
|
||
// 检查版本冲突
|
||
const currentField = await prisma.uploadField.findUnique({
|
||
where: { id: field.id },
|
||
})
|
||
|
||
if (currentField && currentField.updatedAt > field.updatedAt) {
|
||
result.conflictCount++
|
||
result.errors.push({
|
||
source: `category:${fieldKey}`,
|
||
target: `template:${field.templateId}:${field.key}`,
|
||
error: '版本冲突:模板字段已被其他操作修改',
|
||
})
|
||
continue
|
||
}
|
||
|
||
// 更新模板字段
|
||
await prisma.uploadField.update({
|
||
where: { id: field.id },
|
||
data: {
|
||
optionsStatic: JSON.stringify(newOptions),
|
||
updatedAt: new Date(),
|
||
},
|
||
})
|
||
|
||
result.syncedCount++
|
||
}
|
||
} catch (error) {
|
||
result.errors.push({
|
||
source: `category:${fieldKey}`,
|
||
target: `template:${field.templateId}:${field.key}`,
|
||
error: error instanceof Error ? error.message : '未知错误',
|
||
})
|
||
}
|
||
}
|
||
|
||
logSync({
|
||
operation,
|
||
source: 'category',
|
||
fieldKey,
|
||
optionValue,
|
||
details: `同步到 ${result.syncedCount} 个模板字段,${result.conflictCount} 个冲突`,
|
||
success: result.errors.length === 0,
|
||
error: result.errors.length > 0 ? result.errors[0].error : undefined,
|
||
})
|
||
|
||
result.success = result.errors.length === 0
|
||
} catch (error) {
|
||
result.success = false
|
||
result.errors.push({
|
||
source: `category:${fieldKey}`,
|
||
target: 'templates',
|
||
error: error instanceof Error ? error.message : '未知错误',
|
||
})
|
||
|
||
logSync({
|
||
operation,
|
||
source: 'category',
|
||
fieldKey,
|
||
optionValue,
|
||
details: '同步失败',
|
||
success: false,
|
||
error: error instanceof Error ? error.message : '未知错误',
|
||
})
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 从模板同步选项到字段池
|
||
* 当在模板管理中修改字段选项时调用
|
||
*/
|
||
export async function syncOptionToFieldPool(
|
||
fieldKey: string,
|
||
options: string[]
|
||
): Promise<SyncResult> {
|
||
const result: SyncResult = {
|
||
success: true,
|
||
syncedCount: 0,
|
||
conflictCount: 0,
|
||
errors: [],
|
||
rollbackPerformed: false,
|
||
}
|
||
|
||
try {
|
||
// 获取字段池中的字段
|
||
const fieldDef = await prisma.fieldDefinition.findUnique({
|
||
where: { key: fieldKey },
|
||
include: {
|
||
options: {
|
||
orderBy: { orderIndex: 'asc' },
|
||
},
|
||
},
|
||
})
|
||
|
||
if (!fieldDef) {
|
||
// 字段不存在,创建新字段
|
||
await prisma.fieldDefinition.create({
|
||
data: {
|
||
key: fieldKey,
|
||
label: fieldKey,
|
||
type: 'select',
|
||
source: 'template',
|
||
isStandard: false,
|
||
isFilterEnabled: false,
|
||
filterOrder: 0,
|
||
},
|
||
})
|
||
}
|
||
|
||
// 获取现有选项
|
||
const existingOptions = fieldDef?.options || []
|
||
const existingValues = new Set(existingOptions.map(o => o.value))
|
||
|
||
// 获取字段ID(可能是新创建的)
|
||
const field = await prisma.fieldDefinition.findUnique({
|
||
where: { key: fieldKey },
|
||
select: { id: true },
|
||
})
|
||
|
||
if (!field) {
|
||
throw new Error(`字段 ${fieldKey} 不存在`)
|
||
}
|
||
|
||
// 添加新选项
|
||
let orderIndex = existingOptions.length
|
||
for (const optionValue of options) {
|
||
if (!existingValues.has(optionValue)) {
|
||
await prisma.fieldOption.create({
|
||
data: {
|
||
fieldId: field.id,
|
||
value: optionValue,
|
||
label: optionValue,
|
||
orderIndex: orderIndex++,
|
||
},
|
||
})
|
||
result.syncedCount++
|
||
}
|
||
}
|
||
|
||
logSync({
|
||
operation: 'sync',
|
||
source: 'template',
|
||
fieldKey,
|
||
details: `同步 ${result.syncedCount} 个新选项到字段池`,
|
||
success: true,
|
||
})
|
||
|
||
result.success = true
|
||
} catch (error) {
|
||
result.success = false
|
||
result.errors.push({
|
||
source: `template:${fieldKey}`,
|
||
target: `fieldPool:${fieldKey}`,
|
||
error: error instanceof Error ? error.message : '未知错误',
|
||
})
|
||
|
||
logSync({
|
||
operation: 'sync',
|
||
source: 'template',
|
||
fieldKey,
|
||
details: '同步到字段池失败',
|
||
success: false,
|
||
error: error instanceof Error ? error.message : '未知错误',
|
||
})
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 获取字段删除的影响范围
|
||
*/
|
||
export async function getFieldDeletionImpact(fieldKey: string): Promise<{
|
||
templateCount: number
|
||
templateNames: string[]
|
||
optionCount: number
|
||
canDelete: boolean
|
||
warning: string
|
||
}> {
|
||
// 获取使用该字段的模板
|
||
const templateFields = await prisma.uploadField.findMany({
|
||
where: { key: fieldKey },
|
||
include: {
|
||
template: {
|
||
select: { name: true },
|
||
},
|
||
},
|
||
})
|
||
|
||
const templateNames = templateFields.map(f => f.template.name)
|
||
const templateCount = templateNames.length
|
||
|
||
// 获取字段定义
|
||
const fieldDef = await prisma.fieldDefinition.findUnique({
|
||
where: { key: fieldKey },
|
||
select: { id: true },
|
||
})
|
||
|
||
// 获取选项数量
|
||
const optionCount = fieldDef
|
||
? await prisma.fieldOption.count({
|
||
where: { fieldId: fieldDef.id },
|
||
})
|
||
: 0
|
||
|
||
// 判断是否可以删除
|
||
const canDelete = templateCount === 0
|
||
|
||
// 生成警告信息
|
||
let warning = ''
|
||
if (templateCount > 0) {
|
||
warning = `该字段被 ${templateCount} 个模板使用(${templateNames.slice(0, 3).join('、')}${templateCount > 3 ? '等' : ''}),删除后这些模板将失去该字段。`
|
||
}
|
||
if (optionCount > 0) {
|
||
warning += warning ? '\n' : ''
|
||
warning += `该字段有 ${optionCount} 个选项将被一并删除。`
|
||
}
|
||
|
||
return {
|
||
templateCount,
|
||
templateNames,
|
||
optionCount,
|
||
canDelete,
|
||
warning,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 执行带回滚的同步操作
|
||
*/
|
||
export async function syncWithRollback<T>(
|
||
operation: () => Promise<T>,
|
||
rollback: () => Promise<void>
|
||
): Promise<{ success: boolean; result?: T; error?: string; rollbackPerformed: boolean }> {
|
||
try {
|
||
const result = await operation()
|
||
return { success: true, result, rollbackPerformed: false }
|
||
} catch (error) {
|
||
// 尝试回滚
|
||
try {
|
||
await rollback()
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : '未知错误',
|
||
rollbackPerformed: true,
|
||
}
|
||
} catch (rollbackError) {
|
||
return {
|
||
success: false,
|
||
error: `操作失败: ${error instanceof Error ? error.message : '未知错误'}; 回滚也失败: ${rollbackError instanceof Error ? rollbackError.message : '未知错误'}`,
|
||
rollbackPerformed: false,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 批量同步所有模板字段到字段池
|
||
*/
|
||
export async function syncAllTemplatesToFieldPool(): Promise<SyncResult> {
|
||
const result: SyncResult = {
|
||
success: true,
|
||
syncedCount: 0,
|
||
conflictCount: 0,
|
||
errors: [],
|
||
rollbackPerformed: false,
|
||
}
|
||
|
||
try {
|
||
// 获取所有模板字段
|
||
const templateFields = await prisma.uploadField.findMany({
|
||
where: {
|
||
type: 'select',
|
||
optionsStatic: { not: null },
|
||
},
|
||
})
|
||
|
||
// 按字段key分组
|
||
const fieldOptionsMap = new Map<string, Set<string>>()
|
||
|
||
for (const field of templateFields) {
|
||
if (!field.optionsStatic) continue
|
||
|
||
try {
|
||
const options = JSON.parse(field.optionsStatic)
|
||
if (!Array.isArray(options)) continue
|
||
|
||
const existing = fieldOptionsMap.get(field.key) || new Set()
|
||
options.forEach((opt: string) => {
|
||
if (typeof opt === 'string' && opt.trim()) {
|
||
existing.add(opt.trim())
|
||
}
|
||
})
|
||
fieldOptionsMap.set(field.key, existing)
|
||
} catch {
|
||
// 忽略解析错误
|
||
}
|
||
}
|
||
|
||
// 同步到字段池
|
||
for (const [fieldKey, options] of Array.from(fieldOptionsMap.entries())) {
|
||
const syncResult = await syncOptionToFieldPool(fieldKey, Array.from(options))
|
||
result.syncedCount += syncResult.syncedCount
|
||
result.errors.push(...syncResult.errors)
|
||
}
|
||
|
||
logSync({
|
||
operation: 'sync',
|
||
source: 'template',
|
||
fieldKey: '*',
|
||
details: `批量同步完成,共同步 ${result.syncedCount} 个选项`,
|
||
success: result.errors.length === 0,
|
||
})
|
||
|
||
result.success = result.errors.length === 0
|
||
} catch (error) {
|
||
result.success = false
|
||
result.errors.push({
|
||
source: 'templates',
|
||
target: 'fieldPool',
|
||
error: error instanceof Error ? error.message : '未知错误',
|
||
})
|
||
}
|
||
|
||
return result
|
||
}
|