修复溯源模块大量问题

This commit is contained in:
BBIT-Kai
2026-04-14 10:10:52 +08:00
parent 0a43f5e4b9
commit 1c68762421
26 changed files with 3413 additions and 463 deletions
+2
View File
@@ -42,6 +42,7 @@
"@vben/types": "workspace:*",
"@vben/utils": "workspace:*",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"ant-design-vue": "catalog:",
"axios": "catalog:",
"dayjs": "catalog:",
@@ -51,6 +52,7 @@
"markdown-it": "^14.1.0",
"markdown-it-table": "^4.1.1",
"pinia": "catalog:",
"qrcode": "catalog:",
"video.js": "^8.23.4",
"vue": "catalog:",
"vue-router": "catalog:"
@@ -1,6 +1,17 @@
import { requestClient } from '#/api/request';
export namespace TraceabilityApi {
export interface OssStoredValue {
bucketName: string;
objectName: string;
tempUrl?: string;
}
export interface FieldStyle {
bold?: boolean;
color?: string;
}
export interface Overview {
templateCount: number;
batchCount: number;
@@ -15,9 +26,12 @@ export namespace TraceabilityApi {
type: string;
required: boolean;
visible: boolean;
fixedPreset?: boolean;
placeholder?: string;
defaultValue?: any;
defaultPreviewUrl?: string;
options?: string[];
fieldStyle?: FieldStyle;
}
export interface TemplateNode {
@@ -31,6 +45,28 @@ export namespace TraceabilityApi {
fields: FieldDefinition[];
}
export interface PreviewNode {
id?: string;
sort?: number;
category: 'business' | 'public' | string;
name: string;
description: string;
consumerVisible: boolean;
values: Record<string, any>;
valuePreviewUrls?: Record<string, string>;
fields: FieldDefinition[];
}
export interface NodeLibraryItem {
id: string;
category: 'business' | 'public' | string;
name: string;
description: string;
consumerVisible: boolean;
fields: FieldDefinition[];
updatedAt: string;
}
export interface TemplateSummary {
id: string;
name: string;
@@ -38,6 +74,7 @@ export namespace TraceabilityApi {
productName: string;
industryName: string;
coverImage: string;
coverImagePreviewUrl?: string;
themeColor: string;
status: string;
nodeCount: number;
@@ -49,6 +86,24 @@ export namespace TraceabilityApi {
nodes: TemplateNode[];
}
export interface PreviewPageSummary {
id: string;
name: string;
previewCode: string;
description: string;
productName: string;
coverImage: string;
coverImagePreviewUrl?: string;
themeColor: string;
tags: string[];
publicUrl: string;
updatedAt: string;
}
export interface PreviewPageDetail extends PreviewPageSummary {
nodes: PreviewNode[];
}
export interface BatchStep {
id: string;
templateNodeId?: string;
@@ -61,6 +116,7 @@ export namespace TraceabilityApi {
status: string;
operatorName: string;
values: Record<string, any>;
valuePreviewUrls?: Record<string, string>;
completedAt: string;
fields: FieldDefinition[];
}
@@ -74,6 +130,7 @@ export namespace TraceabilityApi {
productName: string;
summary: string;
coverImage: string;
coverImagePreviewUrl?: string;
tags: string[];
status: string;
currentStep: number;
@@ -116,6 +173,18 @@ export namespace TraceabilityApi {
fileName?: string;
size?: number;
}
export interface FileAssetItem {
id: string;
assetType: string;
bucketName: string;
objectName: string;
fileName: string;
contentType: string;
size: number;
previewUrl: string;
createdAt: string;
}
}
export function getTraceabilityOverview() {
@@ -128,6 +197,68 @@ export function getTraceabilityTemplates() {
);
}
export function getTraceabilityPreviews() {
return requestClient.get<TraceabilityApi.PreviewPageSummary[]>(
'/traceability/previews',
);
}
export function getTraceabilityPreview(id: string) {
return requestClient.get<TraceabilityApi.PreviewPageDetail>(
`/traceability/previews/${id}`,
);
}
export function createTraceabilityPreview(
data: Omit<TraceabilityApi.PreviewPageDetail, 'id' | 'previewCode' | 'publicUrl' | 'updatedAt'>,
) {
return requestClient.post('/traceability/previews', data);
}
export function updateTraceabilityPreview(
id: string,
data: Omit<TraceabilityApi.PreviewPageDetail, 'id' | 'previewCode' | 'publicUrl' | 'updatedAt'>,
) {
return requestClient.post(`/traceability/previews/${id}`, data);
}
export function deleteTraceabilityPreview(id: string) {
return requestClient.delete(`/traceability/previews/${id}`);
}
export function syncTraceabilityPreviewToTemplate(id: string) {
return requestClient.post(`/traceability/previews/${id}/sync-template`);
}
export function getTraceabilityNodeLibrary() {
return requestClient.get<TraceabilityApi.NodeLibraryItem[]>(
'/traceability/node-library',
);
}
export function createTraceabilityNodeLibrary(
data: Omit<TraceabilityApi.NodeLibraryItem, 'id' | 'updatedAt'>,
) {
return requestClient.post<TraceabilityApi.NodeLibraryItem>(
'/traceability/node-library',
data,
);
}
export function updateTraceabilityNodeLibrary(
id: string,
data: Omit<TraceabilityApi.NodeLibraryItem, 'id' | 'updatedAt'>,
) {
return requestClient.post<TraceabilityApi.NodeLibraryItem>(
`/traceability/node-library/${id}`,
data,
);
}
export function deleteTraceabilityNodeLibrary(id: string) {
return requestClient.delete(`/traceability/node-library/${id}`);
}
export function getTraceabilityTemplate(id: string) {
return requestClient.get<TraceabilityApi.TemplateDetail>(
`/traceability/templates/${id}`,
@@ -144,7 +275,7 @@ export function updateTraceabilityTemplate(
id: string,
data: Omit<TraceabilityApi.TemplateDetail, 'batchCount' | 'id' | 'nodeCount' | 'updatedAt'>,
) {
return requestClient.put(`/traceability/templates/${id}`, data);
return requestClient.post(`/traceability/templates/${id}`, data);
}
export function deleteTraceabilityTemplate(id: string) {
@@ -180,7 +311,7 @@ export function deleteTraceabilityBatch(id: string) {
}
export function updateTraceabilityBatchBase(id: string, data: any) {
return requestClient.put(`/traceability/batches/${id}/base`, data);
return requestClient.post(`/traceability/batches/${id}/base`, data);
}
export function updateTraceabilityBatchStep(
@@ -193,7 +324,7 @@ export function updateTraceabilityBatchStep(
completedAt?: string;
},
) {
return requestClient.put(`/traceability/batches/${batchId}/steps/${stepId}`, data);
return requestClient.post(`/traceability/batches/${batchId}/steps/${stepId}`, data);
}
export function publishTraceabilityBatch(id: string) {
@@ -206,6 +337,12 @@ export function getTraceabilityPublicDetail(code: string) {
);
}
export function getTraceabilityPreviewDetail(code: string) {
return requestClient.get<TraceabilityApi.PublicDetail>(
`/traceability/public/preview/by-code/${code}`,
);
}
export function getTraceabilityFeedbackList() {
return requestClient.get<TraceabilityApi.FeedbackItem[]>(
'/traceability/feedback',
@@ -236,6 +373,17 @@ export function uploadTraceabilityImage(data: FormData) {
);
}
export function getTraceabilityFileAssets(assetType: string, limit = 24) {
return requestClient.get<TraceabilityApi.FileAssetItem[]>(
'/traceability/files/history',
{ params: { assetType, limit } },
);
}
export function deleteTraceabilityFileAsset(id: string) {
return requestClient.post<boolean>('/traceability/files/history/delete', { id });
}
export function getTraceabilityUploadToken(data: {
bucketName?: string;
objectName: string;
File diff suppressed because it is too large Load Diff
@@ -1,15 +1,25 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import { Page } from '@vben/common-ui';
import { Button, Card, Col, Empty, Input, message, Row, Tag } from 'ant-design-vue';
import { getTraceabilityBatches, getTraceabilityPublicDetail } from '#/api';
import type { TraceabilityApi } from '#/api';
import { formatFieldValue } from './shared';
import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import {
Button,
Card,
Col,
Empty,
Input,
message,
Row,
Tag,
} from 'ant-design-vue';
import { getTraceabilityBatches, getTraceabilityPreviewDetail } from '#/api';
import { formatFieldValue, getFieldDisplayStyle, getImagePreviewSrc } from './shared';
const loading = ref(false);
const batches = ref<TraceabilityApi.BatchSummary[]>([]);
@@ -38,7 +48,7 @@ async function search() {
}
loading.value = true;
try {
detail.value = await getTraceabilityPublicDetail(queryCode.value.trim());
detail.value = await getTraceabilityPreviewDetail(queryCode.value.trim());
} finally {
loading.value = false;
}
@@ -52,10 +62,7 @@ function getStatusLabel(status: string) {
return status || '进行中';
}
function getFieldLabel(
fields: TraceabilityApi.FieldDefinition[],
key: string,
) {
function getFieldLabel(fields: TraceabilityApi.FieldDefinition[], key: string) {
return fields.find((field) => field.key === key)?.label || key;
}
@@ -64,6 +71,7 @@ function getDisplayEntries(step: TraceabilityApi.BatchStep) {
key,
label: getFieldLabel(step.fields, key),
type: step.fields.find((field) => field.key === key)?.type || 'string',
field: step.fields.find((field) => field.key === key),
value,
}));
}
@@ -145,7 +153,12 @@ onMounted(loadBatches);
<span>消费者访问地址</span>
<strong>{{ publicLink }}</strong>
</div>
<p>{{ detail.batch.summary || '该批次已完成发布,可直接用于消费者扫码访问。' }}</p>
<p>
{{
detail.batch.summary ||
'该批次已完成发布,可直接用于消费者扫码访问。'
}}
</p>
</div>
<div class="access-meta">
<div class="access-card">
@@ -154,7 +167,9 @@ onMounted(loadBatches);
</div>
<div class="access-card">
<span>产品名称</span>
<strong>{{ detail.batch.productName || '未设置产品名称' }}</strong>
<strong>{{
detail.batch.productName || '未设置产品名称'
}}</strong>
</div>
<div class="access-card">
<span>所属模板</span>
@@ -162,7 +177,9 @@ onMounted(loadBatches);
</div>
<div class="access-card">
<span>标签</span>
<strong>{{ detail.batch.tags.join('、') || '暂无标签' }}</strong>
<strong>{{
detail.batch.tags.join('、') || '暂无标签'
}}</strong>
</div>
</div>
</div>
@@ -193,11 +210,13 @@ onMounted(loadBatches);
<span>{{ entry.label }}</span>
<img
v-if="entry.type === 'image' && entry.value"
:src="String(entry.value)"
:src="getImagePreviewSrc(entry.value, item.valuePreviewUrls?.[entry.key])"
:alt="entry.label"
class="consumer-image"
/>
<strong v-else>{{ formatFieldValue(entry.value) }}</strong>
<strong v-else :style="getFieldDisplayStyle(entry.field)">
{{ formatFieldValue(entry.value) }}
</strong>
</div>
</div>
</div>
@@ -237,11 +256,16 @@ onMounted(loadBatches);
<span>{{ entry.label }}</span>
<img
v-if="entry.type === 'image' && entry.value"
:src="String(entry.value)"
:src="getImagePreviewSrc(entry.value, item.valuePreviewUrls?.[entry.key])"
:alt="entry.label"
class="consumer-image"
/>
<strong v-else>{{ formatFieldValue(entry.value) }}</strong>
<strong
v-else
:style="getFieldDisplayStyle(entry.field)"
>
{{ formatFieldValue(entry.value) }}
</strong>
</div>
</div>
</div>
@@ -31,7 +31,14 @@ import {
} from '#/api';
import type { TraceabilityApi } from '#/api';
import { formatFieldValue, getFieldTypeLabel, normalizeFieldInput } from './shared';
import {
buildOssStoredValue,
formatFieldValue,
getFieldTypeLabel,
getImagePreviewSrc,
normalizeFieldInput,
stripOssTempUrl,
} from './shared';
const loading = ref(false);
const selectedBatchId = ref('');
@@ -176,6 +183,21 @@ function updateFieldValue(field: TraceabilityApi.FieldDefinition, value: any) {
currentStep.value.values[field.key] = normalizeFieldInput(field, value);
}
function buildPersistedStepValues(step: TraceabilityApi.BatchStep) {
return Object.fromEntries(
step.fields.map((field) => [
field.key,
field.type === 'image'
? stripOssTempUrl(step.values[field.key])
: normalizeFieldInput(field, step.values[field.key]),
]),
);
}
function isFieldValueLocked(field: TraceabilityApi.FieldDefinition) {
return !isCurrentEditableStep.value || !!isPublished.value || !!field.fixedPreset;
}
function sanitizeIntegerInput(value: string) {
const cleaned = value.replaceAll(/[^\d-]/g, '');
const hasLeadingMinus = cleaned.startsWith('-');
@@ -230,7 +252,7 @@ async function handleImageUpload(field: TraceabilityApi.FieldDefinition, event:
`traceability/${selectedBatchId.value}/${currentStep.value.id}/${field.key}`,
);
const result = await uploadTraceabilityImage(formData);
updateFieldValue(field, result.tempUrl || result.objectName);
updateFieldValue(field, buildOssStoredValue(result));
message.success('图片上传成功');
} catch {
message.error('图片上传失败');
@@ -241,9 +263,10 @@ async function handleImageUpload(field: TraceabilityApi.FieldDefinition, event:
}
function removeBatch(id: string) {
const target = batches.value.find((item) => item.id === id);
Modal.confirm({
title: '删除批次',
content: '删除后该批次的填报记录和发布信息都会一起清除,是否继续?',
content: `确认删除批次“${target?.batchName || '未命名批次'}”吗?删除后该批次的填报记录和发布信息都会一起清除`,
async onOk() {
await deleteTraceabilityBatch(id);
message.success('批次已删除');
@@ -271,7 +294,7 @@ async function saveStep() {
completedAt: new Date().toISOString(),
operatorName: currentStep.value.operatorName,
status: 'completed',
values: currentStep.value.values,
values: buildPersistedStepValues(currentStep.value),
},
);
applyBatch(detail);
@@ -439,13 +462,19 @@ onMounted(async () => {
>
<div class="field-entry">
<div class="field-entry__head">
<label class="field-label">{{ field.label }}</label>
<span class="field-type-tag">{{ getFieldTypeLabel(field.type) }}</span>
<div class="field-entry__title">
<label class="field-label">{{ field.label }}</label>
<small v-if="field.placeholder">{{ field.placeholder }}</small>
</div>
<div class="field-head-tags">
<span class="field-type-tag">{{ getFieldTypeLabel(field.type) }}</span>
<Tag v-if="field.fixedPreset" color="gold">固定预设值</Tag>
</div>
</div>
<div class="field-entry__body">
<Select
v-if="field.type === 'select'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
:value="currentStep.values[field.key]"
style="width: 100%"
@@ -453,7 +482,7 @@ onMounted(async () => {
/>
<Select
v-else-if="field.type === 'multi_select'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
:value="currentStep.values[field.key]"
mode="multiple"
@@ -462,7 +491,7 @@ onMounted(async () => {
/>
<Input
v-else-if="field.type === 'integer'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:placeholder="field.placeholder || '请输入整数'"
:value="String(currentStep.values[field.key] ?? '')"
style="width: 100%"
@@ -473,7 +502,7 @@ onMounted(async () => {
/>
<Input
v-else-if="field.type === 'decimal'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:placeholder="field.placeholder || '请输入小数'"
:value="String(currentStep.values[field.key] ?? '')"
style="width: 100%"
@@ -484,7 +513,7 @@ onMounted(async () => {
/>
<DatePicker
v-else-if="field.type === 'date'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:value="currentStep.values[field.key]"
style="width: 100%"
value-format="YYYY-MM-DD"
@@ -492,7 +521,7 @@ onMounted(async () => {
/>
<DatePicker
v-else-if="field.type === 'datetime'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:value="currentStep.values[field.key]"
format="YYYY-MM-DD HH:mm:ss"
show-time
@@ -502,14 +531,14 @@ onMounted(async () => {
/>
<Input
v-else-if="field.type === 'link'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:placeholder="field.placeholder || '请输入链接地址'"
:value="currentStep.values[field.key]"
@update:value="(value) => updateFieldValue(field, value)"
/>
<Input
v-else-if="field.type === 'string'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:placeholder="field.placeholder || '请输入内容'"
:value="currentStep.values[field.key]"
@update:value="(value) => updateFieldValue(field, value)"
@@ -525,7 +554,12 @@ onMounted(async () => {
class="image-preview-wrap"
>
<img
:src="String(currentStep.values[field.key])"
:src="
getImagePreviewSrc(
currentStep.values[field.key],
currentStep.valuePreviewUrls?.[field.key],
)
"
alt="节点图片"
class="image-preview"
/>
@@ -536,11 +570,11 @@ onMounted(async () => {
accept="image/*"
class="upload-input"
type="file"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
@change="(event) => handleImageUpload(field, event)"
/>
<Button
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:loading="uploadingFieldKey === getFieldUploadKey(field)"
size="small"
type="primary"
@@ -550,7 +584,7 @@ onMounted(async () => {
</Button>
<Button
v-if="currentStep.values[field.key]"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
size="small"
@click="clearImageValue(field)"
>
@@ -568,11 +602,14 @@ onMounted(async () => {
<Input.TextArea
v-else
:auto-size="{ minRows: 3, maxRows: 5 }"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:placeholder="field.placeholder || '请输入内容'"
:value="currentStep.values[field.key]"
@update:value="(value) => updateFieldValue(field, value)"
/>
<div v-if="field.fixedPreset" class="field-fixed-tip">
当前字段已启用固定预设值模板和批次中不可改动
</div>
<div class="field-preview">
当前值{{ formatFieldValue(currentStep.values[field.key]) }}
</div>
@@ -650,11 +687,11 @@ onMounted(async () => {
}
.batch-panel-card {
height: 100%;
height: auto;
}
.batch-panel-card :deep(.ant-card-body) {
height: calc(100% - 57px);
height: auto;
}
.batch-list {
@@ -716,12 +753,28 @@ onMounted(async () => {
.step-strip {
margin-bottom: 20px;
overflow: auto;
overflow: visible;
}
.step-strip :deep(.ant-steps) {
display: flex;
flex-wrap: wrap;
row-gap: 12px;
}
.step-strip :deep(.ant-steps-item) {
flex: 1 1 220px;
min-width: 220px;
}
.step-strip :deep(.ant-steps-item-container) {
padding-right: 12px;
}
.step-editor {
border-top: 1px solid #f0f2f5;
padding-top: 20px;
background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
}
.step-strip--published,
@@ -752,7 +805,7 @@ onMounted(async () => {
}
.dynamic-fields {
margin-top: 8px;
margin-top: 12px;
}
.publish-panel {
@@ -794,20 +847,48 @@ onMounted(async () => {
.field-entry {
display: grid;
gap: 8px;
gap: 12px;
min-height: 100%;
border: 1px solid #edf1f7;
border-radius: 18px;
background: linear-gradient(180deg, #ffffff, #fafcff);
padding: 14px;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.8) inset;
}
.field-entry__head {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
gap: 12px;
}
.field-head-tags {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.field-entry__title {
display: grid;
gap: 4px;
}
.field-entry__title small {
color: #8b96a8;
font-size: 12px;
line-height: 1.5;
}
.field-entry__body {
min-height: 120px;
display: grid;
gap: 10px;
border: 1px solid #edf1f7;
border-radius: 14px;
padding: 14px;
background: #fff;
}
.field-entry__body strong {
@@ -832,6 +913,16 @@ onMounted(async () => {
margin-bottom: 8px;
}
@media (max-width: 640px) {
.step-strip :deep(.ant-steps-item) {
min-width: 100%;
}
.field-entry__head {
flex-direction: column;
}
}
.upload-trigger {
display: inline-flex;
margin-top: 4px;
@@ -858,6 +949,17 @@ onMounted(async () => {
.field-preview {
color: #7d8899;
font-size: 12px;
border-top: 1px dashed #e4e9f2;
padding-top: 10px;
}
.field-fixed-tip {
margin-top: 8px;
color: #b45309;
font-size: 12px;
padding: 8px 10px;
border-radius: 10px;
background: #fff8eb;
}
.placeholder-uploader p {
@@ -0,0 +1,572 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import { Page } from '@vben/common-ui';
import { Button, Card, Col, Empty, Input, message, Modal, Row, Select, Space, Switch, Tabs, Tag } from 'ant-design-vue';
import {
createTraceabilityPreview,
deleteTraceabilityFileAsset,
deleteTraceabilityPreview,
getTraceabilityFileAssets,
getTraceabilityPreview,
getTraceabilityPreviews,
syncTraceabilityPreviewToTemplate,
updateTraceabilityPreview,
uploadTraceabilityImage,
} from '#/api';
import type { TraceabilityApi } from '#/api';
import { buildOssStoredValue, clonePreviewForSave, createEmptyField, createEmptyPreviewNode, getFieldTypeLabel, getImagePreviewSrc, normalizeFieldInput } from './shared';
const previews = ref<TraceabilityApi.PreviewPageSummary[]>([]);
const selectedPreviewId = ref('');
const loading = ref(false);
const saving = ref(false);
const syncing = ref(false);
const uploadingFieldKey = ref('');
const coverHistoryVisible = ref(false);
const coverHistoryLoading = ref(false);
const deletingCoverAssetId = ref('');
const coverHistoryItems = ref<TraceabilityApi.FileAssetItem[]>([]);
const activeEditorTab = ref<'base' | 'nodes'>('base');
const activeNodeTab = ref<'business' | 'public'>('business');
const selectedNodeId = ref('');
const selectedFieldKey = ref('');
const draggingNodeId = ref('');
const editor = reactive<Partial<TraceabilityApi.PreviewPageDetail> & { coverImage: any; coverImagePreviewUrl?: string }>({
id: '', name: '', previewCode: '', description: '', productName: '', coverImage: '', coverImagePreviewUrl: '',
themeColor: '#1f4fd6', tags: [], publicUrl: '', updatedAt: '', nodes: [],
});
const qrCode = useQRCode(computed(() => editor.publicUrl || ''), { errorCorrectionLevel: 'M', margin: 1, width: 220 });
const fieldTypeOptions = [
{ label: '字符串', value: 'string' }, { label: '整数', value: 'integer' }, { label: '小数', value: 'decimal' },
{ label: '日期', value: 'date' }, { label: '日期时间', value: 'datetime' }, { label: '单选', value: 'select' },
{ label: '多选', value: 'multi_select' }, { label: '图片', value: 'image' }, { label: '链接', value: 'link' },
{ label: '视频', value: 'video_url' }, { label: 'JSON', value: 'json' },
];
function nodesByCategory(category: 'business' | 'public') {
return (editor.nodes ?? []).filter((item) => item.category === category);
}
const currentNodeList = computed(() => nodesByCategory(activeNodeTab.value));
const currentNode = computed(() => currentNodeList.value.find((item) => item.id === selectedNodeId.value) ?? currentNodeList.value[0] ?? null);
const currentField = computed(() => currentNode.value?.fields.find((field) => field.key === selectedFieldKey.value) ?? currentNode.value?.fields[0] ?? null);
function syncSelectedFieldFromNode(node: TraceabilityApi.PreviewNode | null) {
selectedFieldKey.value = node?.fields[0]?.key ?? '';
}
function resetEditor() {
Object.assign(editor, {
id: '', name: '', previewCode: '', description: '', productName: '', coverImage: '', coverImagePreviewUrl: '',
themeColor: '#1f4fd6', tags: [], publicUrl: '', updatedAt: '', nodes: [createEmptyPreviewNode('public'), createEmptyPreviewNode('business')],
});
}
function applyPreview(detail: TraceabilityApi.PreviewPageDetail) {
Object.assign(editor, structuredClone(detail));
const businessNode = detail.nodes.find((item) => item.category !== 'public');
const publicNode = detail.nodes.find((item) => item.category === 'public');
activeNodeTab.value = businessNode ? 'business' : 'public';
selectedNodeId.value = (businessNode ?? publicNode)?.id ?? '';
syncSelectedFieldFromNode(businessNode ?? publicNode ?? null);
}
async function loadPreviews() {
loading.value = true;
try {
previews.value = await getTraceabilityPreviews();
if (!selectedPreviewId.value && previews.value[0]) await selectPreview(previews.value[0].id);
else if (!selectedPreviewId.value) resetEditor();
} finally { loading.value = false; }
}
async function selectPreview(id: string) {
selectedPreviewId.value = id;
applyPreview(await getTraceabilityPreview(id));
}
async function createPreview() {
saving.value = true;
try {
const created = await createTraceabilityPreview({
name: '新建预演页', description: '', productName: '', coverImage: '', themeColor: '#1f4fd6', tags: [],
nodes: [createEmptyPreviewNode('public'), createEmptyPreviewNode('business')],
});
await loadPreviews();
if (created?.id) await selectPreview(created.id);
message.success('预演页已创建');
} finally { saving.value = false; }
}
async function savePreview() {
if (!editor.id) return message.warning('请先新建预演页');
saving.value = true;
try {
applyPreview(await updateTraceabilityPreview(editor.id, clonePreviewForSave(editor as TraceabilityApi.PreviewPageDetail)));
await loadPreviews();
message.success('预演页已保存');
} finally { saving.value = false; }
}
async function removePreview(id: string) {
const target = previews.value.find((item) => item.id === id);
Modal.confirm({
title: '删除预演页',
content: `确认删除预演页“${target?.name || '未命名预演页'}”吗?`,
async onOk() {
await deleteTraceabilityPreview(id);
if (selectedPreviewId.value === id) selectedPreviewId.value = '';
await loadPreviews();
message.success('预演页已删除');
},
});
}
async function syncToTemplate() {
if (!editor.id) return;
syncing.value = true;
try {
await savePreview();
await syncTraceabilityPreviewToTemplate(editor.id);
message.success('已同步为新模板');
} finally { syncing.value = false; }
}
function addNode(category: 'business' | 'public') {
editor.nodes ??= [];
const node = createEmptyPreviewNode(category);
editor.nodes.push(node);
activeEditorTab.value = 'nodes';
activeNodeTab.value = category;
selectedNodeId.value = node.id ?? '';
syncSelectedFieldFromNode(node);
}
function removeNode(target: TraceabilityApi.PreviewNode) {
editor.nodes = (editor.nodes ?? []).filter((node) => node !== target);
if (selectedNodeId.value === target.id) {
const nextNode = nodesByCategory(activeNodeTab.value)[0] ?? null;
selectedNodeId.value = nextNode?.id ?? '';
syncSelectedFieldFromNode(nextNode);
}
}
function confirmRemovePreviewNode(target: TraceabilityApi.PreviewNode) {
Modal.confirm({
title: '删除节点',
content: `确认删除节点“${target.name || '未命名节点'}”吗?`,
async onOk() {
removeNode(target);
},
});
}
function movePreviewNode(
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.slice();
let cursor = 0;
editor.nodes = reordered.map((node) =>
node.category === category ? movedNodes[cursor++] : node,
);
selectedNodeId.value = draggedId;
}
function handleNodeDragStart(nodeId?: string) {
draggingNodeId.value = nodeId ?? '';
}
function handleNodeDrop(targetNodeId?: string) {
movePreviewNode(
activeNodeTab.value,
draggingNodeId.value,
targetNodeId ?? '',
);
draggingNodeId.value = '';
}
function clearNodeDragState() { draggingNodeId.value = ''; }
function addField(node: TraceabilityApi.PreviewNode) { const field = createEmptyField(); node.fields.push(field); node.values[field.key] = ''; selectedFieldKey.value = field.key; }
function removeField(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) { node.fields = node.fields.filter((item) => item !== field); delete node.values[field.key]; if (selectedFieldKey.value === field.key) selectedFieldKey.value = node.fields[0]?.key ?? ''; }
function confirmRemovePreviewField(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) {
Modal.confirm({
title: '删除字段',
content: `确认删除字段“${field.label || field.key || '未命名字段'}”吗?`,
async onOk() {
removeField(node, field);
},
});
}
function updateFieldValue(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition, value: any) { node.values[field.key] = normalizeFieldInput(field, value); }
function updateFieldKey(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition, nextKey: string) {
const previousKey = field.key;
const finalKey = nextKey.trim() || previousKey;
if (finalKey === previousKey) return;
node.values[finalKey] = node.values[previousKey];
delete node.values[previousKey];
if (node.valuePreviewUrls?.[previousKey]) {
node.valuePreviewUrls[finalKey] = node.valuePreviewUrls[previousKey];
delete node.valuePreviewUrls[previousKey];
}
field.key = finalKey;
if (selectedFieldKey.value === previousKey) {
selectedFieldKey.value = finalKey;
}
}
function getFieldUploadKey(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) { return `${node.id ?? 'node'}:${field.key}`; }
function getFieldInputId(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) { return `traceability-preview-upload-${getFieldUploadKey(node, field)}`; }
function triggerImageSelect(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) { document.getElementById(getFieldInputId(node, field))?.click(); }
function clearImageValue(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) { updateFieldValue(node, field, ''); }
function sanitizeIntegerInput(value: string) { const c = value.replaceAll(/[^\d-]/g, ''); const m = c.startsWith('-'); const u = m ? c.slice(1).replaceAll('-', '') : c.replaceAll('-', ''); return m ? `-${u}` : u; }
function sanitizeDecimalInput(value: string) { const c = value.replaceAll(/[^\d.-]/g, ''); const i = c.indexOf('.'); const d = i === -1 ? c : `${c.slice(0, i + 1)}${c.slice(i + 1).replaceAll('.', '')}`; const m = d.indexOf('-'); return m <= 0 ? d : `-${d.replaceAll('-', '')}`; }
async function handleImageUpload(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition, event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return;
uploadingFieldKey.value = getFieldUploadKey(node, field);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('objectDir', `traceability/preview/${editor.id || 'draft'}/${node.id}/${field.key}`);
const result = await uploadTraceabilityImage(formData);
updateFieldValue(node, field, buildOssStoredValue(result));
message.success('图片上传成功');
} finally { uploadingFieldKey.value = ''; (event.target as HTMLInputElement).value = ''; }
}
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 triggerTemplateCoverSelect() { document.getElementById('preview-cover-upload')?.click(); }
function clearTemplateCoverImage() { editor.coverImage = ''; editor.coverImagePreviewUrl = ''; }
function selectHistoryCover(item: TraceabilityApi.FileAssetItem) { editor.coverImage = item.bucketName ? { bucketName: item.bucketName, objectName: item.objectName } : String(item.objectName); editor.coverImagePreviewUrl = item.previewUrl; coverHistoryVisible.value = false; }
async function deleteHistoryCover(item: TraceabilityApi.FileAssetItem) {
if (item.id.startsWith('legacy-')) return message.warning('历史回溯封面图暂不支持直接删除');
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('历史封面图已删除'); }
finally { deletingCoverAssetId.value = ''; }
},
});
}
async function handleTemplateCoverUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return;
uploadingFieldKey.value = 'preview-cover';
try {
const formData = new FormData();
formData.append('file', file);
formData.append('assetType', 'cover');
formData.append('objectDir', 'traceability/covers/preview');
const result = await uploadTraceabilityImage(formData);
editor.coverImage = buildOssStoredValue(result);
editor.coverImagePreviewUrl = result.tempUrl || '';
await loadCoverHistory();
message.success('封面图上传成功');
} finally { uploadingFieldKey.value = ''; (event.target as HTMLInputElement).value = ''; }
}
onMounted(loadPreviews);
</script>
<template>
<Page auto-content-height>
<div class="trace-preview-page">
<div class="preview-layout">
<Card class="panel-card preview-sidebar" :loading="loading" title="预演页列表">
<Button block type="primary" @click="createPreview">新建预演页</Button>
<div v-if="previews.length" class="preview-list">
<button v-for="item in previews" :key="item.id" :class="['preview-list__item', { 'is-active': item.id === selectedPreviewId }]" type="button" @click="selectPreview(item.id)">
<div><strong>{{ item.name }}</strong><p>{{ item.previewCode }}</p></div>
<Button danger size="small" @click.stop="removePreview(item.id)">删除</Button>
</button>
</div>
<Empty v-else description="暂无预演页" />
</Card>
<div class="preview-main">
<Tabs v-model:active-key="activeEditorTab" class="panel-card preview-tabs">
<template #rightExtra>
<Button type="primary" :loading="saving" @click="savePreview">保存预演页</Button>
</template>
<Tabs.TabPane key="base" tab="基础信息">
<Card class="panel-card compact-card" :bordered="false">
<div class="section-heading">
<div>
<strong>基础信息</strong>
<p>维护预演页名称封面图主题色和标签</p>
</div>
</div>
<Row :gutter="[16, 16]">
<Col :md="12" :xs="24"><label class="field-label">预演页名称</label><Input v-model:value="editor.name" /></Col>
<Col :md="12" :xs="24"><label class="field-label">产品名称</label><Input v-model:value="editor.productName" /></Col>
<Col :span="24"><label class="field-label">说明</label><Input.TextArea v-model:value="editor.description" :auto-size="{ minRows: 2, maxRows: 4 }" /></Col>
<Col :md="12" :xs="24"><label class="field-label">主题色</label><div class="color-picker-line"><input v-model="editor.themeColor" class="color-input" type="color"><span>{{ editor.themeColor }}</span></div></Col>
<Col :md="12" :xs="24"><label class="field-label">标签</label><Select :value="editor.tags" mode="tags" style="width: 100%" @update:value="(value) => (editor.tags = Array.isArray(value) ? value.map((item) => String(item)) : [])" /></Col>
<Col :span="24">
<label class="field-label">封面图</label>
<div class="cover-selector">
<div v-if="getImagePreviewSrc(editor.coverImage, editor.coverImagePreviewUrl)" class="cover-selector__preview"><img :src="getImagePreviewSrc(editor.coverImage, editor.coverImagePreviewUrl)" alt="预演页封面图"></div>
<div class="cover-selector__actions">
<input id="preview-cover-upload" hidden type="file" accept="image/*" @change="handleTemplateCoverUpload">
<Button :loading="uploadingFieldKey === 'preview-cover'" @click="triggerTemplateCoverSelect">选择本地文件</Button>
<Button @click="openCoverHistoryModal">选择历史文件</Button>
<Button v-if="getImagePreviewSrc(editor.coverImage, editor.coverImagePreviewUrl)" danger @click="clearTemplateCoverImage">清空</Button>
</div>
</div>
</Col>
</Row>
</Card>
<Card class="panel-card compact-card" :bordered="false">
<div class="section-heading">
<div>
<strong>预览与同步</strong>
<p>保存后可生成固定预演链接与二维码确认后再同步为模板</p>
</div>
</div>
<div class="preview-link-card__content">
<div class="preview-link-card__meta">
<div class="meta-line"><span>预演地址</span><strong>{{ editor.publicUrl || '保存后生成' }}</strong></div>
<div class="meta-line"><span>预演编码</span><strong>{{ editor.previewCode || '保存后生成' }}</strong></div>
<div class="meta-actions">
<Button :loading="syncing" @click="syncToTemplate">同步为模板</Button>
</div>
</div>
<div class="preview-link-card__qr"><img v-if="editor.publicUrl" :src="qrCode" alt="预演二维码"><Empty v-else description="保存后生成二维码" /></div>
</div>
</Card>
</Tabs.TabPane>
<Tabs.TabPane key="nodes" tab="节点编排">
<Tabs v-model:active-key="activeNodeTab" class="preview-node-tabs">
<Tabs.TabPane key="business" tab="业务流程节点" />
<Tabs.TabPane key="public" tab="公开资料节点" />
</Tabs>
<Card class="panel-card compact-card" :bordered="false">
<div class="section-heading section-heading--tight">
<div>
<strong>节点编排</strong>
<p>顶部切换节点右侧保存当前预演页下面只编辑当前节点内容</p>
</div>
</div>
<div class="node-toolbar">
<div class="node-lane">
<div class="node-lane__title">
<span>{{ activeNodeTab === 'business' ? '业务流程节点' : '公开资料节点' }}</span>
<Button @click="addNode(activeNodeTab)">新增{{ activeNodeTab === 'business' ? '业务流程' : '公开资料' }}节点</Button>
</div>
<div class="node-strip">
<button
v-for="node in currentNodeList"
:key="node.id"
class="node-pill"
:class="{ active: node.id === currentNode?.id }"
draggable="true"
type="button"
@dragstart="handleNodeDragStart(node.id)"
@dragover.prevent
@drop.prevent="handleNodeDrop(node.id)"
@dragend="clearNodeDragState"
@click="selectedNodeId = node.id || ''; syncSelectedFieldFromNode(node)"
>
<span>{{ activeNodeTab === 'business' ? '业务流程' : '公开资料' }}</span>
<strong>{{ node.name || '未命名节点' }}</strong>
<small>{{ node.fields.length }} 个字段</small>
<Button
class="node-pill__remove"
danger
size="small"
type="text"
@click.stop="confirmRemovePreviewNode(node)"
>
删除节点
</Button>
</button>
<div v-if="!currentNodeList.length" class="lane-empty">
{{ activeNodeTab === 'business' ? '还没有业务流程节点' : '还没有公开资料节点' }}
</div>
</div>
</div>
</div>
<template v-if="currentNode">
<div class="node-editor">
<div class="node-editor__summary">
<div>
<strong>{{ currentNode.name || (activeNodeTab === 'business' ? '未命名业务节点' : '未命名公开资料节点') }}</strong>
<p>{{ activeNodeTab === 'business' ? '业务流程节点' : '公开资料节点' }} · {{ currentNode.fields.length }} 个字段</p>
</div>
<Tag color="blue">{{ currentNode.consumerVisible ? '消费者可见' : '仅内部可见' }}</Tag>
</div>
<Row :gutter="[12, 12]">
<Col :md="12" :xs="24"><label class="field-label">节点名称</label><Input v-model:value="currentNode.name" /></Col>
<Col :md="12" :xs="24"><label class="field-label">消费者可见</label><div class="switch-line"><Switch v-model:checked="currentNode.consumerVisible" /></div></Col>
<Col :span="24"><label class="field-label">节点说明</label><Input.TextArea v-model:value="currentNode.description" :auto-size="{ minRows: 2, maxRows: 4 }" /></Col>
</Row>
<div class="field-editor">
<div class="field-editor__header">
<strong>字段设计</strong>
<Button @click="addField(currentNode)">新增字段</Button>
</div>
<div class="field-strip">
<button
v-for="field in currentNode.fields"
:key="field.key"
class="field-pill"
:class="{ active: field.key === currentField?.key }"
type="button"
@click="selectedFieldKey = 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="confirmRemovePreviewField(currentNode, field)">删除字段</Button>
</button>
<div v-if="!currentNode.fields.length" class="lane-empty">还没有字段请先新增字段</div>
</div>
</div>
<div v-if="currentField" class="field-card">
<div class="field-card__head">
<div class="field-card__title">
<strong>{{ currentField.label || '新字段' }}</strong>
<Tag>{{ getFieldTypeLabel(currentField.type) }}</Tag>
</div>
<Button danger size="small" @click="confirmRemovePreviewField(currentNode, currentField)">删除字段</Button>
</div>
<Row :gutter="[12, 12]">
<Col :md="8" :xs="24"><label class="field-label">字段名称</label><Input v-model:value="currentField.label" /></Col>
<Col :md="8" :xs="24"><label class="field-label">字段 Key</label><Input :value="currentField.key" @update:value="(value) => updateFieldKey(currentNode, currentField, String(value ?? ''))" /></Col>
<Col :md="8" :xs="24"><label class="field-label">字段类型</label><Select :options="fieldTypeOptions" :value="currentField.type" style="width: 100%" @update:value="(value) => (currentField.type = String(value || 'string'))" /></Col>
<Col :md="12" :xs="24"><label class="field-label">占位提示</label><Input v-model:value="currentField.placeholder" /></Col>
<Col :md="6" :xs="24"><label class="field-label">字体颜色</label><Input v-model:value="currentField.fieldStyle!.color" placeholder="#1f4fd6" /></Col>
<Col :md="6" :xs="24"><label class="field-label">文字加粗</label><div class="switch-line"><Switch v-model:checked="currentField.fieldStyle!.bold" /></div></Col>
<Col :span="24">
<label class="field-label">字段值</label>
<template v-if="currentField.type === 'image'">
<div class="image-uploader">
<input :id="getFieldInputId(currentNode, currentField)" hidden type="file" accept="image/*" @change="handleImageUpload(currentNode, currentField, $event)">
<div v-if="getImagePreviewSrc(currentNode.values[currentField.key], currentNode.valuePreviewUrls?.[currentField.key])" class="image-uploader__preview"><img :src="getImagePreviewSrc(currentNode.values[currentField.key], currentNode.valuePreviewUrls?.[currentField.key])" :alt="currentField.label"></div>
<Space>
<Button :loading="uploadingFieldKey === getFieldUploadKey(currentNode, currentField)" @click="triggerImageSelect(currentNode, currentField)">上传图片</Button>
<Button v-if="getImagePreviewSrc(currentNode.values[currentField.key], currentNode.valuePreviewUrls?.[currentField.key])" danger @click="clearImageValue(currentNode, currentField)">清空</Button>
</Space>
</div>
</template>
<Input v-else-if="currentField.type === 'integer'" :value="String(currentNode.values[currentField.key] ?? '')" @update:value="(value) => updateFieldValue(currentNode, currentField, sanitizeIntegerInput(String(value ?? '')))" />
<Input v-else-if="currentField.type === 'decimal'" :value="String(currentNode.values[currentField.key] ?? '')" @update:value="(value) => updateFieldValue(currentNode, currentField, sanitizeDecimalInput(String(value ?? '')))" />
<Select v-else-if="currentField.type === 'select'" :options="(currentField.options ?? []).map((item) => ({ label: item, value: item }))" :value="currentNode.values[currentField.key]" style="width: 100%" @update:value="(value) => updateFieldValue(currentNode, currentField, value)" />
<Select v-else-if="currentField.type === 'multi_select'" mode="multiple" :options="(currentField.options ?? []).map((item) => ({ label: item, value: item }))" :value="currentNode.values[currentField.key] ?? []" style="width: 100%" @update:value="(value) => updateFieldValue(currentNode, currentField, value)" />
<Input.TextArea v-else-if="currentField.type === 'json'" :value="typeof currentNode.values[currentField.key] === 'string' ? currentNode.values[currentField.key] : JSON.stringify(currentNode.values[currentField.key] ?? {}, null, 2)" :auto-size="{ minRows: 3, maxRows: 6 }" @update:value="(value) => updateFieldValue(currentNode, currentField, value)" />
<Input v-else :value="currentNode.values[currentField.key]" @update:value="(value) => updateFieldValue(currentNode, currentField, value)" />
</Col>
</Row>
</div>
</div>
</template>
<Empty v-else :description="`暂无${activeNodeTab === 'business' ? '业务流程' : '公开资料'}节点`" />
</Card>
</Tabs.TabPane>
</Tabs>
</div>
</div>
</div>
<Modal v-model:open="coverHistoryVisible" title="选择历史封面图" :footer="null">
<div v-if="coverHistoryItems.length" 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>
</Page>
</template>
<style scoped>
.trace-preview-page { padding: 4px; }
.preview-layout { display: grid; grid-template-columns: 320px minmax(0, 1fr); gap: 16px; }
.panel-card { border-radius: 18px; }
.compact-card :deep(.ant-card-body) { padding: 16px; }
.preview-sidebar { align-self: start; position: sticky; top: 12px; }
.preview-list { display: grid; gap: 10px; margin-top: 12px; }
.preview-list__item { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; padding: 12px 14px; border: 1px solid #e8edf6; border-radius: 14px; background: #fff; text-align: left; cursor: pointer; }
.preview-list__item.is-active { border-color: #1f4fd6; box-shadow: 0 10px 24px rgba(31, 79, 214, 0.12); }
.preview-list__item p { margin: 6px 0 0; color: #7d8899; font-size: 12px; }
.preview-main { display: grid; gap: 16px; }
.preview-tabs :deep(.ant-tabs-nav),
.preview-node-tabs :deep(.ant-tabs-nav) { margin-bottom: 12px; }
.preview-tabs :deep(.ant-tabs-content-holder),
.preview-node-tabs :deep(.ant-tabs-content-holder) { background: transparent; }
.preview-tabs :deep(.ant-tabs-tabpane) { display: grid; gap: 16px; }
.preview-tabs :deep(.ant-tabs-nav),
.preview-node-tabs :deep(.ant-tabs-nav) { padding: 0 4px; }
.preview-tabs :deep(.ant-tabs-tab),
.preview-node-tabs :deep(.ant-tabs-tab) { padding: 10px 14px; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-heading strong { color: #111827; font-size: 16px; }
.section-heading p { margin: 6px 0 0; color: #7d8899; font-size: 12px; line-height: 1.6; }
.section-heading--tight { margin-bottom: 12px; }
.preview-link-card__content { display: grid; grid-template-columns: minmax(0, 1fr) 240px; gap: 20px; align-items: center; }
.preview-link-card__meta,.meta-line,.cover-selector,.image-uploader,.cover-history-card,.cover-history-card__meta { display: grid; gap: 12px; }
.meta-line span,.field-label { color: #7d8899; font-size: 12px; }
.meta-line strong { word-break: break-word; }
.meta-actions,.cover-selector__actions,.cover-history-card__actions { display: flex; flex-wrap: wrap; gap: 8px; }
.node-toolbar { margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid #eef2f8; }
.preview-link-card__qr { display: flex; align-items: center; justify-content: center; min-height: 220px; border: 1px dashed #d7e1f0; border-radius: 16px; background: #fafcff; }
.preview-link-card__qr img { width: 220px; height: 220px; }
.node-editor { padding: 18px; border: 1px solid #edf1f7; border-radius: 18px; background: linear-gradient(180deg, #fcfdff 0%, #ffffff 100%); }
.node-editor__summary { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 16px; padding: 14px 16px; border: 1px solid #ebf0f7; border-radius: 16px; background: #f8fbff; }
.node-editor__summary strong { color: #111827; font-size: 16px; }
.node-editor__summary p { margin: 6px 0 0; color: #7d8899; font-size: 12px; }
.node-strip { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; }
.node-pill { position: relative; min-width: 210px; max-width: 240px; border: 1px solid #edf1f7; background: #fff; border-radius: 16px; padding: 14px; text-align: left; transition: all 0.2s ease; cursor: grab; }
.node-pill:hover { border-color: #cfdaf0; transform: translateY(-1px); }
.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 small { margin-top: 6px; color: #8b96a8; }
.node-pill__remove { margin-top: 10px; padding-left: 0; font-size: 12px; }
.node-lane__title { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: #1f2937; font-size: 14px; font-weight: 700; }
.lane-empty { min-width: 200px; border: 1px dashed #dbe3f0; border-radius: 14px; padding: 18px; color: #8b96a8; background: #fafcff; }
.field-editor { margin-top: 18px; }
.field-editor__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.field-strip { display: flex; flex-wrap: wrap; gap: 12px; padding-bottom: 6px; }
.field-pill { 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 { padding: 14px; border: 1px solid #e8edf6; border-radius: 16px; background: #fafcff; }
.field-card__head,.field-card__title,.switch-line,.color-picker-line { display: flex; align-items: center; }
.field-card__head { justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.field-card__title,.color-picker-line { gap: 8px; }
.field-add-button { margin-top: 14px; }
.cover-selector__preview,.image-uploader__preview { width: 100%; max-width: 320px; overflow: hidden; border: 1px solid #e7edf7; border-radius: 16px; background: #fff; }
.cover-selector__preview img,.image-uploader__preview img { display: block; width: 100%; height: auto; }
.cover-history-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.cover-history-card { padding: 10px; border: 1px solid #e7edf7; border-radius: 16px; background: #fff; }
.cover-history-card img { display: block; width: 100%; height: 140px; object-fit: cover; border-radius: 12px; }
.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; }
.color-input { width: 52px; height: 36px; border: none; background: transparent; padding: 0; }
@media (max-width: 1200px) { .preview-layout { grid-template-columns: 1fr; } }
@media (max-width: 768px) {
.preview-link-card__content,.cover-history-grid { grid-template-columns: 1fr; }
.preview-sidebar { position: static; }
.node-toolbar { align-items: flex-start; }
.node-editor__summary { flex-direction: column; }
.node-pill { min-width: 100%; max-width: none; }
.field-pill { min-width: 100%; max-width: none; }
.node-lane__title { align-items: flex-start; flex-direction: column; }
}
</style>
@@ -1,5 +1,84 @@
import type { TraceabilityApi } from '#/api';
let localIdSeed = 0;
export function createLocalUniqueId(prefix: string) {
localIdSeed += 1;
return `${prefix}_${Date.now()}_${localIdSeed}`;
}
export function isOssStoredValue(
value: any,
): value is TraceabilityApi.OssStoredValue {
return !!value
&& typeof value === 'object'
&& typeof value.bucketName === 'string'
&& typeof value.objectName === 'string';
}
export function parseStoredImageValue(value: any): any {
if (isOssStoredValue(value)) {
return value;
}
if (typeof value === 'string') {
const text = value.trim();
if (text.startsWith('{')) {
try {
const parsed = JSON.parse(text);
return isOssStoredValue(parsed) ? parsed : value;
} catch {
return value;
}
}
}
return value;
}
export function buildOssStoredValue(result: {
bucketName: string;
objectName: string;
tempUrl?: string;
}): TraceabilityApi.OssStoredValue {
return {
bucketName: result.bucketName,
objectName: result.objectName,
tempUrl: result.tempUrl,
};
}
export function stripOssTempUrl(value: any) {
const parsed = parseStoredImageValue(value);
if (!isOssStoredValue(parsed)) {
return value;
}
return {
bucketName: parsed.bucketName,
objectName: parsed.objectName,
};
}
export function serializeImageValue(value: any) {
const normalized = stripOssTempUrl(value);
if (isOssStoredValue(normalized)) {
return JSON.stringify(normalized);
}
return typeof normalized === 'string' ? normalized : '';
}
export function getImagePreviewSrc(value: any, previewUrl?: string) {
if (previewUrl) {
return previewUrl;
}
const parsed = parseStoredImageValue(value);
if (isOssStoredValue(parsed)) {
return parsed.tempUrl || '';
}
if (typeof parsed === 'string') {
return parsed;
}
return '';
}
export interface TraceabilityNodeLibraryItem {
id: string;
category: 'business' | 'public';
@@ -92,23 +171,33 @@ export function createField(
type,
required: false,
visible: true,
fixedPreset: false,
placeholder: '',
defaultValue: '',
options: [],
fieldStyle: {
bold: false,
color: '',
},
...extra,
};
}
export function createEmptyField(): TraceabilityApi.FieldDefinition {
return {
key: `field_${Date.now()}`,
key: createLocalUniqueId('field'),
label: '新字段',
type: 'string',
required: false,
visible: true,
fixedPreset: false,
placeholder: '',
defaultValue: '',
options: [],
fieldStyle: {
bold: false,
color: '',
},
};
}
@@ -116,6 +205,7 @@ export function createEmptyNode(
category: TraceabilityApi.TemplateNode['category'] = 'business',
): TraceabilityApi.TemplateNode {
return {
id: createLocalUniqueId('node'),
category,
name: category === 'public' ? '公开资料节点' : '业务流程节点',
description: '',
@@ -124,10 +214,26 @@ export function createEmptyNode(
};
}
export function createEmptyPreviewNode(
category: TraceabilityApi.PreviewNode['category'] = 'business',
): TraceabilityApi.PreviewNode {
const field = createEmptyField();
return {
id: createLocalUniqueId('preview-node'),
category,
name: category === 'public' ? '公开资料节点' : '业务流程节点',
description: '',
consumerVisible: true,
fields: [field],
values: { [field.key]: '' },
};
}
export function cloneNodeFromLibrary(
preset: TraceabilityNodeLibraryItem,
): TraceabilityApi.TemplateNode {
return {
id: createLocalUniqueId('node'),
category: preset.category,
name: preset.name,
description: preset.description,
@@ -136,6 +242,10 @@ export function cloneNodeFromLibrary(
fields: preset.fields.map((field) => ({
...field,
options: [...(field.options ?? [])],
fieldStyle: {
bold: field.fieldStyle?.bold ?? false,
color: field.fieldStyle?.color ?? '',
},
})),
};
}
@@ -148,7 +258,7 @@ export function cloneTemplateForSave(
description: template.description ?? '',
productName: template.productName ?? '',
industryName: template.industryName ?? '',
coverImage: template.coverImage ?? '',
coverImage: serializeImageValue(template.coverImage ?? ''),
themeColor: template.themeColor ?? '#1f4fd6',
status: template.status ?? 'draft',
nodes: (template.nodes ?? []).map((node) => ({
@@ -163,14 +273,74 @@ export function cloneTemplateForSave(
type: field.type ?? 'string',
required: field.required ?? false,
visible: field.visible ?? true,
fixedPreset: field.fixedPreset ?? false,
placeholder: field.placeholder ?? '',
defaultValue: field.defaultValue ?? '',
defaultValue:
field.type === 'image'
? stripOssTempUrl(field.defaultValue ?? '')
: field.defaultValue ?? '',
options: field.options ?? [],
fieldStyle: {
bold: field.fieldStyle?.bold ?? false,
color: field.fieldStyle?.color ?? '',
},
})),
})),
};
}
export function clonePreviewForSave(
preview: Partial<TraceabilityApi.PreviewPageDetail>,
) {
return {
name: preview.name ?? '',
description: preview.description ?? '',
productName: preview.productName ?? '',
coverImage: serializeImageValue(preview.coverImage ?? ''),
themeColor: preview.themeColor ?? '#1f4fd6',
tags: preview.tags ?? [],
nodes: (preview.nodes ?? []).map((node) => ({
category: node.category ?? 'business',
name: node.name ?? '',
description: node.description ?? '',
consumerVisible: node.consumerVisible ?? true,
values: Object.fromEntries(
Object.entries(node.values ?? {}).map(([key, value]) => [
key,
node.fields?.find((field) => field.key === key)?.type === 'image'
? stripOssTempUrl(value)
: value,
]),
),
fields: (node.fields ?? []).map((field) => ({
key: field.key,
label: field.label,
type: field.type ?? 'string',
required: field.required ?? false,
visible: field.visible ?? true,
fixedPreset: field.fixedPreset ?? false,
placeholder: field.placeholder ?? '',
defaultValue:
field.type === 'image'
? stripOssTempUrl(field.defaultValue ?? '')
: field.defaultValue ?? '',
options: field.options ?? [],
fieldStyle: {
bold: field.fieldStyle?.bold ?? false,
color: field.fieldStyle?.color ?? '',
},
})),
})),
};
}
export function getFieldDisplayStyle(field?: TraceabilityApi.FieldDefinition) {
return {
color: field?.fieldStyle?.color || undefined,
fontWeight: field?.fieldStyle?.bold ? '700' : undefined,
};
}
export function formatFieldValue(value: any) {
if (value === null || value === undefined || value === '') {
return '未填写';
@@ -179,6 +349,9 @@ export function formatFieldValue(value: any) {
return value.join('、');
}
if (typeof value === 'object') {
if (isOssStoredValue(value)) {
return value.objectName || '已上传图片';
}
try {
return JSON.stringify(value, null, 2);
} catch {