1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
419 lines
9.9 KiB
TypeScript
419 lines
9.9 KiB
TypeScript
/**
|
||
* 分类管理批量操作工具
|
||
*
|
||
* 支持以下批量操作:
|
||
* - 批量启用/禁用字段
|
||
* - 批量删除字段
|
||
* - 批量导入选项(CSV/JSON)
|
||
* - 批量导出选项
|
||
*
|
||
* @created 2024-12-13
|
||
*/
|
||
|
||
import { prisma } from '@/lib/prisma'
|
||
|
||
// 类型定义
|
||
export interface BatchOperationResult {
|
||
success: boolean
|
||
processed: number
|
||
failed: number
|
||
errors: Array<{ key: string; error: string }>
|
||
}
|
||
|
||
export interface ImportedOption {
|
||
fieldKey: string
|
||
value: string
|
||
label?: string
|
||
}
|
||
|
||
export interface ExportedField {
|
||
key: string
|
||
label: string
|
||
filterDisplayName: string | null
|
||
filterOrder: number
|
||
isFilterEnabled: boolean
|
||
options: Array<{ value: string; label: string | null }>
|
||
}
|
||
|
||
/**
|
||
* 批量启用/禁用字段
|
||
*/
|
||
export async function batchToggleFields(
|
||
fieldKeys: string[],
|
||
enabled: boolean
|
||
): Promise<BatchOperationResult> {
|
||
const result: BatchOperationResult = {
|
||
success: true,
|
||
processed: 0,
|
||
failed: 0,
|
||
errors: [],
|
||
}
|
||
|
||
for (const key of fieldKeys) {
|
||
try {
|
||
await prisma.fieldDefinition.update({
|
||
where: { key },
|
||
data: {
|
||
isFilterEnabled: enabled,
|
||
updatedAt: new Date(),
|
||
},
|
||
})
|
||
result.processed++
|
||
} catch (error) {
|
||
result.failed++
|
||
result.errors.push({
|
||
key,
|
||
error: error instanceof Error ? error.message : '未知错误',
|
||
})
|
||
}
|
||
}
|
||
|
||
result.success = result.failed === 0
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 批量删除字段(仅删除字段池中的记录,不影响模板)
|
||
*/
|
||
export async function batchDeleteFields(
|
||
fieldKeys: string[]
|
||
): Promise<BatchOperationResult> {
|
||
const result: BatchOperationResult = {
|
||
success: true,
|
||
processed: 0,
|
||
failed: 0,
|
||
errors: [],
|
||
}
|
||
|
||
for (const key of fieldKeys) {
|
||
try {
|
||
// 先获取字段ID
|
||
const field = await prisma.fieldDefinition.findUnique({
|
||
where: { key },
|
||
select: { id: true },
|
||
})
|
||
|
||
if (field) {
|
||
// 先删除关联的选项(使用 fieldId)
|
||
await prisma.fieldOption.deleteMany({
|
||
where: { fieldId: field.id },
|
||
})
|
||
// 再删除字段定义
|
||
await prisma.fieldDefinition.delete({
|
||
where: { key },
|
||
})
|
||
}
|
||
result.processed++
|
||
} catch (error) {
|
||
result.failed++
|
||
result.errors.push({
|
||
key,
|
||
error: error instanceof Error ? error.message : '未知错误',
|
||
})
|
||
}
|
||
}
|
||
|
||
result.success = result.failed === 0
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 解析CSV格式的选项数据
|
||
* 格式:fieldKey,value,label(label可选)
|
||
*/
|
||
export function parseCSVOptions(csvContent: string): ImportedOption[] {
|
||
const lines = csvContent.split('\n').filter(line => line.trim())
|
||
const options: ImportedOption[] = []
|
||
|
||
// 跳过标题行(如果有)
|
||
const startIndex = lines[0]?.toLowerCase().includes('fieldkey') ? 1 : 0
|
||
|
||
for (let i = startIndex; i < lines.length; i++) {
|
||
const line = lines[i].trim()
|
||
if (!line) continue
|
||
|
||
// 处理CSV中的引号
|
||
const parts = parseCSVLine(line)
|
||
if (parts.length >= 2) {
|
||
options.push({
|
||
fieldKey: parts[0].trim(),
|
||
value: parts[1].trim(),
|
||
label: parts[2]?.trim() || undefined,
|
||
})
|
||
}
|
||
}
|
||
|
||
return options
|
||
}
|
||
|
||
/**
|
||
* 解析单行CSV(处理引号)
|
||
*/
|
||
function parseCSVLine(line: string): string[] {
|
||
const result: string[] = []
|
||
let current = ''
|
||
let inQuotes = false
|
||
|
||
for (let i = 0; i < line.length; i++) {
|
||
const char = line[i]
|
||
|
||
if (char === '"') {
|
||
if (inQuotes && line[i + 1] === '"') {
|
||
current += '"'
|
||
i++
|
||
} else {
|
||
inQuotes = !inQuotes
|
||
}
|
||
} else if (char === ',' && !inQuotes) {
|
||
result.push(current)
|
||
current = ''
|
||
} else {
|
||
current += char
|
||
}
|
||
}
|
||
|
||
result.push(current)
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 解析JSON格式的选项数据
|
||
*/
|
||
export function parseJSONOptions(jsonContent: string): ImportedOption[] {
|
||
try {
|
||
const data = JSON.parse(jsonContent)
|
||
|
||
// 支持两种格式:
|
||
// 1. 数组格式:[{ fieldKey, value, label? }, ...]
|
||
// 2. 对象格式:{ fieldKey: [{ value, label? }, ...], ... }
|
||
|
||
if (Array.isArray(data)) {
|
||
return data.filter(item => item.fieldKey && item.value).map(item => ({
|
||
fieldKey: String(item.fieldKey),
|
||
value: String(item.value),
|
||
label: item.label ? String(item.label) : undefined,
|
||
}))
|
||
}
|
||
|
||
if (typeof data === 'object' && data !== null) {
|
||
const options: ImportedOption[] = []
|
||
for (const [fieldKey, values] of Object.entries(data)) {
|
||
if (Array.isArray(values)) {
|
||
for (const item of values) {
|
||
if (typeof item === 'string') {
|
||
options.push({ fieldKey, value: item })
|
||
} else if (item && typeof item === 'object' && item.value) {
|
||
options.push({
|
||
fieldKey,
|
||
value: String(item.value),
|
||
label: item.label ? String(item.label) : undefined,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return options
|
||
}
|
||
|
||
return []
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 批量导入选项
|
||
*/
|
||
export async function batchImportOptions(
|
||
options: ImportedOption[],
|
||
mode: 'merge' | 'replace' = 'merge'
|
||
): Promise<BatchOperationResult> {
|
||
const result: BatchOperationResult = {
|
||
success: true,
|
||
processed: 0,
|
||
failed: 0,
|
||
errors: [],
|
||
}
|
||
|
||
// 按字段分组
|
||
const optionsByField = new Map<string, ImportedOption[]>()
|
||
for (const option of options) {
|
||
const existing = optionsByField.get(option.fieldKey) || []
|
||
existing.push(option)
|
||
optionsByField.set(option.fieldKey, existing)
|
||
}
|
||
|
||
for (const [fieldKey, fieldOptions] of Array.from(optionsByField.entries())) {
|
||
try {
|
||
// 检查字段是否存在
|
||
const field = await prisma.fieldDefinition.findUnique({
|
||
where: { key: fieldKey },
|
||
})
|
||
|
||
if (!field) {
|
||
result.failed += fieldOptions.length
|
||
result.errors.push({
|
||
key: fieldKey,
|
||
error: `字段 ${fieldKey} 不存在`,
|
||
})
|
||
continue
|
||
}
|
||
|
||
if (mode === 'replace') {
|
||
// 替换模式:先删除所有现有选项
|
||
await prisma.fieldOption.deleteMany({
|
||
where: { fieldId: field.id },
|
||
})
|
||
}
|
||
|
||
// 获取现有选项(用于去重和确定orderIndex)
|
||
const existingOptions = await prisma.fieldOption.findMany({
|
||
where: { fieldId: field.id },
|
||
orderBy: { orderIndex: 'desc' },
|
||
})
|
||
const existingValues = new Set(existingOptions.map(o => o.value))
|
||
let nextOrderIndex = (existingOptions[0]?.orderIndex ?? -1) + 1
|
||
|
||
// 添加新选项
|
||
for (const option of fieldOptions) {
|
||
if (mode === 'merge' && existingValues.has(option.value)) {
|
||
// 合并模式下跳过已存在的选项
|
||
continue
|
||
}
|
||
|
||
await prisma.fieldOption.create({
|
||
data: {
|
||
fieldId: field.id,
|
||
value: option.value,
|
||
label: option.label || '',
|
||
orderIndex: nextOrderIndex++,
|
||
},
|
||
})
|
||
result.processed++
|
||
}
|
||
} catch (error) {
|
||
result.failed += fieldOptions.length
|
||
result.errors.push({
|
||
key: fieldKey,
|
||
error: error instanceof Error ? error.message : '未知错误',
|
||
})
|
||
}
|
||
}
|
||
|
||
result.success = result.failed === 0
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 导出所有字段和选项
|
||
*/
|
||
export async function exportAllFields(): Promise<ExportedField[]> {
|
||
const fields = await prisma.fieldDefinition.findMany({
|
||
orderBy: [
|
||
{ filterOrder: 'asc' },
|
||
{ label: 'asc' },
|
||
],
|
||
include: {
|
||
options: {
|
||
orderBy: { orderIndex: 'asc' },
|
||
},
|
||
},
|
||
})
|
||
|
||
return fields.map(field => ({
|
||
key: field.key,
|
||
label: field.label,
|
||
filterDisplayName: field.filterDisplayName,
|
||
filterOrder: field.filterOrder,
|
||
isFilterEnabled: field.isFilterEnabled,
|
||
options: field.options.map(opt => ({
|
||
value: opt.value,
|
||
label: opt.label,
|
||
})),
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* 导出为CSV格式
|
||
*/
|
||
export function exportToCSV(fields: ExportedField[]): string {
|
||
const lines: string[] = ['fieldKey,value,label']
|
||
|
||
for (const field of fields) {
|
||
for (const option of field.options) {
|
||
const escapedValue = escapeCSVField(option.value)
|
||
const escapedLabel = escapeCSVField(option.label || '')
|
||
lines.push(`${field.key},${escapedValue},${escapedLabel}`)
|
||
}
|
||
}
|
||
|
||
return lines.join('\n')
|
||
}
|
||
|
||
/**
|
||
* 导出为JSON格式
|
||
*/
|
||
export function exportToJSON(fields: ExportedField[]): string {
|
||
return JSON.stringify(fields, null, 2)
|
||
}
|
||
|
||
/**
|
||
* 转义CSV字段
|
||
*/
|
||
function escapeCSVField(value: string): string {
|
||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||
return `"${value.replace(/"/g, '""')}"`
|
||
}
|
||
return value
|
||
}
|
||
|
||
/**
|
||
* 获取字段的影响范围(用于删除前警告)
|
||
*/
|
||
export async function getFieldImpactInfo(fieldKey: string): Promise<{
|
||
templateCount: number
|
||
materialCount: number
|
||
optionCount: number
|
||
}> {
|
||
// 获取使用该字段的模板数量
|
||
const templateCount = await prisma.uploadField.count({
|
||
where: { key: fieldKey },
|
||
})
|
||
|
||
// 获取字段定义
|
||
const field = await prisma.fieldDefinition.findUnique({
|
||
where: { key: fieldKey },
|
||
select: { id: true },
|
||
})
|
||
|
||
// 获取该字段的选项数量
|
||
const optionCount = field
|
||
? await prisma.fieldOption.count({
|
||
where: { fieldId: field.id },
|
||
})
|
||
: 0
|
||
|
||
// 获取使用该字段的素材数量
|
||
// 注意:这是一个近似值,只统计标准字段
|
||
let materialCount = 0
|
||
try {
|
||
// 对于标准字段,直接查询
|
||
const standardFields = ['categoryName', 'sceneName', 'productModel', 'renderer', 'brandName', 'seriesName']
|
||
if (standardFields.includes(fieldKey)) {
|
||
materialCount = await prisma.material.count({
|
||
where: {
|
||
[fieldKey]: { not: '' },
|
||
},
|
||
})
|
||
}
|
||
} catch {
|
||
materialCount = 0
|
||
}
|
||
|
||
return {
|
||
templateCount,
|
||
materialCount,
|
||
optionCount,
|
||
}
|
||
}
|