完善各种需求

This commit is contained in:
BBIT-Kai
2026-04-14 16:52:30 +08:00
parent 1c68762421
commit 44181bcf5a
19 changed files with 1848 additions and 282 deletions
@@ -1,7 +1,8 @@
<script lang="ts" setup>
import type { TraceabilityApi } from '#/api';
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import {
@@ -16,8 +17,8 @@ import {
message,
Modal,
Row,
Space,
Select,
Space,
Switch,
Tabs,
Tag,
@@ -34,21 +35,21 @@ import {
getTraceabilityOverview,
getTraceabilityTemplate,
getTraceabilityTemplates,
uploadTraceabilityImage,
updateTraceabilityNodeLibrary,
updateTraceabilityTemplate,
uploadTraceabilityImage,
} from '#/api';
import type { TraceabilityApi } from '#/api';
import CoordinateFieldEditor from './components/CoordinateFieldEditor.vue';
import {
buildOssStoredValue,
createLocalUniqueId,
cloneTemplateForSave,
createEmptyField,
createEmptyNode,
createLocalUniqueId,
fieldTypeOptions,
getImagePreviewSrc,
getFieldTypeLabel,
getImagePreviewSrc,
serializeImageValue,
stripOssTempUrl,
} from './shared';
@@ -69,6 +70,8 @@ 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,
@@ -91,6 +94,10 @@ const editor = reactive<
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);
@@ -104,11 +111,13 @@ const createTemplateForm = reactive<{
industryName: string;
name: string;
productName: string;
remark: string;
themeColor: string;
}>({
coverImage: '',
coverImagePreviewUrl: '',
description: '',
remark: '',
industryName: '',
name: '新建溯源模板',
productName: '',
@@ -133,6 +142,7 @@ const selectedTemplateSummary = computed(() =>
const isTemplatePublished = computed(
() => selectedTemplateSummary.value?.status === 'active',
);
const isTemplateDraft = computed(() => !isTemplatePublished.value);
const currentLibraryNode = computed(() =>
nodeLibrary.value.find((item) => item.libraryId === selectedLibraryNodeId.value),
);
@@ -162,8 +172,9 @@ function toEditableLibraryNode(item: TraceabilityApi.NodeLibraryItem): EditableT
category: item.category,
consumerVisible: item.consumerVisible,
description: item.description,
fields: item.fields.map((field) => ({
fields: item.fields.map((field, fieldIndex) => ({
...field,
sort: field.sort ?? fieldIndex,
defaultPreviewUrl: field.defaultPreviewUrl,
options: [...(field.options ?? [])],
fieldStyle: {
@@ -183,7 +194,8 @@ function buildLibraryPayload(node: EditableTemplateNode) {
category: node.category,
consumerVisible: node.consumerVisible,
description: node.description,
fields: (node.fields ?? []).map((field) => ({
fields: (node.fields ?? []).map((field, fieldIndex) => ({
sort: field.sort ?? fieldIndex,
defaultValue:
field.type === 'image'
? stripOssTempUrl(field.defaultValue ?? '')
@@ -236,13 +248,53 @@ function applyEditor(detail: TraceabilityApi.TemplateDetail) {
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);
@@ -252,6 +304,7 @@ function openCreateTemplateModal() {
createTemplateForm.coverImage = '';
createTemplateForm.coverImagePreviewUrl = '';
createTemplateForm.description = '';
createTemplateForm.remark = '';
createTemplateForm.industryName = '';
createTemplateForm.name = '新建溯源模板';
createTemplateForm.productName = '';
@@ -337,7 +390,7 @@ async function handleTemplateCoverUpload(event: Event) {
}
function triggerTemplateCoverSelect() {
const input = document.getElementById('template-cover-upload');
const input = document.querySelector('#template-cover-upload');
input?.click();
}
@@ -347,6 +400,7 @@ async function createTemplate() {
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')],
@@ -436,8 +490,9 @@ function addNodeFromLibrary(category: 'business' | 'public', libraryId?: string)
category: source.category,
consumerVisible: source.consumerVisible,
description: source.description,
fields: source.fields.map((field) => ({
fields: source.fields.map((field, fieldIndex) => ({
...field,
sort: field.sort ?? fieldIndex,
options: [...(field.options ?? [])],
})),
libraryId: source.libraryId,
@@ -489,14 +544,14 @@ function moveTemplateNode(
const [movedNode] = movedNodes.splice(fromIndex, 1);
movedNodes.splice(toIndex, 0, movedNode);
const reordered = nodes.slice();
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 >= 0) {
if (nextIndex !== -1) {
nodeIndex.value = nextIndex;
}
}
@@ -526,6 +581,7 @@ function clearTemplateNodeDragState() {
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;
@@ -539,6 +595,9 @@ function removeField(target: EditableTemplateNode | null | undefined, index: num
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 || '';
}
@@ -559,6 +618,54 @@ function confirmRemoveField(target: EditableTemplateNode | null | undefined, ind
});
}
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}`;
}
@@ -644,12 +751,12 @@ function getPresetValue(field: TraceabilityApi.FieldDefinition) {
if (field.type === 'multi_select') {
return Array.isArray(field.defaultValue)
? field.defaultValue
: field.defaultValue
: (field.defaultValue
? String(field.defaultValue)
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
: []);
}
return field.defaultValue;
}
@@ -726,11 +833,11 @@ async function removeLibraryNode() {
title: '删除节点库节点',
content: `确认删除节点库节点“${currentName}”吗?`,
async onOk() {
if (!currentLibraryNode.value?.persisted) {
nodeLibrary.value = nodeLibrary.value.filter((item) => item.libraryId !== currentId);
} else {
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('节点库节点已删除');
@@ -758,11 +865,7 @@ async function saveLibraryNode() {
message.success('节点库节点已保存');
}
async function saveTemplate() {
if (isTemplatePublished.value) {
message.warning('模板已发布,不可修改');
return;
}
async function persistTemplate(status?: 'active' | 'draft') {
if (!editor.name?.trim()) {
message.warning('请先填写模板名称');
return;
@@ -773,13 +876,16 @@ async function saveTemplate() {
}
saving.value = true;
try {
const payload = cloneTemplateForSave(editor);
const payload = {
...cloneTemplateForSave(editor),
status: status ?? editor.status ?? 'draft',
};
if (selectedTemplateId.value) {
await updateTraceabilityTemplate(selectedTemplateId.value, payload);
message.success('模板已更新');
message.success(status === 'active' ? '模板已发布' : '模板已更新');
} else {
await createTraceabilityTemplate(payload);
message.success('模板已创建');
message.success(status === 'active' ? '模板已创建并发布' : '模板已创建');
}
await loadAll();
} finally {
@@ -787,22 +893,20 @@ async function saveTemplate() {
}
}
async function saveTemplate() {
if (isTemplatePublished.value) {
message.warning('模板已发布,不可修改');
return;
}
await persistTemplate('draft');
}
async function publishTemplate() {
if (!selectedTemplateId.value) {
message.warning('请先创建并选择模板');
return;
}
saving.value = true;
try {
await updateTraceabilityTemplate(selectedTemplateId.value, {
...cloneTemplateForSave(editor),
status: 'active',
});
message.success('模板已发布');
await loadAll();
} finally {
saving.value = false;
}
await persistTemplate('active');
}
function removeTemplate(id: string) {
@@ -837,9 +941,9 @@ onMounted(async () => {
</script>
<template>
<Page auto-content-height>
<div class="trace-admin-page">
<div class="trace-admin">
<Tabs v-model:active-key="activeTab">
<Tabs v-model:active-key="activeTab">
<Tabs.TabPane key="templates" tab="模板中心">
<Row :gutter="[16, 16]" align="stretch">
<Col :lg="8" :xs="24">
@@ -868,6 +972,9 @@ onMounted(async () => {
<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>
@@ -883,30 +990,59 @@ onMounted(async () => {
<Col :lg="16" :xs="24">
<div class="template-main">
<div class="template-summary" v-if="editor.name">
<div class="summary-inline">
<div class="summary-inline__item">
<span>模板名称</span>
<strong>{{ editor.name }}</strong>
</div>
<div class="summary-inline__item">
<span>产品名称</span>
<strong>{{ editor.productName || '未设置' }}</strong>
</div>
<div class="summary-inline__item">
<span>行业名称</span>
<strong>{{ editor.industryName || '未设置' }}</strong>
</div>
<div class="summary-inline__item">
<span>模板状态</span>
<strong>{{ selectedTemplateSummary?.status === 'active' ? '已发布' : '草稿' }}</strong>
</div>
<div class="summary-inline__item summary-inline__item--wide">
<span>模板说明</span>
<strong>{{ editor.description || '暂无模板说明' }}</strong>
</div>
</div>
</div>
<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="节点编排">
@@ -929,7 +1065,7 @@ onMounted(async () => {
>
{{ item.name }}
</Menu.Item>
<Menu.Item v-if="!businessLibraryNodes.length" key="business-empty" disabled>
<Menu.Item v-if="businessLibraryNodes.length === 0" key="business-empty" disabled>
暂无业务节点
</Menu.Item>
</Menu.SubMenu>
@@ -953,7 +1089,7 @@ onMounted(async () => {
>
{{ item.name }}
</Menu.Item>
<Menu.Item v-if="!publicLibraryNodes.length" key="public-empty" disabled>
<Menu.Item v-if="publicLibraryNodes.length === 0" key="public-empty" disabled>
暂无公共资料节点
</Menu.Item>
</Menu.SubMenu>
@@ -961,21 +1097,7 @@ onMounted(async () => {
</template>
</Dropdown>
</Space>
<Space wrap class="editor-toolbar__right">
<Button :loading="saving" :disabled="isTemplatePublished" @click="saveTemplate">
保存草稿
</Button>
<Button
v-if="!isTemplatePublished"
type="primary"
:loading="saving"
@click="publishTemplate"
>
发布模板
</Button>
</Space>
</div>
</div>
</template>
<div class="node-lane">
@@ -1011,7 +1133,7 @@ onMounted(async () => {
删除节点
</Button>
</button>
<div v-if="!publicNodes.length" class="lane-empty">还没有公共资料节点</div>
<div v-if="publicNodes.length === 0" class="lane-empty">还没有公共资料节点</div>
</div>
</div>
@@ -1048,7 +1170,7 @@ onMounted(async () => {
删除节点
</Button>
</button>
<div v-if="!businessNodes.length" class="lane-empty">还没有业务流程节点</div>
<div v-if="businessNodes.length === 0" class="lane-empty">还没有业务流程节点</div>
</div>
</div>
@@ -1107,7 +1229,12 @@ onMounted(async () => {
: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>
@@ -1124,7 +1251,7 @@ onMounted(async () => {
删除字段
</Button>
</button>
<div v-if="!currentNode.fields.length" class="lane-empty">还没有字段请先新增字段</div>
<div v-if="currentNode.fields.length === 0" class="lane-empty">还没有字段请先新增字段</div>
</div>
<div v-if="currentTemplateField" class="field-card">
<div class="field-card__header">
@@ -1305,6 +1432,12 @@ onMounted(async () => {
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 }"
@@ -1459,7 +1592,12 @@ onMounted(async () => {
: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>
@@ -1469,7 +1607,7 @@ onMounted(async () => {
删除字段
</Button>
</button>
<div v-if="!currentLibraryNode.fields.length" class="lane-empty">还没有字段请先新增字段</div>
<div v-if="currentLibraryNode.fields.length === 0" class="lane-empty">还没有字段,请先新增字段</div>
</div>
<div v-if="currentLibraryField" class="field-card">
<div class="field-card__header">
@@ -1614,6 +1752,11 @@ onMounted(async () => {
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 }"
@@ -1710,7 +1853,7 @@ onMounted(async () => {
v-model="createTemplateForm.themeColor"
class="color-input"
type="color"
>
/>
<span>{{ createTemplateForm.themeColor }}</span>
</div>
</Col>
@@ -1724,7 +1867,7 @@ onMounted(async () => {
<img
:src="getImagePreviewSrc(createTemplateForm.coverImage, createTemplateForm.coverImagePreviewUrl)"
alt="模板封面图"
>
/>
</div>
<div class="cover-selector__actions">
<input
@@ -1733,7 +1876,7 @@ onMounted(async () => {
type="file"
accept="image/*"
@change="handleTemplateCoverUpload"
>
/>
<Button
:loading="uploadingFieldKey === 'template-cover'"
@click="triggerTemplateCoverSelect"
@@ -1761,6 +1904,14 @@ onMounted(async () => {
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>
@@ -1769,13 +1920,13 @@ onMounted(async () => {
title="选择历史封面图"
:footer="null"
>
<div v-if="coverHistoryItems.length" class="cover-history-grid">
<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">
<img :src="item.previewUrl" :alt="item.fileName" />
<div class="cover-history-card__meta">
<strong>{{ item.fileName || item.objectName }}</strong>
<span>{{ item.createdAt }}</span>
@@ -1798,8 +1949,23 @@ onMounted(async () => {
</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>
</Page>
</template>
<style scoped>
@@ -2037,6 +2203,13 @@ onMounted(async () => {
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;
@@ -2169,6 +2342,7 @@ onMounted(async () => {
}
.field-pill {
cursor: grab;
min-width: 200px;
max-width: 220px;
border: 1px solid #e5ebf5;
@@ -2366,6 +2540,16 @@ onMounted(async () => {
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));