Files
AILab/vue2/apps/web-antd/src/views/traceability/admin.vue
T
2026-04-14 16:52:30 +08:00

2575 lines
89 KiB
Vue
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.
<script lang="ts" setup>
import type { TraceabilityApi } from '#/api';
import { computed, onMounted, reactive, ref } from 'vue';
import { Plus } from '@vben/icons';
import {
Button,
Card,
Col,
DatePicker,
Dropdown,
Empty,
Input,
Menu,
message,
Modal,
Row,
Select,
Space,
Switch,
Tabs,
Tag,
} from 'ant-design-vue';
import {
createTraceabilityNodeLibrary,
createTraceabilityTemplate,
deleteTraceabilityFileAsset,
deleteTraceabilityNodeLibrary,
deleteTraceabilityTemplate,
getTraceabilityFileAssets,
getTraceabilityNodeLibrary,
getTraceabilityOverview,
getTraceabilityTemplate,
getTraceabilityTemplates,
updateTraceabilityNodeLibrary,
updateTraceabilityTemplate,
uploadTraceabilityImage,
} from '#/api';
import CoordinateFieldEditor from './components/CoordinateFieldEditor.vue';
import {
buildOssStoredValue,
cloneTemplateForSave,
createEmptyField,
createEmptyNode,
createLocalUniqueId,
fieldTypeOptions,
getFieldTypeLabel,
getImagePreviewSrc,
serializeImageValue,
stripOssTempUrl,
} from './shared';
type EditableTemplateNode = TraceabilityApi.TemplateNode & {
libraryId?: string;
locked?: boolean;
persisted?: boolean;
};
const activeTab = ref<'library' | 'stats' | 'templates'>('templates');
const libraryTab = ref<'business' | 'public'>('business');
const loading = ref(false);
const saving = ref(false);
const uploadingFieldKey = ref('');
const selectedTemplateId = ref('');
const selectedLibraryNodeId = ref('');
const selectedTemplateFieldKey = ref('');
const selectedLibraryFieldKey = ref('');
const draggingTemplateNodeId = ref('');
const draggingTemplateFieldKey = ref('');
const draggingLibraryFieldKey = ref('');
const overview = reactive<TraceabilityApi.Overview>({
batchCount: 0,
feedbackCount: 0,
publishedCount: 0,
templateCount: 0,
totalScans: 0,
});
const templates = ref<TraceabilityApi.TemplateSummary[]>([]);
const nodeLibrary = ref<EditableTemplateNode[]>([]);
const editor = reactive<
Partial<TraceabilityApi.TemplateDetail & { nodes: EditableTemplateNode[] }>
>({
coverImage: '',
description: '',
industryName: '',
name: '',
nodes: [],
productName: '',
status: 'draft',
themeColor: '#1f4fd6',
});
const nodeIndex = ref(0);
const templateSnapshot = ref('');
const leaveDialogVisible = ref(false);
const pendingTemplateSelectId = ref('');
const bypassTemplateLeaveGuard = ref(false);
const createTemplateVisible = ref(false);
const coverHistoryVisible = ref(false);
const coverHistoryLoading = ref(false);
const deletingCoverAssetId = ref('');
const coverHistoryItems = ref<TraceabilityApi.FileAssetItem[]>([]);
const createTemplateForm = reactive<{
coverImage: any;
coverImagePreviewUrl: string;
description: string;
industryName: string;
name: string;
productName: string;
remark: string;
themeColor: string;
}>({
coverImage: '',
coverImagePreviewUrl: '',
description: '',
remark: '',
industryName: '',
name: '新建溯源模板',
productName: '',
themeColor: '#1f4fd6',
});
const currentNode = computed(() => editor.nodes?.[nodeIndex.value] ?? null);
const currentTemplateField = computed(() =>
currentNode.value?.fields.find((field) => field.key === selectedTemplateFieldKey.value) ??
currentNode.value?.fields[0] ??
null,
);
const publicNodes = computed(() =>
(editor.nodes ?? []).filter((item) => item.category === 'public'),
);
const businessNodes = computed(() =>
(editor.nodes ?? []).filter((item) => item.category !== 'public'),
);
const selectedTemplateSummary = computed(() =>
templates.value.find((item) => item.id === selectedTemplateId.value),
);
const isTemplatePublished = computed(
() => selectedTemplateSummary.value?.status === 'active',
);
const isTemplateDraft = computed(() => !isTemplatePublished.value);
const currentLibraryNode = computed(() =>
nodeLibrary.value.find((item) => item.libraryId === selectedLibraryNodeId.value),
);
const currentLibraryField = computed(() =>
currentLibraryNode.value?.fields.find((field) => field.key === selectedLibraryFieldKey.value) ??
currentLibraryNode.value?.fields[0] ??
null,
);
const businessLibraryNodes = computed(() =>
nodeLibrary.value.filter((item) => item.category === 'business'),
);
const publicLibraryNodes = computed(() =>
nodeLibrary.value.filter((item) => item.category === 'public'),
);
const currentNodeLocked = computed(() => !!currentNode.value?.locked);
function syncSelectedTemplateField() {
selectedTemplateFieldKey.value = currentNode.value?.fields[0]?.key || '';
}
function syncSelectedLibraryField() {
selectedLibraryFieldKey.value = currentLibraryNode.value?.fields[0]?.key || '';
}
function toEditableLibraryNode(item: TraceabilityApi.NodeLibraryItem): EditableTemplateNode {
return {
category: item.category,
consumerVisible: item.consumerVisible,
description: item.description,
fields: item.fields.map((field, fieldIndex) => ({
...field,
sort: field.sort ?? fieldIndex,
defaultPreviewUrl: field.defaultPreviewUrl,
options: [...(field.options ?? [])],
fieldStyle: {
bold: field.fieldStyle?.bold ?? false,
color: field.fieldStyle?.color ?? '',
},
})),
libraryId: item.id,
locked: false,
name: item.name,
persisted: true,
};
}
function buildLibraryPayload(node: EditableTemplateNode) {
return {
category: node.category,
consumerVisible: node.consumerVisible,
description: node.description,
fields: (node.fields ?? []).map((field, fieldIndex) => ({
sort: field.sort ?? fieldIndex,
defaultValue:
field.type === 'image'
? stripOssTempUrl(field.defaultValue ?? '')
: field.defaultValue ?? '',
fieldStyle: {
bold: field.fieldStyle?.bold ?? false,
color: field.fieldStyle?.color ?? '',
},
fixedPreset: field.fixedPreset ?? false,
key: field.key,
label: field.label,
options: field.options ?? [],
placeholder: field.placeholder ?? '',
required: field.required ?? false,
type: field.type ?? 'string',
visible: field.visible ?? true,
})),
name: node.name,
};
}
async function loadNodeLibrary() {
const list = await getTraceabilityNodeLibrary();
nodeLibrary.value = list.map(toEditableLibraryNode);
}
async function loadAll() {
loading.value = true;
try {
await loadNodeLibrary();
Object.assign(overview, await getTraceabilityOverview());
templates.value = await getTraceabilityTemplates();
if (!selectedTemplateId.value && templates.value[0]) {
selectedTemplateId.value = templates.value[0].id;
}
if (!selectedLibraryNodeId.value && nodeLibrary.value[0]) {
selectedLibraryNodeId.value = nodeLibrary.value[0].libraryId || '';
}
if (selectedTemplateId.value) {
const detail = await getTraceabilityTemplate(selectedTemplateId.value);
applyEditor(detail);
}
} finally {
loading.value = false;
}
}
function applyEditor(detail: TraceabilityApi.TemplateDetail) {
Object.assign(editor, structuredClone(detail));
editor.nodes = (editor.nodes ?? []).map((node) => ({
...node,
id: node.id || createLocalUniqueId('node'),
fields: (node.fields ?? []).map((field, fieldIndex) => ({
...field,
sort: field.sort ?? fieldIndex,
})),
locked: !!node.locked,
}));
nodeIndex.value = 0;
syncSelectedTemplateField();
templateSnapshot.value = JSON.stringify(cloneTemplateForSave(editor));
}
const hasTemplateChanges = computed(() => {
if (!selectedTemplateId.value) return false;
return templateSnapshot.value !== JSON.stringify(cloneTemplateForSave(editor));
});
function openTemplateLeaveDialog(nextId: string) {
pendingTemplateSelectId.value = nextId;
leaveDialogVisible.value = true;
}
async function confirmTemplateLeave(action: 'cancel' | 'discard' | 'save') {
leaveDialogVisible.value = false;
const nextId = pendingTemplateSelectId.value;
pendingTemplateSelectId.value = '';
if (action === 'cancel' || !nextId) return;
if (action === 'save') {
await saveTemplate();
}
bypassTemplateLeaveGuard.value = true;
try {
await selectTemplate(nextId);
} finally {
bypassTemplateLeaveGuard.value = false;
}
}
async function selectTemplate(id: string) {
if (
!bypassTemplateLeaveGuard.value &&
selectedTemplateId.value &&
id !== selectedTemplateId.value &&
hasTemplateChanges.value
) {
openTemplateLeaveDialog(id);
return;
}
selectedTemplateId.value = id;
const detail = await getTraceabilityTemplate(id);
applyEditor(detail);
}
function openCreateTemplateModal() {
createTemplateForm.coverImage = '';
createTemplateForm.coverImagePreviewUrl = '';
createTemplateForm.description = '';
createTemplateForm.remark = '';
createTemplateForm.industryName = '';
createTemplateForm.name = '新建溯源模板';
createTemplateForm.productName = '';
createTemplateForm.themeColor = '#1f4fd6';
createTemplateVisible.value = true;
}
async function loadCoverHistory() {
coverHistoryLoading.value = true;
try {
coverHistoryItems.value = await getTraceabilityFileAssets('cover', 30);
} finally {
coverHistoryLoading.value = false;
}
}
async function openCoverHistoryModal() {
coverHistoryVisible.value = true;
await loadCoverHistory();
}
function clearTemplateCoverImage() {
createTemplateForm.coverImage = '';
createTemplateForm.coverImagePreviewUrl = '';
}
function selectHistoryCover(item: TraceabilityApi.FileAssetItem) {
createTemplateForm.coverImage = item.bucketName
? {
bucketName: item.bucketName,
objectName: item.objectName,
}
: item.objectName;
createTemplateForm.coverImagePreviewUrl = item.previewUrl;
coverHistoryVisible.value = false;
}
async function deleteHistoryCover(item: TraceabilityApi.FileAssetItem) {
if (item.id.startsWith('legacy-')) {
message.warning('历史回溯封面图暂不支持直接删除');
return;
}
Modal.confirm({
title: '删除历史封面图',
content: `确认删除历史封面图“${item.fileName || item.objectName}”吗?`,
async onOk() {
deletingCoverAssetId.value = item.id;
try {
await deleteTraceabilityFileAsset(item.id);
coverHistoryItems.value = coverHistoryItems.value.filter((asset) => asset.id !== item.id);
message.success('历史封面图已删除');
} catch (error: any) {
message.error(error?.message || '删除失败');
} finally {
deletingCoverAssetId.value = '';
}
},
});
}
async function handleTemplateCoverUpload(event: Event) {
const files = (event.target as HTMLInputElement).files;
const file = files?.[0];
if (!file) return;
uploadingFieldKey.value = 'template-cover';
try {
const formData = new FormData();
formData.append('file', file);
formData.append('assetType', 'cover');
formData.append('objectDir', 'traceability/covers/templates');
const result = await uploadTraceabilityImage(formData);
createTemplateForm.coverImage = buildOssStoredValue(result);
createTemplateForm.coverImagePreviewUrl = result.tempUrl || '';
message.success('封面图上传成功');
await loadCoverHistory();
} catch {
message.error('封面图上传失败');
} finally {
uploadingFieldKey.value = '';
(event.target as HTMLInputElement).value = '';
}
}
function triggerTemplateCoverSelect() {
const input = document.querySelector('#template-cover-upload');
input?.click();
}
async function createTemplate() {
saving.value = true;
try {
const created = await createTraceabilityTemplate({
coverImage: serializeImageValue(createTemplateForm.coverImage),
description: createTemplateForm.description,
remark: createTemplateForm.remark,
industryName: createTemplateForm.industryName,
name: createTemplateForm.name.trim() || '新建溯源模板',
nodes: [createEmptyNode('public'), createEmptyNode('business')],
productName: createTemplateForm.productName,
status: 'draft',
themeColor: createTemplateForm.themeColor,
});
createTemplateVisible.value = false;
await loadAll();
if (created?.id) {
await selectTemplate(created.id);
}
message.success('模板已创建');
} finally {
saving.value = false;
}
}
async function copyTemplate(id: string) {
saving.value = true;
try {
const source = await getTraceabilityTemplate(id);
const created = await createTraceabilityTemplate({
...cloneTemplateForSave({
...source,
name: `${source.name} 副本`,
status: 'draft',
}),
status: 'draft',
});
await loadAll();
if (created?.id) {
await selectTemplate(created.id);
}
message.success('模板副本已创建');
} finally {
saving.value = false;
}
}
async function createLibraryNode(category: 'business' | 'public') {
const node: EditableTemplateNode = {
...createEmptyNode(category),
libraryId: createLocalUniqueId('library-draft'),
locked: false,
persisted: false,
};
nodeLibrary.value.unshift(node);
selectedLibraryNodeId.value = node.libraryId || '';
libraryTab.value = category;
activeTab.value = 'library';
}
function selectLibraryTab(category: 'business' | 'public') {
libraryTab.value = category;
const target =
category === 'business' ? businessLibraryNodes.value[0] : publicLibraryNodes.value[0];
selectedLibraryNodeId.value = target?.libraryId || '';
syncSelectedLibraryField();
}
function selectLibraryNode(libraryId: string) {
selectedLibraryNodeId.value = libraryId;
syncSelectedLibraryField();
}
function addPlainNode(category: 'business' | 'public') {
if (isTemplatePublished.value) return;
editor.nodes ??= [];
editor.nodes.push(createEmptyNode(category));
nodeIndex.value = editor.nodes.length - 1;
syncSelectedTemplateField();
}
function addNodeFromLibrary(category: 'business' | 'public', libraryId?: string) {
if (isTemplatePublished.value) return;
const source = nodeLibrary.value.find(
(item) => item.libraryId === libraryId && item.category === category,
);
if (!source) {
message.warning('未找到对应节点库配置');
return;
}
editor.nodes ??= [];
editor.nodes.push({
id: createLocalUniqueId('node'),
category: source.category,
consumerVisible: source.consumerVisible,
description: source.description,
fields: source.fields.map((field, fieldIndex) => ({
...field,
sort: field.sort ?? fieldIndex,
options: [...(field.options ?? [])],
})),
libraryId: source.libraryId,
locked: true,
name: source.name,
});
nodeIndex.value = editor.nodes.length - 1;
syncSelectedTemplateField();
}
function removeNode(index: number) {
if (isTemplatePublished.value) return;
editor.nodes?.splice(index, 1);
nodeIndex.value = Math.max(0, nodeIndex.value - 1);
syncSelectedTemplateField();
}
function confirmRemoveTemplateNode(index: number) {
const target = editor.nodes?.[index];
if (!target || isTemplatePublished.value) return;
Modal.confirm({
title: '删除节点',
content: `确认删除节点“${target.name || '未命名节点'}”吗?`,
async onOk() {
removeNode(index);
},
});
}
function selectNode(index: number) {
nodeIndex.value = index;
syncSelectedTemplateField();
}
function moveTemplateNode(
category: 'business' | 'public',
draggedId: string,
targetId: string,
) {
const nodes = editor.nodes ?? [];
if (!draggedId || !targetId || draggedId === targetId) return;
const categoryNodes = nodes.filter((node) => node.category === category);
const fromIndex = categoryNodes.findIndex((node) => node.id === draggedId);
const toIndex = categoryNodes.findIndex((node) => node.id === targetId);
if (fromIndex === -1 || toIndex === -1) return;
const movedNodes = [...categoryNodes];
const [movedNode] = movedNodes.splice(fromIndex, 1);
movedNodes.splice(toIndex, 0, movedNode);
const reordered = [...nodes];
let cursor = 0;
editor.nodes = reordered.map((node) =>
node.category === category ? movedNodes[cursor++] : node,
);
const nextIndex = (editor.nodes ?? []).findIndex((node) => node.id === draggedId);
if (nextIndex !== -1) {
nodeIndex.value = nextIndex;
}
}
function handleTemplateNodeDragStart(nodeId?: string) {
if (isTemplatePublished.value) return;
draggingTemplateNodeId.value = nodeId || '';
}
function handleTemplateNodeDrop(
category: 'business' | 'public',
targetNodeId?: string,
) {
if (isTemplatePublished.value) return;
moveTemplateNode(
category,
draggingTemplateNodeId.value,
targetNodeId || '',
);
draggingTemplateNodeId.value = '';
}
function clearTemplateNodeDragState() {
draggingTemplateNodeId.value = '';
}
function addField(target: EditableTemplateNode | null | undefined) {
if (target?.locked) return;
const field = createEmptyField();
field.sort = target?.fields.length ?? 0;
target?.fields.push(field);
if (target === currentNode.value) {
selectedTemplateFieldKey.value = field.key;
}
if (target === currentLibraryNode.value) {
selectedLibraryFieldKey.value = field.key;
}
}
function removeField(target: EditableTemplateNode | null | undefined, index: number) {
if (target?.locked) return;
const removed = target?.fields[index];
target?.fields.splice(index, 1);
target?.fields.forEach((field, fieldIndex) => {
field.sort = fieldIndex;
});
if (target === currentNode.value && removed?.key === selectedTemplateFieldKey.value) {
selectedTemplateFieldKey.value = target?.fields[0]?.key || '';
}
if (target === currentLibraryNode.value && removed?.key === selectedLibraryFieldKey.value) {
selectedLibraryFieldKey.value = target?.fields[0]?.key || '';
}
}
function confirmRemoveField(target: EditableTemplateNode | null | undefined, index: number) {
const field = target?.fields[index];
if (!target || target.locked || !field) return;
Modal.confirm({
title: '删除字段',
content: `确认删除字段“${field.label || field.key || '未命名字段'}”吗?`,
async onOk() {
removeField(target, index);
},
});
}
function moveField(
target: EditableTemplateNode | null | undefined,
draggedKey: string,
targetKey: string,
) {
const fields = target?.fields ?? [];
if (!draggedKey || !targetKey || draggedKey === targetKey) return;
const fromIndex = fields.findIndex((field) => field.key === draggedKey);
const toIndex = fields.findIndex((field) => field.key === targetKey);
if (fromIndex === -1 || toIndex === -1) return;
const movedFields = [...fields];
const [movedField] = movedFields.splice(fromIndex, 1);
if (!movedField) return;
movedFields.splice(toIndex, 0, movedField);
target!.fields = movedFields.map((field, fieldIndex) => ({
...field,
sort: fieldIndex,
}));
}
function handleTemplateFieldDragStart(fieldKey?: string) {
if (isTemplatePublished.value || currentNodeLocked.value) return;
draggingTemplateFieldKey.value = fieldKey || '';
}
function handleTemplateFieldDrop(targetFieldKey?: string) {
if (isTemplatePublished.value || currentNodeLocked.value) return;
moveField(currentNode.value, draggingTemplateFieldKey.value, targetFieldKey || '');
draggingTemplateFieldKey.value = '';
}
function clearTemplateFieldDragState() {
draggingTemplateFieldKey.value = '';
}
function handleLibraryFieldDragStart(fieldKey?: string) {
draggingLibraryFieldKey.value = fieldKey || '';
}
function handleLibraryFieldDrop(targetFieldKey?: string) {
moveField(currentLibraryNode.value, draggingLibraryFieldKey.value, targetFieldKey || '');
draggingLibraryFieldKey.value = '';
}
function clearLibraryFieldDragState() {
draggingLibraryFieldKey.value = '';
}
function getFieldUploadKey(scope: string, field: TraceabilityApi.FieldDefinition) {
return `${scope}:${field.key}`;
}
function getFieldInputId(scope: string, field: TraceabilityApi.FieldDefinition) {
return `traceability-admin-upload-${getFieldUploadKey(scope, field)}`;
}
function triggerFieldImageSelect(scope: string, field: TraceabilityApi.FieldDefinition) {
const input = document.getElementById(getFieldInputId(scope, field));
input?.click();
}
async function handleFieldImageUpload(
scope: string,
field: TraceabilityApi.FieldDefinition,
event: Event,
) {
const files = (event.target as HTMLInputElement).files;
const file = files?.[0];
if (!file) return;
const uploadKey = getFieldUploadKey(scope, field);
uploadingFieldKey.value = uploadKey;
try {
const formData = new FormData();
formData.append('file', file);
formData.append('objectDir', `traceability/node-library/${scope}/${field.key}`);
const result = await uploadTraceabilityImage(formData);
field.defaultValue = buildOssStoredValue(result);
field.defaultPreviewUrl = result.tempUrl || '';
message.success('默认图片已上传');
} catch {
message.error('默认图片上传失败');
} finally {
uploadingFieldKey.value = '';
(event.target as HTMLInputElement).value = '';
}
}
function clearFieldImage(field: TraceabilityApi.FieldDefinition) {
field.defaultValue = '';
field.defaultPreviewUrl = '';
}
function getFieldStyleConfig(field: TraceabilityApi.FieldDefinition) {
if (!field.fieldStyle) {
field.fieldStyle = {
bold: false,
color: '',
};
}
return field.fieldStyle;
}
function updateFieldStyleBold(field: TraceabilityApi.FieldDefinition, checked: boolean) {
getFieldStyleConfig(field).bold = checked;
}
function updateFieldStyleColor(field: TraceabilityApi.FieldDefinition, color: string) {
getFieldStyleConfig(field).color = color || '';
}
function updateSelectedFieldKey(
scope: 'library' | 'template',
field: TraceabilityApi.FieldDefinition,
value: string,
) {
const nextValue = value || field.key;
field.key = nextValue;
if (scope === 'template') {
selectedTemplateFieldKey.value = nextValue;
} else {
selectedLibraryFieldKey.value = nextValue;
}
}
function isFieldPresetLocked(field: TraceabilityApi.FieldDefinition) {
return isTemplatePublished.value || !!field.fixedPreset;
}
function getPresetValue(field: TraceabilityApi.FieldDefinition) {
if (field.type === 'multi_select') {
return Array.isArray(field.defaultValue)
? field.defaultValue
: (field.defaultValue
? String(field.defaultValue)
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: []);
}
return field.defaultValue;
}
function updatePresetValue(field: TraceabilityApi.FieldDefinition, value: any) {
if (field.type === 'integer') {
if (value === null || value === undefined || value === '') {
field.defaultValue = '';
return;
}
const parsed = Number(value);
field.defaultValue = Number.isNaN(parsed) ? '' : Math.trunc(parsed);
return;
}
if (field.type === 'decimal') {
if (value === null || value === undefined || value === '') {
field.defaultValue = '';
return;
}
const parsed = Number(value);
field.defaultValue = Number.isNaN(parsed) ? '' : parsed;
return;
}
if (field.type === 'multi_select') {
field.defaultValue = Array.isArray(value) ? value : [];
return;
}
field.defaultValue = value;
}
function sanitizeIntegerPreset(value: string) {
const cleaned = value.replaceAll(/[^\d-]/g, '');
const hasLeadingMinus = cleaned.startsWith('-');
const unsigned = hasLeadingMinus ? cleaned.slice(1).replaceAll('-', '') : cleaned.replaceAll('-', '');
return hasLeadingMinus ? `-${unsigned}` : unsigned;
}
function sanitizeDecimalPreset(value: string) {
const cleaned = value.replaceAll(/[^\d.-]/g, '');
const firstDot = cleaned.indexOf('.');
const normalizedDot =
firstDot === -1
? cleaned
: `${cleaned.slice(0, firstDot + 1)}${cleaned.slice(firstDot + 1).replaceAll('.', '')}`;
const hasLeadingMinus = normalizedDot.startsWith('-');
const unsigned = hasLeadingMinus ? normalizedDot.slice(1).replaceAll('-', '') : normalizedDot;
return hasLeadingMinus ? `-${unsigned}` : unsigned.replaceAll('-', '');
}
function sanitizeIntegerInput(value: string) {
return value.replaceAll(/[^\d-]/g, '');
}
function sanitizeDecimalInput(value: string) {
const cleaned = value.replaceAll(/[^\d.-]/g, '');
const firstDot = cleaned.indexOf('.');
const normalizedDot =
firstDot === -1
? cleaned
: `${cleaned.slice(0, firstDot + 1)}${cleaned
.slice(firstDot + 1)
.replaceAll('.', '')}`;
const firstMinus = normalizedDot.indexOf('-');
return firstMinus <= 0
? normalizedDot
: `-${normalizedDot.replaceAll('-', '')}`;
}
async function removeLibraryNode() {
const currentId = currentLibraryNode.value?.libraryId;
if (!currentId) return;
const currentName = currentLibraryNode.value?.name || '未命名节点';
Modal.confirm({
title: '删除节点库节点',
content: `确认删除节点库节点“${currentName}”吗?`,
async onOk() {
if (currentLibraryNode.value?.persisted) {
await deleteTraceabilityNodeLibrary(currentId);
await loadNodeLibrary();
} else {
nodeLibrary.value = nodeLibrary.value.filter((item) => item.libraryId !== currentId);
}
selectedLibraryNodeId.value = nodeLibrary.value[0]?.libraryId || '';
message.success('节点库节点已删除');
},
});
}
async function saveLibraryNode() {
const currentId = currentLibraryNode.value?.libraryId;
if (!currentId || !currentLibraryNode.value) {
message.warning('请先选择节点库节点');
return;
}
const payload = buildLibraryPayload(currentLibraryNode.value);
if (currentLibraryNode.value.persisted === false) {
const created = await createTraceabilityNodeLibrary(payload);
await loadNodeLibrary();
selectedLibraryNodeId.value = created.id;
message.success('节点库节点已保存');
return;
}
await updateTraceabilityNodeLibrary(currentId, payload);
await loadNodeLibrary();
selectedLibraryNodeId.value = currentId;
message.success('节点库节点已保存');
}
async function persistTemplate(status?: 'active' | 'draft') {
if (!editor.name?.trim()) {
message.warning('请先填写模板名称');
return;
}
if (!editor.nodes?.length) {
message.warning('至少保留一个节点');
return;
}
saving.value = true;
try {
const payload = {
...cloneTemplateForSave(editor),
status: status ?? editor.status ?? 'draft',
};
if (selectedTemplateId.value) {
await updateTraceabilityTemplate(selectedTemplateId.value, payload);
message.success(status === 'active' ? '模板已发布' : '模板已更新');
} else {
await createTraceabilityTemplate(payload);
message.success(status === 'active' ? '模板已创建并发布' : '模板已创建');
}
await loadAll();
} finally {
saving.value = false;
}
}
async function saveTemplate() {
if (isTemplatePublished.value) {
message.warning('模板已发布,不可修改');
return;
}
await persistTemplate('draft');
}
async function publishTemplate() {
if (!selectedTemplateId.value) {
message.warning('请先创建并选择模板');
return;
}
await persistTemplate('active');
}
function removeTemplate(id: string) {
const target = templates.value.find((item) => item.id === id);
Modal.confirm({
title: '删除模板',
content: `确认删除模板“${target?.name || '未命名模板'}”吗?删除后关联批次也会一起删除。`,
async onOk() {
await deleteTraceabilityTemplate(id);
message.success('模板已删除');
if (selectedTemplateId.value === id) {
selectedTemplateId.value = '';
Object.assign(editor, {
coverImage: '',
description: '',
industryName: '',
name: '',
nodes: [],
productName: '',
status: 'draft',
themeColor: '#1f4fd6',
});
}
await loadAll();
},
});
}
onMounted(async () => {
await loadAll();
});
</script>
<template>
<div class="trace-admin-page">
<div class="trace-admin">
<Tabs v-model:active-key="activeTab">
<Tabs.TabPane key="templates" tab="模板中心">
<Row :gutter="[16, 16]" align="stretch">
<Col :lg="8" :xs="24">
<Card :loading="loading" class="panel-card editor-panel" title="模板列表">
<template #extra>
<Button type="primary" @click="openCreateTemplateModal">
<Plus class="size-5" />
新建模板
</Button>
</template>
<div class="template-list">
<button
v-for="item in templates"
:key="item.id"
class="template-card compact-template"
:class="{ active: item.id === selectedTemplateId }"
@click="selectTemplate(item.id)"
>
<div class="template-card__top">
<strong>{{ item.name }}</strong>
<Tag :color="item.status === 'active' ? 'blue' : 'default'">
{{ item.status === 'active' ? '已发布' : '草稿' }}
</Tag>
</div>
<div class="template-card__meta">
<span>{{ item.nodeCount }} 节点</span>
<span>{{ item.batchCount }} 批次</span>
</div>
<p class="template-card__remark">
{{ item.remark || '暂无备注' }}
</p>
<p>{{ item.description || '暂无模板说明' }}</p>
<div class="template-card__footer">
<span>{{ item.productName || '未设置产品名称' }}</span>
<div class="template-card__actions">
<Button size="small" @click.stop="copyTemplate(item.id)">复制模板</Button>
<Button danger size="small" @click.stop="removeTemplate(item.id)">删除模板</Button>
</div>
</div>
</button>
</div>
</Card>
</Col>
<Col :lg="16" :xs="24">
<div class="template-main">
<Card
v-if="selectedTemplateId"
class="panel-card template-summary"
:bordered="false"
title="基础信息"
>
<template #extra>
<Space>
<Button
type="primary"
:disabled="!isTemplateDraft"
:loading="saving"
@click="saveTemplate"
>
保存草稿
</Button>
<Button
type="primary"
:disabled="!isTemplateDraft"
:loading="saving"
@click="publishTemplate"
>
发布模板
</Button>
</Space>
</template>
<Row :gutter="[12, 12]">
<Col :md="12" :xs="24">
<label class="field-label">模板名称</label>
<Input v-model:value="editor.name" :disabled="!isTemplateDraft" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">产品名称</label>
<Input v-model:value="editor.productName" :disabled="!isTemplateDraft" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">行业名称</label>
<Input v-model:value="editor.industryName" :disabled="!isTemplateDraft" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">模板备注</label>
<Input v-model:value="editor.remark" :disabled="!isTemplateDraft" placeholder="仅内部可见" />
</Col>
<Col :span="24">
<label class="field-label">模板说明</label>
<Input.TextArea
v-model:value="editor.description"
:disabled="!isTemplateDraft"
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</Col>
</Row>
</Card>
<Empty v-else description="点击左侧模板或新建模板开始配置" />
<Card class="panel-card editor-panel template-main__editor" title="节点编排">
<template #extra>
<div class="editor-toolbar">
<Space wrap class="editor-toolbar__left">
<Dropdown>
<Button :disabled="isTemplatePublished">新增业务节点</Button>
<template #overlay>
<Menu>
<Menu.Item key="business-temp" @click="addPlainNode('business')">
临时节点
</Menu.Item>
<Menu.SubMenu key="business-library">
<template #title>节点库</template>
<Menu.Item
v-for="item in businessLibraryNodes"
:key="`business-${item.libraryId}`"
@click="addNodeFromLibrary('business', item.libraryId)"
>
{{ item.name }}
</Menu.Item>
<Menu.Item v-if="businessLibraryNodes.length === 0" key="business-empty" disabled>
暂无业务节点
</Menu.Item>
</Menu.SubMenu>
</Menu>
</template>
</Dropdown>
<Dropdown>
<Button :disabled="isTemplatePublished">新增公共资料节点</Button>
<template #overlay>
<Menu>
<Menu.Item key="public-temp" @click="addPlainNode('public')">
临时节点
</Menu.Item>
<Menu.SubMenu key="public-library">
<template #title>节点库</template>
<Menu.Item
v-for="item in publicLibraryNodes"
:key="`public-${item.libraryId}`"
@click="addNodeFromLibrary('public', item.libraryId)"
>
{{ item.name }}
</Menu.Item>
<Menu.Item v-if="publicLibraryNodes.length === 0" key="public-empty" disabled>
暂无公共资料节点
</Menu.Item>
</Menu.SubMenu>
</Menu>
</template>
</Dropdown>
</Space>
</div>
</template>
<div class="node-lane">
<div class="node-lane__title">公共资料</div>
<div class="node-strip">
<button
v-for="item in publicNodes"
:key="item.id"
class="node-pill"
:class="{ active: currentNode?.id === item.id }"
:draggable="!isTemplatePublished"
type="button"
@dragstart="handleTemplateNodeDragStart(item.id)"
@dragover.prevent
@drop.prevent="handleTemplateNodeDrop('public', item.id)"
@dragend="clearTemplateNodeDragState"
@click="selectNode((editor.nodes ?? []).findIndex((node) => node.id === item.id))"
>
<span>公共资料</span>
<strong>{{ item.name || '未命名节点' }}</strong>
<small>
{{ item.fields.length }} 个字段
<template v-if="item.locked"> · 节点库</template>
</small>
<Button
v-if="!isTemplatePublished"
class="node-pill__remove"
danger
size="small"
type="text"
@click.stop="confirmRemoveTemplateNode((editor.nodes ?? []).findIndex((node) => node.id === item.id))"
>
删除节点
</Button>
</button>
<div v-if="publicNodes.length === 0" class="lane-empty">还没有公共资料节点</div>
</div>
</div>
<div class="node-lane">
<div class="node-lane__title">业务流程节点</div>
<div class="node-strip">
<button
v-for="item in businessNodes"
:key="item.id"
class="node-pill"
:class="{ active: currentNode?.id === item.id }"
:draggable="!isTemplatePublished"
type="button"
@dragstart="handleTemplateNodeDragStart(item.id)"
@dragover.prevent
@drop.prevent="handleTemplateNodeDrop('business', item.id)"
@dragend="clearTemplateNodeDragState"
@click="selectNode((editor.nodes ?? []).findIndex((node) => node.id === item.id))"
>
<span>业务流程</span>
<strong>{{ item.name || '未命名节点' }}</strong>
<small>
{{ item.fields.length }} 个字段
<template v-if="item.locked"> · 节点库</template>
</small>
<Button
v-if="!isTemplatePublished"
class="node-pill__remove"
danger
size="small"
type="text"
@click.stop="confirmRemoveTemplateNode((editor.nodes ?? []).findIndex((node) => node.id === item.id))"
>
删除节点
</Button>
</button>
<div v-if="businessNodes.length === 0" class="lane-empty">还没有业务流程节点</div>
</div>
</div>
<div v-if="currentNode" class="node-editor">
<div class="node-editor__header">
<div>
<h3>节点详情</h3>
<p v-if="currentNode.locked" class="locked-tip">
当前节点来自节点库已复制到模板中并锁定后续修改节点库不会影响当前模板和已创建批次
</p>
</div>
</div>
<Row :gutter="[16, 16]">
<Col :md="12" :xs="24">
<label class="field-label">节点名称</label>
<Input v-model:value="currentNode.name" :disabled="currentNodeLocked || isTemplatePublished" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">节点类型</label>
<div class="readonly-box">
{{ currentNode.category === 'public' ? '公共资料节点' : '业务节点' }}
</div>
</Col>
<Col :md="18" :xs="24">
<label class="field-label">节点说明</label>
<Input
v-model:value="currentNode.description"
:disabled="currentNodeLocked || isTemplatePublished"
/>
</Col>
<Col :md="6" :xs="24">
<label class="field-label">消费者可见</label>
<div class="switch-line">
<Switch
v-model:checked="currentNode.consumerVisible"
:disabled="currentNodeLocked || isTemplatePublished"
/>
</div>
</Col>
</Row>
<div class="field-editor">
<div class="field-editor__header">
<h4>字段设计</h4>
<Button
type="dashed"
:disabled="currentNodeLocked || isTemplatePublished"
@click="addField(currentNode)"
>
新增字段
</Button>
</div>
<div class="field-strip">
<button
v-for="(field, fieldIndex) in currentNode.fields"
:key="`${field.key}-${fieldIndex}`"
class="field-pill"
:class="{ active: field.key === currentTemplateField?.key }"
:draggable="!isTemplatePublished && !currentNodeLocked"
type="button"
@dragstart="handleTemplateFieldDragStart(field.key)"
@dragover.prevent
@drop.prevent="handleTemplateFieldDrop(field.key)"
@dragend="clearTemplateFieldDragState"
@click="selectedTemplateFieldKey = field.key"
>
<span>{{ getFieldTypeLabel(field.type) }}</span>
<strong>{{ field.label || '新字段' }}</strong>
<small>{{ field.key }}</small>
<Button
class="field-pill__remove"
danger
size="small"
type="text"
:disabled="currentNodeLocked || isTemplatePublished"
@click.stop="confirmRemoveField(currentNode, fieldIndex)"
>
删除字段
</Button>
</button>
<div v-if="currentNode.fields.length === 0" class="lane-empty">还没有字段请先新增字段</div>
</div>
<div v-if="currentTemplateField" class="field-card">
<div class="field-card__header">
<div class="field-card__title">
<strong>{{ currentTemplateField.label || '新字段' }}</strong>
<span>{{ getFieldTypeLabel(currentTemplateField.type) }}</span>
</div>
<Button
class="field-delete-button"
danger
:disabled="currentNodeLocked || isTemplatePublished"
@click="confirmRemoveField(currentNode, currentNode.fields.findIndex((item) => item.key === currentTemplateField.key))"
>
删除字段
</Button>
</div>
<Row :gutter="[12, 12]" class="field-row field-row--base">
<Col :md="6" :xs="24">
<label class="field-label">字段 Key</label>
<Input :value="currentTemplateField.key" :disabled="currentNodeLocked || isTemplatePublished" @update:value="(value) => updateSelectedFieldKey('template', currentTemplateField, String(value ?? ''))" />
</Col>
<Col :md="6" :xs="24">
<label class="field-label">字段名称</label>
<Input v-model:value="currentTemplateField.label" :disabled="currentNodeLocked || isTemplatePublished" />
</Col>
<Col :md="6" :xs="24">
<label class="field-label">类型</label>
<Select
v-model:value="currentTemplateField.type"
:disabled="currentNodeLocked || isTemplatePublished"
:options="fieldTypeOptions"
style="width: 100%"
/>
</Col>
<Col :md="6" :xs="24">
<label class="field-label">占位提示</label>
<Input
v-model:value="currentTemplateField.placeholder"
:disabled="currentNodeLocked || isTemplatePublished"
/>
</Col>
</Row>
<div class="field-toggle-row">
<div class="field-toggle-chip">
<span>必填</span>
<Switch
v-model:checked="currentTemplateField.required"
:disabled="currentNodeLocked || isTemplatePublished"
/>
</div>
<div class="field-toggle-chip">
<span>消费者可见</span>
<Switch
v-model:checked="currentTemplateField.visible"
:disabled="currentNodeLocked || isTemplatePublished"
/>
</div>
<div class="field-toggle-chip">
<span>固定预设值</span>
<Switch
v-model:checked="currentTemplateField.fixedPreset"
:disabled="currentNodeLocked || isTemplatePublished"
/>
</div>
<div class="field-toggle-chip">
<span>文字加粗</span>
<Switch
:checked="getFieldStyleConfig(currentTemplateField).bold"
:disabled="currentNodeLocked || isTemplatePublished"
@update:checked="(checked) => updateFieldStyleBold(currentTemplateField, checked)"
/>
</div>
</div>
<Row :gutter="[12, 12]" class="field-row field-row--content">
<Col :md="14" :xs="24">
<label class="field-label">预设值</label>
<template v-if="currentTemplateField.type === 'image'">
<div class="default-image-editor">
<img
v-if="currentTemplateField.defaultValue"
:src="getImagePreviewSrc(currentTemplateField.defaultValue, currentTemplateField.defaultPreviewUrl)"
alt="默认图片"
class="default-image-preview"
/>
<div class="default-image-actions">
<input
:id="getFieldInputId(`template-${nodeIndex}-${currentTemplateField.key}`, currentTemplateField)"
accept="image/*"
class="upload-input"
type="file"
:disabled="isFieldPresetLocked(currentTemplateField)"
@change="
(event) =>
handleFieldImageUpload(
`template-${nodeIndex}-${currentTemplateField.key}`,
currentTemplateField,
event,
)
"
/>
<Button
:disabled="isFieldPresetLocked(currentTemplateField)"
:loading="
uploadingFieldKey ===
getFieldUploadKey(`template-${nodeIndex}-${currentTemplateField.key}`, currentTemplateField)
"
size="small"
type="primary"
@click="triggerFieldImageSelect(`template-${nodeIndex}-${currentTemplateField.key}`, currentTemplateField)"
>
{{ currentTemplateField.defaultValue ? '重新上传' : '上传默认图' }}
</Button>
<Button
v-if="currentTemplateField.defaultValue"
:disabled="isFieldPresetLocked(currentTemplateField)"
size="small"
@click="clearFieldImage(currentTemplateField)"
>
移除
</Button>
</div>
</div>
</template>
<Select
v-else-if="currentTemplateField.type === 'select'"
:disabled="isFieldPresetLocked(currentTemplateField)"
:options="(currentTemplateField.options || []).map((item) => ({ label: item, value: item }))"
:value="getPresetValue(currentTemplateField)"
style="width: 100%"
@update:value="(value) => updatePresetValue(currentTemplateField, value)"
/>
<Select
v-else-if="currentTemplateField.type === 'multi_select'"
:disabled="isFieldPresetLocked(currentTemplateField)"
:options="(currentTemplateField.options || []).map((item) => ({ label: item, value: item }))"
:value="getPresetValue(currentTemplateField)"
mode="multiple"
style="width: 100%"
@update:value="(value) => updatePresetValue(currentTemplateField, value)"
/>
<Input
v-else-if="currentTemplateField.type === 'integer'"
:disabled="isFieldPresetLocked(currentTemplateField)"
:placeholder="currentTemplateField.placeholder || '请输入整数'"
:value="String(getPresetValue(currentTemplateField) ?? '')"
style="width: 100%"
@update:value="
(value) =>
updatePresetValue(currentTemplateField, sanitizeIntegerPreset(String(value ?? '')))
"
/>
<Input
v-else-if="currentTemplateField.type === 'decimal'"
:disabled="isFieldPresetLocked(currentTemplateField)"
:placeholder="currentTemplateField.placeholder || '请输入小数'"
:value="String(getPresetValue(currentTemplateField) ?? '')"
style="width: 100%"
@update:value="
(value) =>
updatePresetValue(currentTemplateField, sanitizeDecimalPreset(String(value ?? '')))
"
/>
<DatePicker
v-else-if="currentTemplateField.type === 'date'"
:disabled="isFieldPresetLocked(currentTemplateField)"
:value="getPresetValue(currentTemplateField)"
style="width: 100%"
value-format="YYYY-MM-DD"
@update:value="(value) => updatePresetValue(currentTemplateField, value)"
/>
<DatePicker
v-else-if="currentTemplateField.type === 'datetime'"
:disabled="isFieldPresetLocked(currentTemplateField)"
:value="getPresetValue(currentTemplateField)"
format="YYYY-MM-DD HH:mm:ss"
show-time
style="width: 100%"
value-format="YYYY-MM-DD HH:mm:ss"
@update:value="(value) => updatePresetValue(currentTemplateField, value)"
/>
<CoordinateFieldEditor
v-else-if="currentTemplateField.type === 'coordinate'"
:disabled="isFieldPresetLocked(currentTemplateField)"
:model-value="getPresetValue(currentTemplateField)"
@update:model-value="(value) => updatePresetValue(currentTemplateField, value)"
/>
<Input.TextArea
v-else-if="currentTemplateField.type === 'json'"
:auto-size="{ minRows: 3, maxRows: 5 }"
:disabled="isFieldPresetLocked(currentTemplateField)"
:value="String(getPresetValue(currentTemplateField) || '')"
@update:value="(value) => updatePresetValue(currentTemplateField, value)"
/>
<Input
v-else
:disabled="isFieldPresetLocked(currentTemplateField)"
:value="getPresetValue(currentTemplateField)"
@update:value="(value) => updatePresetValue(currentTemplateField, value)"
/>
</Col>
<Col :md="10" :xs="24">
<label class="field-label">文字颜色</label>
<div class="color-picker-line compact-color-line field-color-inline">
<input
:disabled="currentNodeLocked || isTemplatePublished"
:value="getFieldStyleConfig(currentTemplateField).color || '#1f2937'"
class="color-input"
type="color"
@input="
(event) =>
updateFieldStyleColor(
currentTemplateField,
(event.target as HTMLInputElement).value,
)
"
/>
<span>{{ getFieldStyleConfig(currentTemplateField).color || '#1f2937' }}</span>
</div>
</Col>
<Col v-if="['select', 'multi_select'].includes(currentTemplateField.type)" :span="24">
<label class="field-label">选项</label>
<Select
v-model:value="currentTemplateField.options"
:disabled="currentNodeLocked || isTemplatePublished"
mode="tags"
placeholder="输入后回车"
/>
</Col>
<Col :span="24">
<div class="field-type-hint">当前字段类型:{{ getFieldTypeLabel(currentTemplateField.type) }}</div>
</Col>
</Row>
</div>
</div>
</div>
<Empty v-else description="请选择或新增节点" />
</Card>
</div>
</Col>
</Row>
</Tabs.TabPane>
<Tabs.TabPane key="library" tab="节点库">
<Row :gutter="[16, 16]">
<Col :lg="8" :xs="24">
<Card class="panel-card editor-panel" title="节点列表">
<template #extra>
<Space>
<Button @click="createLibraryNode('business')">新增业务节点</Button>
<Button @click="createLibraryNode('public')">新增公共资料节点</Button>
</Space>
</template>
<Tabs :active-key="libraryTab" @change="(key) => selectLibraryTab(key as 'business' | 'public')">
<Tabs.TabPane key="business" tab="业务节点">
<div class="template-list">
<button
v-for="item in businessLibraryNodes"
:key="item.libraryId"
class="template-card compact-template"
:class="{ active: item.libraryId === selectedLibraryNodeId }"
@click="selectLibraryNode(item.libraryId || '')"
>
<div class="template-card__top">
<strong>{{ item.name }}</strong>
<Tag color="blue">业务</Tag>
</div>
<p>{{ item.description || '暂无节点说明' }}</p>
<span>{{ item.fields.length }} 个字段</span>
</button>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="public" tab="公共资料">
<div class="template-list">
<button
v-for="item in publicLibraryNodes"
:key="item.libraryId"
class="template-card compact-template"
:class="{ active: item.libraryId === selectedLibraryNodeId }"
@click="selectLibraryNode(item.libraryId || '')"
>
<div class="template-card__top">
<strong>{{ item.name }}</strong>
<Tag color="green">公共资料</Tag>
</div>
<p>{{ item.description || '暂无节点说明' }}</p>
<span>{{ item.fields.length }} 个字段</span>
</button>
</div>
</Tabs.TabPane>
</Tabs>
</Card>
</Col>
<Col :lg="16" :xs="24">
<Card class="panel-card editor-panel" title="节点配置项">
<template #extra>
<Space>
<Button type="primary" @click="saveLibraryNode">保存节点</Button>
<Button danger @click="removeLibraryNode">删除节点</Button>
</Space>
</template>
<div v-if="currentLibraryNode" class="node-editor library-editor">
<div class="library-tip">
节点库是模板素材库。这里的修改只会影响后续新加入模板的节点,以及基于新模板创建的批次;不会追溯修改已有模板和历史批次。
</div>
<Row :gutter="[16, 16]">
<Col :md="12" :xs="24">
<label class="field-label">节点名称</label>
<Input v-model:value="currentLibraryNode.name" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">节点类型</label>
<div class="readonly-box">
{{ currentLibraryNode.category === 'public' ? '公共资料节点' : '业务节点' }}
</div>
</Col>
<Col :md="18" :xs="24">
<label class="field-label">节点说明</label>
<Input v-model:value="currentLibraryNode.description" />
</Col>
<Col :md="6" :xs="24">
<label class="field-label">消费者可见</label>
<div class="switch-line">
<Switch v-model:checked="currentLibraryNode.consumerVisible" />
</div>
</Col>
</Row>
<div class="field-editor">
<div class="field-editor__header">
<h4>字段设计</h4>
<Button type="dashed" @click="addField(currentLibraryNode)">新增字段</Button>
</div>
<div class="field-strip">
<button
v-for="(field, fieldIndex) in currentLibraryNode.fields"
:key="`${field.key}-${fieldIndex}`"
class="field-pill"
:class="{ active: field.key === currentLibraryField?.key }"
draggable="true"
type="button"
@dragstart="handleLibraryFieldDragStart(field.key)"
@dragover.prevent
@drop.prevent="handleLibraryFieldDrop(field.key)"
@dragend="clearLibraryFieldDragState"
@click="selectedLibraryFieldKey = field.key"
>
<span>{{ getFieldTypeLabel(field.type) }}</span>
<strong>{{ field.label || '新字段' }}</strong>
<small>{{ field.key }}</small>
<Button class="field-pill__remove" danger size="small" type="text" @click.stop="confirmRemoveField(currentLibraryNode, fieldIndex)">
删除字段
</Button>
</button>
<div v-if="currentLibraryNode.fields.length === 0" class="lane-empty">还没有字段,请先新增字段</div>
</div>
<div v-if="currentLibraryField" class="field-card">
<div class="field-card__header">
<div class="field-card__title">
<strong>{{ currentLibraryField.label || '新字段' }}</strong>
<span>{{ getFieldTypeLabel(currentLibraryField.type) }}</span>
</div>
<Button class="field-delete-button" danger @click="confirmRemoveField(currentLibraryNode, currentLibraryNode.fields.findIndex((item) => item.key === currentLibraryField.key))">
删除字段
</Button>
</div>
<Row :gutter="[12, 12]" class="field-row field-row--base">
<Col :md="6" :xs="24">
<label class="field-label">字段 Key</label>
<Input :value="currentLibraryField.key" @update:value="(value) => updateSelectedFieldKey('library', currentLibraryField, String(value ?? ''))" />
</Col>
<Col :md="6" :xs="24">
<label class="field-label">字段名称</label>
<Input v-model:value="currentLibraryField.label" />
</Col>
<Col :md="6" :xs="24">
<label class="field-label">类型</label>
<Select v-model:value="currentLibraryField.type" :options="fieldTypeOptions" style="width: 100%" />
</Col>
<Col :md="6" :xs="24">
<label class="field-label">占位提示</label>
<Input v-model:value="currentLibraryField.placeholder" />
</Col>
</Row>
<div class="field-toggle-row">
<div class="field-toggle-chip">
<span>必填</span>
<Switch v-model:checked="currentLibraryField.required" />
</div>
<div class="field-toggle-chip">
<span>消费者可见</span>
<Switch v-model:checked="currentLibraryField.visible" />
</div>
<div class="field-toggle-chip">
<span>固定预设值</span>
<Switch v-model:checked="currentLibraryField.fixedPreset" />
</div>
<div class="field-toggle-chip">
<span>文字加粗</span>
<Switch
:checked="getFieldStyleConfig(currentLibraryField).bold"
@update:checked="(checked) => updateFieldStyleBold(currentLibraryField, checked)"
/>
</div>
</div>
<Row :gutter="[12, 12]" class="field-row field-row--content">
<Col :md="14" :xs="24">
<label class="field-label">预设值</label>
<template v-if="currentLibraryField.type === 'image'">
<div class="default-image-editor">
<img
v-if="currentLibraryField.defaultValue"
:src="getImagePreviewSrc(currentLibraryField.defaultValue, currentLibraryField.defaultPreviewUrl)"
alt="默认图片"
class="default-image-preview"
/>
<div class="default-image-actions">
<input
:id="getFieldInputId(`library-${currentLibraryField.key}`, currentLibraryField)"
accept="image/*"
class="upload-input"
type="file"
@change="
(event) =>
handleFieldImageUpload(`library-${currentLibraryField.key}`, currentLibraryField, event)
"
/>
<Button
:loading="
uploadingFieldKey ===
getFieldUploadKey(`library-${currentLibraryField.key}`, currentLibraryField)
"
size="small"
type="primary"
@click="triggerFieldImageSelect(`library-${currentLibraryField.key}`, currentLibraryField)"
>
{{ currentLibraryField.defaultValue ? '重新上传' : '上传默认图' }}
</Button>
<Button
v-if="currentLibraryField.defaultValue"
size="small"
@click="clearFieldImage(currentLibraryField)"
>
移除
</Button>
</div>
</div>
</template>
<Select
v-else-if="currentLibraryField.type === 'select'"
:options="(currentLibraryField.options || []).map((item) => ({ label: item, value: item }))"
:value="getPresetValue(currentLibraryField)"
style="width: 100%"
@update:value="(value) => updatePresetValue(currentLibraryField, value)"
/>
<Select
v-else-if="currentLibraryField.type === 'multi_select'"
:options="(currentLibraryField.options || []).map((item) => ({ label: item, value: item }))"
:value="getPresetValue(currentLibraryField)"
mode="multiple"
style="width: 100%"
@update:value="(value) => updatePresetValue(currentLibraryField, value)"
/>
<Input
v-else-if="currentLibraryField.type === 'integer'"
:placeholder="currentLibraryField.placeholder || '请输入整数'"
:value="String(getPresetValue(currentLibraryField) ?? '')"
style="width: 100%"
@update:value="
(value) =>
updatePresetValue(currentLibraryField, sanitizeIntegerPreset(String(value ?? '')))
"
/>
<Input
v-else-if="currentLibraryField.type === 'decimal'"
:placeholder="currentLibraryField.placeholder || '请输入小数'"
:value="String(getPresetValue(currentLibraryField) ?? '')"
style="width: 100%"
@update:value="
(value) =>
updatePresetValue(currentLibraryField, sanitizeDecimalPreset(String(value ?? '')))
"
/>
<DatePicker
v-else-if="currentLibraryField.type === 'date'"
:value="getPresetValue(currentLibraryField)"
style="width: 100%"
value-format="YYYY-MM-DD"
@update:value="(value) => updatePresetValue(currentLibraryField, value)"
/>
<DatePicker
v-else-if="currentLibraryField.type === 'datetime'"
:value="getPresetValue(currentLibraryField)"
format="YYYY-MM-DD HH:mm:ss"
show-time
style="width: 100%"
value-format="YYYY-MM-DD HH:mm:ss"
@update:value="(value) => updatePresetValue(currentLibraryField, value)"
/>
<CoordinateFieldEditor
v-else-if="currentLibraryField.type === 'coordinate'"
:model-value="getPresetValue(currentLibraryField)"
@update:model-value="(value) => updatePresetValue(currentLibraryField, value)"
/>
<Input.TextArea
v-else-if="currentLibraryField.type === 'json'"
:auto-size="{ minRows: 3, maxRows: 5 }"
:value="String(getPresetValue(currentLibraryField) || '')"
@update:value="(value) => updatePresetValue(currentLibraryField, value)"
/>
<Input
v-else
:value="getPresetValue(currentLibraryField)"
@update:value="(value) => updatePresetValue(currentLibraryField, value)"
/>
</Col>
<Col :md="10" :xs="24">
<label class="field-label">文字颜色</label>
<div class="color-picker-line compact-color-line field-color-inline">
<input
:value="getFieldStyleConfig(currentLibraryField).color || '#1f2937'"
class="color-input"
type="color"
@input="
(event) =>
updateFieldStyleColor(
currentLibraryField,
(event.target as HTMLInputElement).value,
)
"
/>
<span>{{ getFieldStyleConfig(currentLibraryField).color || '#1f2937' }}</span>
</div>
</Col>
<Col v-if="['select', 'multi_select'].includes(currentLibraryField.type)" :span="24">
<label class="field-label">选项</label>
<Select v-model:value="currentLibraryField.options" mode="tags" placeholder="输入后回车" />
</Col>
</Row>
</div>
</div>
</div>
<Empty v-else description="请选择左侧节点进行配置" />
</Card>
</Col>
</Row>
</Tabs.TabPane>
<Tabs.TabPane key="stats" tab="统计信息">
<Card class="panel-card compact-panel" title="统计信息总览">
<div class="stat-grid">
<div class="stat-item">
<span>模板</span>
<strong>{{ overview.templateCount }}</strong>
</div>
<div class="stat-item">
<span>批次</span>
<strong>{{ overview.batchCount }}</strong>
</div>
<div class="stat-item">
<span>已发布</span>
<strong>{{ overview.publishedCount }}</strong>
</div>
<div class="stat-item">
<span>扫码量</span>
<strong>{{ overview.totalScans }}</strong>
</div>
</div>
</Card>
</Tabs.TabPane>
</Tabs>
</div>
<Modal
v-model:open="createTemplateVisible"
title="新建模板"
ok-text="开始编排"
cancel-text="取消"
@ok="createTemplate"
>
<Row :gutter="[16, 16]">
<Col :span="24">
<label class="field-label">模板名称</label>
<Input v-model:value="createTemplateForm.name" placeholder="茶叶溯源模板" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">产品名称</label>
<Input v-model:value="createTemplateForm.productName" placeholder="高山有机绿茶" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">行业名称</label>
<Input v-model:value="createTemplateForm.industryName" placeholder="农产品 / 食品加工" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">主题色</label>
<div class="color-picker-line">
<input
v-model="createTemplateForm.themeColor"
class="color-input"
type="color"
/>
<span>{{ createTemplateForm.themeColor }}</span>
</div>
</Col>
<Col :md="12" :xs="24">
<label class="field-label">封面图</label>
<div class="cover-selector">
<div
v-if="getImagePreviewSrc(createTemplateForm.coverImage, createTemplateForm.coverImagePreviewUrl)"
class="cover-selector__preview"
>
<img
:src="getImagePreviewSrc(createTemplateForm.coverImage, createTemplateForm.coverImagePreviewUrl)"
alt="模板封面图"
/>
</div>
<div class="cover-selector__actions">
<input
id="template-cover-upload"
hidden
type="file"
accept="image/*"
@change="handleTemplateCoverUpload"
/>
<Button
:loading="uploadingFieldKey === 'template-cover'"
@click="triggerTemplateCoverSelect"
>
选择本地文件
</Button>
<Button @click="openCoverHistoryModal">
选择历史文件
</Button>
<Button
v-if="getImagePreviewSrc(createTemplateForm.coverImage, createTemplateForm.coverImagePreviewUrl)"
danger
@click="clearTemplateCoverImage"
>
清空
</Button>
</div>
</div>
</Col>
<Col :span="24">
<label class="field-label">模板说明</label>
<Input.TextArea
v-model:value="createTemplateForm.description"
:auto-size="{ minRows: 3, maxRows: 5 }"
placeholder="说明此模板适用对象展示重点和字段规范"
/>
</Col>
<Col :span="24">
<label class="field-label">备注</label>
<Input.TextArea
v-model:value="createTemplateForm.remark"
:auto-size="{ minRows: 2, maxRows: 4 }"
placeholder="仅内部可见用于记录模板背景使用限制或补充说明"
/>
</Col>
</Row>
</Modal>
<Modal
v-model:open="coverHistoryVisible"
title="选择历史封面图"
:footer="null"
>
<div v-if="coverHistoryItems.length > 0" class="cover-history-grid">
<div
v-for="item in coverHistoryItems"
:key="item.id"
class="cover-history-card"
>
<img :src="item.previewUrl" :alt="item.fileName" />
<div class="cover-history-card__meta">
<strong>{{ item.fileName || item.objectName }}</strong>
<span>{{ item.createdAt }}</span>
</div>
<div class="cover-history-card__actions">
<Button size="small" type="primary" @click="selectHistoryCover(item)">
选择
</Button>
<Button
size="small"
danger
:disabled="item.id.startsWith('legacy-')"
:loading="deletingCoverAssetId === item.id"
@click="deleteHistoryCover(item)"
>
删除
</Button>
</div>
</div>
</div>
<Empty v-else :description="coverHistoryLoading ? '正在加载历史封面图…' : '暂无历史封面图'" />
</Modal>
<Modal
v-model:open="leaveDialogVisible"
title="有未保存的改动"
:mask-closable="false"
:closable="true"
:footer="null"
@cancel="confirmTemplateLeave('cancel')"
>
<p>当前模板有未保存的修改,切换前要怎么处理?</p>
<div class="leave-dialog__actions">
<Button block @click="confirmTemplateLeave('save')">保存</Button>
<Button block @click="confirmTemplateLeave('discard')">不保存</Button>
<Button block @click="confirmTemplateLeave('cancel')">取消</Button>
</div>
</Modal>
</div>
</template>
<style scoped>
.trace-admin {
padding: 4px;
}
.panel-card {
border-radius: 18px;
}
.editor-panel {
min-height: 620px;
}
.template-main {
display: grid;
gap: 16px;
height: 100%;
}
.template-main__editor {
min-height: 540px;
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
width: 100%;
padding-bottom: 4px;
}
.editor-toolbar__left,
.editor-toolbar__right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.editor-toolbar__left {
margin-right: auto;
}
.editor-toolbar__right {
margin-left: 24px;
}
.template-summary {
border: 1px solid #edf1f7;
border-radius: 16px;
background: #fff;
padding: 14px 16px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.summary-inline {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.summary-inline__item {
min-width: 0;
}
.cover-selector {
display: grid;
gap: 12px;
}
.cover-selector__preview {
width: 100%;
max-width: 320px;
overflow: hidden;
border: 1px solid #e7edf7;
border-radius: 16px;
background: #fff;
}
.cover-selector__preview img {
display: block;
width: 100%;
height: auto;
}
.cover-selector__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cover-history-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.cover-history-card {
display: grid;
gap: 10px;
padding: 10px;
border: 1px solid #e7edf7;
border-radius: 16px;
background: #fff;
text-align: left;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.cover-history-card:hover {
border-color: #1f4fd6;
box-shadow: 0 10px 24px rgba(31, 79, 214, 0.12);
}
.cover-history-card img {
display: block;
width: 100%;
height: 140px;
object-fit: cover;
border-radius: 12px;
background: #f7faff;
}
.cover-history-card__meta {
display: grid;
gap: 4px;
}
.cover-history-card__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.cover-history-card__meta strong,
.cover-history-card__meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cover-history-card__meta span {
color: #7d8899;
font-size: 12px;
}
.summary-inline__item--wide {
grid-column: 1 / -1;
}
.summary-inline__item span,
.field-label {
color: #7d8899;
font-size: 12px;
}
.summary-inline__item strong {
display: block;
margin-top: 6px;
color: #1f2937;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
}
.field-label {
display: block;
margin-bottom: 8px;
font-weight: 600;
}
.compact-panel :deep(.ant-card-body) {
padding: 16px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.stat-item {
border: 1px solid #edf1f7;
border-radius: 14px;
padding: 12px;
background: #fafcff;
}
.stat-item span {
color: #7d8899;
font-size: 12px;
}
.stat-item strong {
display: block;
margin-top: 8px;
font-size: 24px;
}
.template-list {
display: grid;
gap: 10px;
}
.template-card {
width: 100%;
text-align: left;
border: 1px solid #edf1f7;
background: #fff;
border-radius: 14px;
padding: 14px;
}
.template-card.active {
border-color: #adc4ff;
background: #f6f9ff;
}
.template-card__top,
.template-card__meta,
.node-editor__header,
.field-editor__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.template-card__meta {
justify-content: flex-start;
flex-wrap: wrap;
margin: 8px 0 6px;
}
.template-card__remark {
margin: 6px 0 0;
color: #556070;
font-size: 12px;
line-height: 1.5;
}
.template-card__footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.template-card__actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.template-card p {
margin: 0;
color: #5f6b7c;
}
.template-card span {
color: #8b96a8;
font-size: 12px;
}
.node-strip {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 10px;
}
.node-pill {
cursor: grab;
min-width: 180px;
border: 1px solid #edf1f7;
background: #fff;
border-radius: 16px;
padding: 14px;
text-align: left;
}
.node-pill.active {
border-color: #adc4ff;
background: #f5f8ff;
}
.node-pill span {
color: #1d4ed8;
font-size: 12px;
}
.node-pill strong,
.node-pill small {
display: block;
}
.node-pill__remove {
margin-top: 10px;
padding-left: 0;
}
.node-pill small {
margin-top: 6px;
color: #8b96a8;
}
.node-lane + .node-lane {
margin-top: 18px;
}
.node-lane__title {
color: #1f2937;
font-size: 14px;
font-weight: 700;
}
.lane-empty {
min-width: 200px;
border: 1px dashed #dbe3f0;
border-radius: 16px;
padding: 18px;
color: #8b96a8;
background: #fbfcff;
}
.node-editor {
border-top: 1px solid #f0f2f5;
padding-top: 20px;
margin-top: 20px;
}
.node-editor__header h3,
.field-editor__header h4 {
margin: 0;
}
.node-editor__header,
.field-editor__header {
margin-bottom: 16px;
}
.locked-tip {
margin: 6px 0 0;
color: #d97706;
font-size: 13px;
line-height: 1.6;
}
.library-tip {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 12px;
background: #fff8eb;
color: #b45309;
font-size: 13px;
line-height: 1.7;
}
.field-editor {
margin-top: 20px;
}
.field-strip {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding-bottom: 6px;
margin-bottom: 14px;
}
.field-pill {
cursor: grab;
min-width: 200px;
max-width: 220px;
border: 1px solid #e5ebf5;
border-radius: 16px;
padding: 12px 14px;
background: #fff;
text-align: left;
transition: all 0.2s ease;
}
.field-pill.active {
border-color: #adc4ff;
background: #f5f8ff;
}
.field-pill:hover {
border-color: #cfdaf0;
transform: translateY(-1px);
}
.field-pill span {
color: #1d4ed8;
font-size: 12px;
}
.field-pill strong,
.field-pill small {
display: block;
}
.field-pill small {
margin-top: 6px;
color: #8b96a8;
}
.field-pill__remove {
margin-top: 10px;
padding-left: 0;
font-size: 12px;
}
.field-card {
border: 1px solid #edf1f7;
border-radius: 16px;
padding: 16px;
background: linear-gradient(180deg, #fbfcff, #f7faff);
}
.field-card + .field-card {
margin-top: 12px;
}
.field-card__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.field-card__title {
display: grid;
gap: 4px;
}
.field-card__title strong {
color: #1f2937;
font-size: 15px;
line-height: 1.4;
}
.field-card__title span {
color: #7d8899;
font-size: 12px;
}
.field-delete-button {
border-radius: 10px;
}
.field-row + .field-row {
margin-top: 12px;
}
.field-toggle-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin: 14px 0;
}
.field-toggle-chip {
min-height: 44px;
border: 1px solid #e6edf8;
border-radius: 12px;
background: #fff;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.field-toggle-chip span {
color: #556070;
font-size: 13px;
}
.switch-line {
min-height: 32px;
display: flex;
align-items: center;
}
.library-editor {
border-top: none;
padding-top: 0;
}
.readonly-box,
.field-type-hint {
min-height: 32px;
border: 1px solid #edf1f7;
border-radius: 12px;
background: #fafcff;
padding: 8px 12px;
color: #5f6b7c;
display: flex;
align-items: center;
}
.theme-dot {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.color-picker-line {
display: flex;
align-items: center;
gap: 12px;
}
.field-style-editor {
display: grid;
gap: 8px;
}
.compact-switch-line {
justify-content: space-between;
}
.compact-color-line span {
color: #5f6b7c;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.field-color-inline {
min-height: 54px;
border: 1px solid #edf1f7;
border-radius: 12px;
background: #fff;
padding: 9px 12px;
}
.color-input {
width: 56px;
height: 36px;
border: none;
padding: 0;
background: transparent;
cursor: pointer;
}
.default-image-editor {
display: grid;
gap: 10px;
}
.default-image-preview {
display: block;
max-width: 100%;
max-height: 180px;
border: 1px solid #dbe3f0;
border-radius: 14px;
object-fit: cover;
background: #fff;
}
.default-image-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.leave-dialog__actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.leave-dialog__actions :deep(.ant-btn) {
width: 100%;
}
@media (max-width: 960px) {
.field-toggle-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.field-card__header {
flex-direction: column;
align-items: stretch;
}
.field-pill {
min-width: 100%;
max-width: none;
}
.field-toggle-row {
grid-template-columns: minmax(0, 1fr);
}
}
</style>