修复溯源模块大量问题
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user