2575 lines
89 KiB
Vue
2575 lines
89 KiB
Vue
<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>
|