418 lines
11 KiB
TypeScript
418 lines
11 KiB
TypeScript
import { Prisma, UploadField } from '@prisma/client'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { STANDARD_FIELDS } from './upload-template-presets'
|
||
|
||
const prismaAny = prisma as any
|
||
|
||
type AggregatedField = {
|
||
key: string
|
||
label: string
|
||
type: string
|
||
source: 'standard' | 'custom'
|
||
templateIds: Set<string>
|
||
options: Set<string>
|
||
}
|
||
|
||
export function normalizeFieldOptionValue(option: string): string {
|
||
let cleaned = option?.trim?.() ?? ''
|
||
if (!cleaned) return ''
|
||
|
||
cleaned = stripRedundantBrackets(cleaned)
|
||
|
||
try {
|
||
if (
|
||
(cleaned.startsWith('"') && cleaned.endsWith('"')) ||
|
||
(cleaned.startsWith("'") && cleaned.endsWith("'"))
|
||
) {
|
||
const parsed = JSON.parse(cleaned)
|
||
if (typeof parsed === 'string') {
|
||
cleaned = parsed.trim()
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
cleaned = cleaned.replace(/\\+"/g, '"').replace(/\\'/g, "'")
|
||
cleaned = cleaned.replace(/["'`“”]/g, '')
|
||
cleaned = cleaned.trim()
|
||
if (cleaned.startsWith('\\') && cleaned.endsWith('\\')) {
|
||
cleaned = cleaned.slice(1, -1).trim()
|
||
}
|
||
cleaned = stripRedundantBrackets(cleaned)
|
||
return cleaned.trim()
|
||
}
|
||
|
||
function stripRedundantBrackets(value: string): string {
|
||
let cleaned = value
|
||
let changed = false
|
||
let safeGuard = 0
|
||
while (
|
||
cleaned.startsWith('[[') &&
|
||
cleaned.endsWith(']]') &&
|
||
safeGuard < 5
|
||
) {
|
||
const next = cleaned.slice(1, -1).trim()
|
||
if (!next || next === cleaned) {
|
||
break
|
||
}
|
||
cleaned = next
|
||
changed = true
|
||
safeGuard += 1
|
||
}
|
||
if (
|
||
changed &&
|
||
cleaned.startsWith('[') &&
|
||
cleaned.endsWith(']') &&
|
||
!/[,\n,]/.test(cleaned.slice(1, -1))
|
||
) {
|
||
const inner = cleaned.slice(1, -1).trim()
|
||
if (inner) {
|
||
cleaned = inner
|
||
}
|
||
}
|
||
return cleaned
|
||
}
|
||
|
||
function isArrayString(value: string): boolean {
|
||
const trimmed = value?.trim?.()
|
||
if (!trimmed || !trimmed.startsWith('[') || !trimmed.endsWith(']')) {
|
||
return false
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(trimmed)
|
||
return Array.isArray(parsed)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function extractLooseArrayOptions(option: string): string[] | null {
|
||
let trimmed = option?.trim?.() ?? ''
|
||
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
|
||
return null
|
||
}
|
||
|
||
let inner = trimmed
|
||
let safeGuard = 0
|
||
while (inner.startsWith('[') && inner.endsWith(']') && safeGuard < 5) {
|
||
const next = inner.slice(1, -1).trim()
|
||
if (!next || next === inner) {
|
||
break
|
||
}
|
||
inner = next
|
||
safeGuard += 1
|
||
}
|
||
|
||
if (!inner) return []
|
||
|
||
const segments = inner
|
||
.split(/[,,]/)
|
||
.map((seg) => normalizeFieldOptionValue(seg))
|
||
.filter(Boolean)
|
||
|
||
if (segments.length > 1) {
|
||
return segments
|
||
}
|
||
|
||
const single = normalizeFieldOptionValue(inner)
|
||
return single ? [single] : null
|
||
}
|
||
|
||
export function parseOptionsStatic(raw: string | null | undefined): string[] {
|
||
if (!raw) return []
|
||
const normalizedOptions: string[] = []
|
||
|
||
try {
|
||
const parsed = JSON.parse(raw)
|
||
if (Array.isArray(parsed)) {
|
||
parsed.forEach((item) => {
|
||
const value = String(item ?? '').trim()
|
||
if (!value) return
|
||
const nested = extractLooseArrayOptions(value)
|
||
if (nested && nested.length) {
|
||
nested.forEach((nestedValue) => {
|
||
const normalized = normalizeFieldOptionValue(nestedValue)
|
||
if (normalized) {
|
||
normalizedOptions.push(normalized)
|
||
}
|
||
})
|
||
return
|
||
}
|
||
const normalized = normalizeFieldOptionValue(value)
|
||
if (normalized) {
|
||
normalizedOptions.push(normalized)
|
||
}
|
||
})
|
||
return Array.from(new Set(normalizedOptions))
|
||
}
|
||
} catch {
|
||
// ignore JSON parse error, fallback to newline parsing
|
||
}
|
||
|
||
return raw
|
||
.split('\n')
|
||
.flatMap((line) => expandOptionValue(line))
|
||
}
|
||
|
||
function aggregateUploadField(target: AggregatedField, field: UploadField): void {
|
||
const options = parseOptionsStatic(field.optionsStatic)
|
||
options.forEach((option) => {
|
||
if (option) {
|
||
target.options.add(option)
|
||
}
|
||
})
|
||
}
|
||
|
||
export async function syncFieldPoolFromTemplates(): Promise<void> {
|
||
const aggregated = new Map<string, AggregatedField>()
|
||
|
||
// Seed standard fields from presets
|
||
Object.values(STANDARD_FIELDS).forEach((field) => {
|
||
if (field.addToCategory) {
|
||
aggregated.set(field.key, {
|
||
key: field.key,
|
||
label: field.label,
|
||
type: field.type ?? 'select',
|
||
source: 'standard',
|
||
templateIds: new Set<string>(),
|
||
options: new Set(parseOptionsStatic(field.optionsStatic ?? null)),
|
||
})
|
||
}
|
||
})
|
||
|
||
const categoryFields = await prismaAny.uploadField.findMany({
|
||
where: {
|
||
addToCategory: true,
|
||
type: 'select',
|
||
},
|
||
include: {
|
||
template: {
|
||
select: {
|
||
id: true,
|
||
},
|
||
},
|
||
},
|
||
orderBy: [
|
||
{ templateId: 'asc' },
|
||
{ orderIndex: 'asc' },
|
||
],
|
||
})
|
||
|
||
categoryFields.forEach((field: UploadField) => {
|
||
if (!field.key) return
|
||
const key = field.key.trim()
|
||
if (!key) return
|
||
const existing = aggregated.get(key)
|
||
if (existing) {
|
||
existing.label = field.label || existing.label || key
|
||
aggregateUploadField(existing, field)
|
||
if (field.templateId) {
|
||
existing.templateIds.add(field.templateId)
|
||
}
|
||
return
|
||
}
|
||
|
||
const entry: AggregatedField = {
|
||
key,
|
||
label: field.label || key,
|
||
type: field.type || 'select',
|
||
source: STANDARD_FIELDS[key] ? 'standard' : 'custom',
|
||
templateIds: new Set(field.templateId ? [field.templateId] : []),
|
||
options: new Set(parseOptionsStatic(field.optionsStatic)),
|
||
}
|
||
aggregated.set(key, entry)
|
||
})
|
||
|
||
await prismaAny.$transaction(async (tx: any) => {
|
||
const existing = (await tx.fieldDefinition.findMany({
|
||
select: { id: true, key: true },
|
||
})) as Array<{ id: string; key: string }>
|
||
const existingKeyMap = new Map(
|
||
existing.map((item: { id: string; key: string }) => [item.key, item.id]),
|
||
)
|
||
const activeKeys = new Set(aggregated.keys())
|
||
|
||
// Remove orphaned field definitions that are no longer aggregated
|
||
const removedKeys = Array.from(existingKeyMap.keys()).filter((key) => !activeKeys.has(key))
|
||
if (removedKeys.length) {
|
||
await tx.fieldOption.deleteMany({
|
||
where: { field: { key: { in: removedKeys } } },
|
||
})
|
||
await tx.fieldDefinition.deleteMany({
|
||
where: { key: { in: removedKeys } },
|
||
})
|
||
}
|
||
|
||
const aggregatedEntries = Array.from(aggregated.values())
|
||
for (const entry of aggregatedEntries) {
|
||
const templateIds = Array.from(entry.templateIds)
|
||
|
||
// 查询现有字段定义,保留已有的 filterDisplayName
|
||
const existing = await tx.fieldDefinition.findUnique({
|
||
where: { key: entry.key },
|
||
select: { filterDisplayName: true },
|
||
})
|
||
|
||
const payload = {
|
||
label: entry.label || entry.key,
|
||
type: entry.type || 'select',
|
||
source: entry.source,
|
||
isStandard: entry.source === 'standard',
|
||
sourceTemplateIds: templateIds.length ? JSON.stringify(templateIds) : null,
|
||
// 如果已有 filterDisplayName,保留它;否则使用 entry.label
|
||
filterDisplayName: existing?.filterDisplayName ?? (entry.label || entry.key),
|
||
}
|
||
|
||
const fieldDefinition = await tx.fieldDefinition.upsert({
|
||
where: { key: entry.key },
|
||
update: {
|
||
...payload,
|
||
updatedAt: new Date(),
|
||
},
|
||
create: {
|
||
key: entry.key,
|
||
...payload,
|
||
isFilterEnabled: entry.source === 'standard',
|
||
},
|
||
})
|
||
|
||
await syncFieldOptions(tx, fieldDefinition.id, Array.from(entry.options))
|
||
}
|
||
})
|
||
}
|
||
|
||
async function syncFieldOptions(tx: any, fieldId: string, options: string[]): Promise<void> {
|
||
const uniqueOptions = Array.from(new Set(options.filter(Boolean)))
|
||
const existingOptions = (await tx.fieldOption.findMany({
|
||
where: { fieldId },
|
||
orderBy: { orderIndex: 'asc' },
|
||
})) as Array<{ id: string; value: string }>
|
||
|
||
const existingMap = new Map<string, { id: string; value: string }>(
|
||
existingOptions.map((opt: { id: string; value: string }) => [opt.value, opt]),
|
||
)
|
||
let orderIndex = 0
|
||
|
||
for (const value of uniqueOptions) {
|
||
const label = value
|
||
const existing = existingMap.get(value)
|
||
if (existing) {
|
||
await tx.fieldOption.update({
|
||
where: { id: existing.id },
|
||
data: {
|
||
label,
|
||
orderIndex,
|
||
},
|
||
})
|
||
orderIndex += 1
|
||
continue
|
||
}
|
||
|
||
await tx.fieldOption.create({
|
||
data: {
|
||
fieldId,
|
||
value,
|
||
label,
|
||
orderIndex,
|
||
},
|
||
})
|
||
orderIndex += 1
|
||
}
|
||
|
||
const keepValues = uniqueOptions
|
||
await tx.fieldOption.deleteMany({
|
||
where: {
|
||
fieldId,
|
||
value: { notIn: keepValues.length ? keepValues : ['__none__'] },
|
||
},
|
||
})
|
||
}
|
||
|
||
export async function getFieldPoolRecords() {
|
||
return prismaAny.fieldDefinition.findMany({
|
||
orderBy: [
|
||
{ filterOrder: 'asc' },
|
||
{ label: 'asc' },
|
||
],
|
||
include: {
|
||
options: {
|
||
orderBy: { orderIndex: 'asc' },
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
export async function getEnabledFilterFields() {
|
||
const fields = await prismaAny.fieldDefinition.findMany({
|
||
where: { isFilterEnabled: true },
|
||
orderBy: [
|
||
{ filterOrder: 'asc' },
|
||
{ label: 'asc' },
|
||
],
|
||
include: {
|
||
options: {
|
||
orderBy: { orderIndex: 'asc' },
|
||
},
|
||
},
|
||
})
|
||
|
||
return fields.map((field: { key: string; label: string; filterDisplayName: string | null; options: Array<{ value: string }> }) => ({
|
||
key: field.key,
|
||
label: field.filterDisplayName || field.label,
|
||
options: formatFieldOptionValues(field.options),
|
||
}))
|
||
}
|
||
|
||
export async function updateFieldFilterState(fieldKey: string, enabled: boolean) {
|
||
return prismaAny.fieldDefinition.update({
|
||
where: { key: fieldKey },
|
||
data: {
|
||
isFilterEnabled: enabled,
|
||
updatedAt: new Date(),
|
||
},
|
||
})
|
||
}
|
||
|
||
export function formatFieldOptionEntries(
|
||
optionRecords: Array<{ value: string; label?: string | null }>
|
||
): Array<{ value: string; label: string }> {
|
||
const entries: Array<{ value: string; label: string }> = []
|
||
const seen = new Set<string>()
|
||
optionRecords.forEach((option) => {
|
||
const values = expandOptionValue(option.value)
|
||
const labelSource =
|
||
typeof option.label === 'string' && option.label.trim() ? option.label : option.value
|
||
const labels = expandOptionValue(labelSource)
|
||
|
||
values.forEach((value, index) => {
|
||
if (!value || seen.has(value)) return
|
||
const labelCandidate = labels[index] || labels[0] || normalizeFieldOptionValue(labelSource)
|
||
entries.push({
|
||
value,
|
||
label: labelCandidate || value,
|
||
})
|
||
seen.add(value)
|
||
})
|
||
})
|
||
return entries
|
||
}
|
||
|
||
export function formatFieldOptionValues(optionRecords: Array<{ value: string; label?: string | null }>): string[] {
|
||
return formatFieldOptionEntries(optionRecords).map((entry) => entry.value)
|
||
}
|
||
|
||
export function expandOptionValue(raw: string): string[] {
|
||
if (!raw?.trim()) return []
|
||
const attempts = [raw, normalizeFieldOptionValue(raw)]
|
||
for (const candidate of attempts) {
|
||
const nested = extractLooseArrayOptions(candidate)
|
||
if (nested && nested.length) {
|
||
return nested.map((item) => normalizeFieldOptionValue(item)).filter(Boolean)
|
||
}
|
||
}
|
||
const normalized = normalizeFieldOptionValue(raw)
|
||
return normalized ? [normalized] : []
|
||
}
|
||
|