flnxi-Wu/lib/field-pool.ts
WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

418 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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] : []
}