溯源系统初版
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,554 @@
|
||||
<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';
|
||||
|
||||
const loading = ref(false);
|
||||
const batches = ref<TraceabilityApi.BatchSummary[]>([]);
|
||||
const queryCode = ref('');
|
||||
const detail = ref<null | TraceabilityApi.PublicDetail>(null);
|
||||
|
||||
const publicLink = computed(() => detail.value?.batch.publicUrl ?? '');
|
||||
const qrCode = useQRCode(publicLink, {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 2,
|
||||
width: 220,
|
||||
});
|
||||
|
||||
async function loadBatches() {
|
||||
batches.value = await getTraceabilityBatches();
|
||||
if (!queryCode.value && batches.value[0]) {
|
||||
queryCode.value = batches.value[0].batchCode;
|
||||
await search();
|
||||
}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!queryCode.value.trim()) {
|
||||
message.warning('请输入批次编码');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await getTraceabilityPublicDetail(queryCode.value.trim());
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string) {
|
||||
if (status === 'completed') return '已完成';
|
||||
if (status === 'pending') return '进行中';
|
||||
if (status === 'published') return '已发布';
|
||||
if (status === 'draft') return '未发布';
|
||||
return status || '进行中';
|
||||
}
|
||||
|
||||
function getFieldLabel(
|
||||
fields: TraceabilityApi.FieldDefinition[],
|
||||
key: string,
|
||||
) {
|
||||
return fields.find((field) => field.key === key)?.label || key;
|
||||
}
|
||||
|
||||
function getDisplayEntries(step: TraceabilityApi.BatchStep) {
|
||||
return Object.entries(step.values).map(([key, value]) => ({
|
||||
key,
|
||||
label: getFieldLabel(step.fields, key),
|
||||
type: step.fields.find((field) => field.key === key)?.type || 'string',
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
onMounted(loadBatches);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="trace-consumer">
|
||||
<Card class="panel-card search-panel">
|
||||
<div class="search-panel__meta">
|
||||
<div>
|
||||
<span class="panel-kicker">消费者页预览</span>
|
||||
<h2>溯源信息预览</h2>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<Input
|
||||
v-model:value="queryCode"
|
||||
placeholder="请输入批次编码,如:TR-2026-000001"
|
||||
@press-enter="search"
|
||||
/>
|
||||
<Button type="primary" :loading="loading" @click="search">
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<template v-if="detail">
|
||||
<Row :gutter="[16, 16]" class="metrics-row">
|
||||
<Col :lg="6" :sm="12" :xs="24">
|
||||
<Card class="panel-card stat-panel">
|
||||
<span>批次名称</span>
|
||||
<strong>{{ detail.batch.batchName }}</strong>
|
||||
<small>{{ detail.batch.batchCode }}</small>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :lg="6" :sm="12" :xs="24">
|
||||
<Card class="panel-card stat-panel">
|
||||
<span>当前状态</span>
|
||||
<strong>{{ detail.batch.status }}</strong>
|
||||
<small>{{ detail.batch.productName || '未设置产品名称' }}</small>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :lg="6" :sm="12" :xs="24">
|
||||
<Card class="panel-card stat-panel">
|
||||
<span>扫码次数</span>
|
||||
<strong>{{ detail.batch.scanCount }}</strong>
|
||||
<small>{{ detail.batch.templateName }}</small>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :lg="6" :sm="12" :xs="24">
|
||||
<Card class="panel-card stat-panel">
|
||||
<span>公开节点数</span>
|
||||
<strong>{{ detail.publicSections.length }}</strong>
|
||||
<small>业务节点 {{ detail.businessSections.length }} 个</small>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="[16, 16]" class="feature-row">
|
||||
<Col :lg="8" :xs="24">
|
||||
<Card class="panel-card qr-panel" title="二维码">
|
||||
<div class="qr-wrap">
|
||||
<img :src="qrCode" alt="溯源二维码" class="qr-image" />
|
||||
<div class="qr-meta">
|
||||
<strong>扫码查看溯源页</strong>
|
||||
<p>{{ publicLink }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :lg="16" :xs="24">
|
||||
<Card class="panel-card access-panel" title="访问信息">
|
||||
<div class="access-layout">
|
||||
<div class="access-main">
|
||||
<div class="access-main__head">
|
||||
<span>消费者访问地址</span>
|
||||
<strong>{{ publicLink }}</strong>
|
||||
</div>
|
||||
<p>{{ detail.batch.summary || '该批次已完成发布,可直接用于消费者扫码访问。' }}</p>
|
||||
</div>
|
||||
<div class="access-meta">
|
||||
<div class="access-card">
|
||||
<span>发布时间</span>
|
||||
<strong>{{ detail.batch.publishedAt || '未发布' }}</strong>
|
||||
</div>
|
||||
<div class="access-card">
|
||||
<span>产品名称</span>
|
||||
<strong>{{ detail.batch.productName || '未设置产品名称' }}</strong>
|
||||
</div>
|
||||
<div class="access-card">
|
||||
<span>所属模板</span>
|
||||
<strong>{{ detail.batch.templateName }}</strong>
|
||||
</div>
|
||||
<div class="access-card">
|
||||
<span>标签</span>
|
||||
<strong>{{ detail.batch.tags.join('、') || '暂无标签' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="[16, 16]">
|
||||
<Col :lg="9" :xs="24">
|
||||
<Card class="panel-card" title="公开资料区">
|
||||
<div class="section-stack">
|
||||
<div
|
||||
v-for="item in detail.publicSections"
|
||||
:key="item.id"
|
||||
class="section-card"
|
||||
>
|
||||
<div class="section-card__head">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<Tag color="blue">公开</Tag>
|
||||
</div>
|
||||
<p>{{ item.description || '企业与资质信息展示区' }}</p>
|
||||
<div class="kv-grid">
|
||||
<div
|
||||
v-for="entry in getDisplayEntries(item)"
|
||||
:key="entry.key"
|
||||
class="kv-card"
|
||||
>
|
||||
<span>{{ entry.label }}</span>
|
||||
<img
|
||||
v-if="entry.type === 'image' && entry.value"
|
||||
:src="String(entry.value)"
|
||||
:alt="entry.label"
|
||||
class="consumer-image"
|
||||
/>
|
||||
<strong v-else>{{ formatFieldValue(entry.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col :lg="15" :xs="24">
|
||||
<Card class="panel-card" title="溯源时间轴">
|
||||
<div class="timeline">
|
||||
<div
|
||||
v-for="(item, index) in detail.businessSections"
|
||||
:key="item.id"
|
||||
class="timeline-item"
|
||||
>
|
||||
<div class="timeline-rail">
|
||||
<span class="dot"></span>
|
||||
<span
|
||||
v-if="index !== detail.businessSections.length - 1"
|
||||
class="line"
|
||||
></span>
|
||||
</div>
|
||||
<div class="timeline-card">
|
||||
<div class="timeline-card__head">
|
||||
<div>
|
||||
<h3>{{ item.name }}</h3>
|
||||
<p>{{ item.description || '流程记录' }}</p>
|
||||
</div>
|
||||
<Tag>{{ getStatusLabel(item.status) }}</Tag>
|
||||
</div>
|
||||
<div class="kv-grid">
|
||||
<div
|
||||
v-for="entry in getDisplayEntries(item)"
|
||||
:key="entry.key"
|
||||
class="kv-card"
|
||||
>
|
||||
<span>{{ entry.label }}</span>
|
||||
<img
|
||||
v-if="entry.type === 'image' && entry.value"
|
||||
:src="String(entry.value)"
|
||||
:alt="entry.label"
|
||||
class="consumer-image"
|
||||
/>
|
||||
<strong v-else>{{ formatFieldValue(entry.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</template>
|
||||
|
||||
<Card v-else class="panel-card empty-wrap">
|
||||
<Empty description="输入批次编码后查看消费者端预览" />
|
||||
</Card>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trace-consumer {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
background: linear-gradient(135deg, #ffffff, #f8fbff);
|
||||
}
|
||||
|
||||
.search-panel__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panel-kicker {
|
||||
display: inline-flex;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: #edf3ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-panel h2 {
|
||||
margin: 12px 0 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 360px) auto;
|
||||
gap: 12px;
|
||||
width: min(100%, 520px);
|
||||
}
|
||||
|
||||
.stat-panel span,
|
||||
.kv-card span,
|
||||
.access-card span {
|
||||
color: #7d8899;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-panel strong,
|
||||
.access-card strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 18px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.stat-panel small {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #98a2b3;
|
||||
}
|
||||
|
||||
.metrics-row :deep(.ant-col),
|
||||
.feature-row :deep(.ant-col) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.metrics-row .panel-card,
|
||||
.feature-row .panel-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stat-panel :deep(.ant-card-body),
|
||||
.access-panel :deep(.ant-card-body),
|
||||
.qr-panel :deep(.ant-card-body) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stat-panel {
|
||||
min-height: 138px;
|
||||
}
|
||||
|
||||
.qr-wrap {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: 16px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid #edf1f7;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.qr-meta {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-meta strong {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.qr-meta p {
|
||||
margin: 10px 0 0;
|
||||
color: #667085;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.access-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.access-main {
|
||||
border: 1px solid #dfe9fb;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, #f7faff, #eef4ff);
|
||||
padding: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.access-main__head {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.access-main span {
|
||||
color: #7d8899;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.access-main strong {
|
||||
display: block;
|
||||
color: #1d4ed8;
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.access-main p {
|
||||
margin: 14px 0 0;
|
||||
color: #5f6b7c;
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.access-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.access-card {
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 18px;
|
||||
background: #fafcff;
|
||||
padding: 16px;
|
||||
min-height: 108px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.access-card strong {
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.section-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-card,
|
||||
.timeline-card {
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-card__head,
|
||||
.timeline-card__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-card p,
|
||||
.timeline-card p {
|
||||
margin: 8px 0 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.kv-card {
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 14px;
|
||||
background: #fafcff;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.kv-card strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.consumer-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 220px;
|
||||
margin-top: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.timeline-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #1d4ed8;
|
||||
box-shadow: 0 0 0 5px rgba(29, 78, 216, 0.1);
|
||||
}
|
||||
|
||||
.line {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
min-height: 60px;
|
||||
margin-top: 8px;
|
||||
background: linear-gradient(180deg, #c9d8ff, transparent);
|
||||
}
|
||||
|
||||
.timeline-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-card__head strong,
|
||||
.timeline-card__head h3 {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.empty-wrap {
|
||||
padding: 48px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.search-box,
|
||||
.access-layout,
|
||||
.access-meta,
|
||||
.kv-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,868 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Empty,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Steps,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
createTraceabilityBatch,
|
||||
deleteTraceabilityBatch,
|
||||
getTraceabilityBatch,
|
||||
getTraceabilityBatches,
|
||||
getTraceabilityTemplates,
|
||||
publishTraceabilityBatch,
|
||||
uploadTraceabilityImage,
|
||||
updateTraceabilityBatchStep,
|
||||
} from '#/api';
|
||||
import type { TraceabilityApi } from '#/api';
|
||||
|
||||
import { formatFieldValue, getFieldTypeLabel, normalizeFieldInput } from './shared';
|
||||
|
||||
const loading = ref(false);
|
||||
const selectedBatchId = ref('');
|
||||
const templates = ref<TraceabilityApi.TemplateSummary[]>([]);
|
||||
const batches = ref<TraceabilityApi.BatchSummary[]>([]);
|
||||
const batchDetail = ref<null | TraceabilityApi.BatchDetail>(null);
|
||||
const stepIndex = ref(0);
|
||||
const editableStartIndex = ref(0);
|
||||
const createBatchVisible = ref(false);
|
||||
const formState = reactive({
|
||||
batchCode: '',
|
||||
batchName: '',
|
||||
coverImage: '',
|
||||
productName: '',
|
||||
summary: '',
|
||||
tagsText: '',
|
||||
templateId: '',
|
||||
});
|
||||
const saving = ref(false);
|
||||
const uploadingFieldKey = ref('');
|
||||
const publishedTemplates = computed(() =>
|
||||
templates.value.filter((item) => item.status === 'active'),
|
||||
);
|
||||
|
||||
const currentStep = computed(
|
||||
() => batchDetail.value?.steps?.[stepIndex.value] ?? null,
|
||||
);
|
||||
const isPublished = computed(() => batchDetail.value?.status === 'published');
|
||||
const isLockedStep = computed(() => !!currentStep.value?.locked);
|
||||
const actualCurrentStepIndex = computed(() =>
|
||||
isPublished.value ? (batchDetail.value?.currentStep ?? 0) : editableStartIndex.value,
|
||||
);
|
||||
const isCurrentEditableStep = computed(() => !!batchDetail.value && !isPublished.value);
|
||||
const isLastStep = computed(() => {
|
||||
if (!batchDetail.value?.steps?.length) return false;
|
||||
return actualCurrentStepIndex.value >= batchDetail.value.steps.length - 1;
|
||||
});
|
||||
const allStepsCompleted = computed(() =>
|
||||
(batchDetail.value?.steps ?? []).every((item) => item.status === 'completed'),
|
||||
);
|
||||
const stepActionText = computed(() =>
|
||||
isLastStep.value ? '保存并发布' : '保存并切换至下一节点',
|
||||
);
|
||||
|
||||
async function loadLists() {
|
||||
loading.value = true;
|
||||
try {
|
||||
templates.value = await getTraceabilityTemplates();
|
||||
batches.value = await getTraceabilityBatches();
|
||||
if (!selectedBatchId.value && batches.value[0]) {
|
||||
await selectBatch(batches.value[0].id);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyBatch(detail: TraceabilityApi.BatchDetail) {
|
||||
batchDetail.value = structuredClone(detail);
|
||||
formState.batchCode = detail.batchCode;
|
||||
formState.batchName = detail.batchName;
|
||||
formState.coverImage = detail.coverImage;
|
||||
formState.productName = detail.productName;
|
||||
formState.summary = detail.summary;
|
||||
formState.tagsText = detail.tags.join(',');
|
||||
formState.templateId = detail.templateId;
|
||||
stepIndex.value = detail.currentStep ?? 0;
|
||||
editableStartIndex.value = detail.currentStep ?? 0;
|
||||
}
|
||||
|
||||
async function selectBatch(id: string) {
|
||||
selectedBatchId.value = id;
|
||||
const detail = await getTraceabilityBatch(id);
|
||||
applyBatch(detail);
|
||||
}
|
||||
|
||||
function openCreateBatchModal() {
|
||||
if (publishedTemplates.value.length === 0) {
|
||||
message.warning('请先在管理员页发布模板后,再新建批次');
|
||||
return;
|
||||
}
|
||||
selectedBatchId.value = '';
|
||||
batchDetail.value = null;
|
||||
formState.batchCode = `TR-${new Date().getFullYear()}-${String(Date.now()).slice(-6)}`;
|
||||
formState.batchName = '新建批次';
|
||||
formState.coverImage = '';
|
||||
formState.productName = '';
|
||||
formState.summary = '';
|
||||
formState.tagsText = '';
|
||||
formState.templateId = publishedTemplates.value[0]?.id ?? '';
|
||||
stepIndex.value = 0;
|
||||
createBatchVisible.value = true;
|
||||
}
|
||||
|
||||
async function createBatch() {
|
||||
if (!formState.templateId) {
|
||||
message.warning('请先选择模板');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const detail = await createTraceabilityBatch({
|
||||
batchCode: formState.batchCode,
|
||||
batchName: formState.batchName,
|
||||
coverImage: formState.coverImage,
|
||||
productName: formState.productName,
|
||||
summary: formState.summary,
|
||||
tags: formState.tagsText
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
templateId: formState.templateId,
|
||||
});
|
||||
message.success('批次已创建');
|
||||
createBatchVisible.value = false;
|
||||
await loadLists();
|
||||
if (detail?.id) {
|
||||
await selectBatch(detail.id);
|
||||
} else if (batches.value[0]) {
|
||||
await selectBatch(batches.value[0].id);
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getBatchStatusText(status: string) {
|
||||
if (status === 'published') return '已发布';
|
||||
if (status === 'draft') return '未发布';
|
||||
return status || '进行中';
|
||||
}
|
||||
|
||||
function getStepStatusText(index: number) {
|
||||
if (allStepsCompleted.value) {
|
||||
return '已完成';
|
||||
}
|
||||
return index < actualCurrentStepIndex.value ? '已完成' : '进行中';
|
||||
}
|
||||
|
||||
function updateFieldValue(field: TraceabilityApi.FieldDefinition, value: any) {
|
||||
if (!currentStep.value) return;
|
||||
currentStep.value.values[field.key] = normalizeFieldInput(field, value);
|
||||
}
|
||||
|
||||
function sanitizeIntegerInput(value: string) {
|
||||
const cleaned = value.replaceAll(/[^\d-]/g, '');
|
||||
const hasLeadingMinus = cleaned.startsWith('-');
|
||||
const unsigned = hasLeadingMinus ? cleaned.slice(1).replaceAll('-', '') : cleaned.replaceAll('-', '');
|
||||
return hasLeadingMinus ? `-${unsigned}` : unsigned;
|
||||
}
|
||||
|
||||
function sanitizeDecimalInput(value: string) {
|
||||
const cleaned = value.replaceAll(/[^\d.-]/g, '');
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
const normalizedDot =
|
||||
firstDot === -1
|
||||
? cleaned
|
||||
: `${cleaned.slice(0, firstDot + 1)}${cleaned
|
||||
.slice(firstDot + 1)
|
||||
.replaceAll('.', '')}`;
|
||||
const firstMinus = normalizedDot.indexOf('-');
|
||||
return firstMinus <= 0
|
||||
? normalizedDot
|
||||
: `-${normalizedDot.replaceAll('-', '')}`;
|
||||
}
|
||||
|
||||
function getFieldUploadKey(field: TraceabilityApi.FieldDefinition) {
|
||||
return `${currentStep.value?.id ?? 'step'}:${field.key}`;
|
||||
}
|
||||
|
||||
function getFieldInputId(field: TraceabilityApi.FieldDefinition) {
|
||||
return `traceability-upload-${getFieldUploadKey(field)}`;
|
||||
}
|
||||
|
||||
function triggerImageSelect(field: TraceabilityApi.FieldDefinition) {
|
||||
const input = document.getElementById(getFieldInputId(field));
|
||||
input?.click();
|
||||
}
|
||||
|
||||
function clearImageValue(field: TraceabilityApi.FieldDefinition) {
|
||||
updateFieldValue(field, '');
|
||||
}
|
||||
|
||||
async function handleImageUpload(field: TraceabilityApi.FieldDefinition, event: Event) {
|
||||
const files = (event.target as HTMLInputElement).files;
|
||||
const file = files?.[0];
|
||||
if (!file || !currentStep.value || !selectedBatchId.value) return;
|
||||
|
||||
const uploadKey = getFieldUploadKey(field);
|
||||
uploadingFieldKey.value = uploadKey;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append(
|
||||
'objectDir',
|
||||
`traceability/${selectedBatchId.value}/${currentStep.value.id}/${field.key}`,
|
||||
);
|
||||
const result = await uploadTraceabilityImage(formData);
|
||||
updateFieldValue(field, result.tempUrl || result.objectName);
|
||||
message.success('图片上传成功');
|
||||
} catch {
|
||||
message.error('图片上传失败');
|
||||
} finally {
|
||||
uploadingFieldKey.value = '';
|
||||
(event.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeBatch(id: string) {
|
||||
Modal.confirm({
|
||||
title: '删除批次',
|
||||
content: '删除后该批次的填报记录和发布信息都会一起清除,是否继续?',
|
||||
async onOk() {
|
||||
await deleteTraceabilityBatch(id);
|
||||
message.success('批次已删除');
|
||||
if (selectedBatchId.value === id) {
|
||||
selectedBatchId.value = '';
|
||||
batchDetail.value = null;
|
||||
}
|
||||
await loadLists();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function saveStep() {
|
||||
if (!selectedBatchId.value || !currentStep.value) return;
|
||||
if (!isCurrentEditableStep.value || isPublished.value) {
|
||||
message.warning('请先完成当前进行中的节点');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const detail = await updateTraceabilityBatchStep(
|
||||
selectedBatchId.value,
|
||||
currentStep.value.id,
|
||||
{
|
||||
completedAt: new Date().toISOString(),
|
||||
operatorName: currentStep.value.operatorName,
|
||||
status: 'completed',
|
||||
values: currentStep.value.values,
|
||||
},
|
||||
);
|
||||
applyBatch(detail);
|
||||
if (detail.steps.every((item) => item.status === 'completed')) {
|
||||
const published = await publishTraceabilityBatch(selectedBatchId.value);
|
||||
applyBatch(published);
|
||||
message.success('最后一个节点已保存并发布');
|
||||
} else {
|
||||
const nextIndex = detail.currentStep ?? 0;
|
||||
editableStartIndex.value = nextIndex;
|
||||
stepIndex.value = nextIndex;
|
||||
message.success('当前节点已保存,已切换至下一节点');
|
||||
}
|
||||
await loadLists();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLists();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="trace-operator">
|
||||
<Row :gutter="[16, 16]">
|
||||
<Col :lg="7" :md="8" :sm="24" :xs="24">
|
||||
<Card :loading="loading" class="panel-card batch-panel-card" title="批次流程">
|
||||
<template #extra>
|
||||
<Button type="primary" @click="openCreateBatchModal">新建批次</Button>
|
||||
</template>
|
||||
<div class="batch-list">
|
||||
<button
|
||||
v-for="item in batches"
|
||||
:key="item.id"
|
||||
class="batch-card"
|
||||
:class="{ active: item.id === selectedBatchId }"
|
||||
@click="selectBatch(item.id)"
|
||||
>
|
||||
<div class="batch-card__header">
|
||||
<strong>{{ item.batchName }}</strong>
|
||||
<div class="batch-card__actions">
|
||||
<Tag>{{ getBatchStatusText(item.status) }}</Tag>
|
||||
<Button danger size="small" type="link" @click.stop="removeBatch(item.id)">
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p>{{ item.batchCode }}</p>
|
||||
<div class="batch-card__meta">
|
||||
<span>{{ item.templateName }}</span>
|
||||
<span>{{ item.productName || '未设置产品名称' }}</span>
|
||||
<span>扫码 {{ item.scanCount }} 次</span>
|
||||
</div>
|
||||
<small>{{ item.summary || '暂无批次概述' }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col :lg="17" :md="16" :sm="24" :xs="24">
|
||||
<Space direction="vertical" size="middle" style="width: 100%">
|
||||
<Card v-if="batchDetail?.publishedAt" class="panel-card" title="发布信息">
|
||||
<div class="publish-panel">
|
||||
<div>
|
||||
<span>溯源码</span>
|
||||
<strong>{{ batchDetail.batchCode }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>消费者访问地址</span>
|
||||
<strong>{{ batchDetail.publicUrl }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发布时间</span>
|
||||
<strong>{{ batchDetail.publishedAt || '未发布' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>当前状态</span>
|
||||
<strong>{{ getBatchStatusText(batchDetail.status) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="panel-card" title="节点填报">
|
||||
<template #extra>
|
||||
<Button
|
||||
v-if="!isPublished"
|
||||
type="primary"
|
||||
:disabled="!currentStep || !isCurrentEditableStep"
|
||||
:loading="saving"
|
||||
@click="saveStep"
|
||||
>
|
||||
{{ stepActionText }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template v-if="batchDetail">
|
||||
<Steps
|
||||
class="step-strip"
|
||||
:class="{ 'step-strip--published': isPublished }"
|
||||
:current="stepIndex"
|
||||
size="small"
|
||||
@change="
|
||||
(value) => {
|
||||
stepIndex = value;
|
||||
if (!isPublished) {
|
||||
editableStartIndex = value;
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<Steps.Step
|
||||
v-for="(item, index) in batchDetail.steps"
|
||||
:key="item.id"
|
||||
:title="item.name"
|
||||
:description="getStepStatusText(index)"
|
||||
/>
|
||||
</Steps>
|
||||
|
||||
<div
|
||||
v-if="currentStep"
|
||||
class="step-editor"
|
||||
:class="{ 'step-editor--published': isPublished }"
|
||||
>
|
||||
<div class="step-header">
|
||||
<div>
|
||||
<h3>{{ currentStep.name }}</h3>
|
||||
<p>{{ currentStep.description || '请填报此环节的过程记录。' }}</p>
|
||||
<small class="step-hint">
|
||||
{{
|
||||
isPublished
|
||||
? '当前批次已发布,溯源链已锁定为只读。'
|
||||
: isLockedStep
|
||||
? '当前节点来自节点库,字段和值固定,不可修改。'
|
||||
: isCurrentEditableStep
|
||||
? '当前节点可填写并继续流转。'
|
||||
: '当前查看的是非进行中节点,仅供浏览。'
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
<Tag color="blue">
|
||||
{{ currentStep.category === 'public' ? '公开节点' : '业务节点' }}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<Row :gutter="[16, 16]">
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">操作人</label>
|
||||
<Input v-model:value="currentStep.operatorName" :disabled="!isCurrentEditableStep" />
|
||||
</Col>
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">节点状态</label>
|
||||
<div class="readonly-box">{{ getStepStatusText(stepIndex) }}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="[16, 16]" class="dynamic-fields">
|
||||
<Col
|
||||
v-for="field in currentStep.fields"
|
||||
:key="field.key"
|
||||
:md="12"
|
||||
:xs="24"
|
||||
>
|
||||
<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>
|
||||
<div class="field-entry__body">
|
||||
<Select
|
||||
v-if="field.type === 'select'"
|
||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||||
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
|
||||
:value="currentStep.values[field.key]"
|
||||
style="width: 100%"
|
||||
@update:value="(value) => updateFieldValue(field, value)"
|
||||
/>
|
||||
<Select
|
||||
v-else-if="field.type === 'multi_select'"
|
||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||||
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
|
||||
:value="currentStep.values[field.key]"
|
||||
mode="multiple"
|
||||
style="width: 100%"
|
||||
@update:value="(value) => updateFieldValue(field, value)"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="field.type === 'integer'"
|
||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||||
:placeholder="field.placeholder || '请输入整数'"
|
||||
:value="String(currentStep.values[field.key] ?? '')"
|
||||
style="width: 100%"
|
||||
@update:value="
|
||||
(value) =>
|
||||
updateFieldValue(field, sanitizeIntegerInput(String(value ?? '')))
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="field.type === 'decimal'"
|
||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||||
:placeholder="field.placeholder || '请输入小数'"
|
||||
:value="String(currentStep.values[field.key] ?? '')"
|
||||
style="width: 100%"
|
||||
@update:value="
|
||||
(value) =>
|
||||
updateFieldValue(field, sanitizeDecimalInput(String(value ?? '')))
|
||||
"
|
||||
/>
|
||||
<DatePicker
|
||||
v-else-if="field.type === 'date'"
|
||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||||
:value="currentStep.values[field.key]"
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD"
|
||||
@update:value="(value) => updateFieldValue(field, value)"
|
||||
/>
|
||||
<DatePicker
|
||||
v-else-if="field.type === 'datetime'"
|
||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||||
:value="currentStep.values[field.key]"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
show-time
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
@update:value="(value) => updateFieldValue(field, value)"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="field.type === 'link'"
|
||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||||
: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"
|
||||
:placeholder="field.placeholder || '请输入内容'"
|
||||
:value="currentStep.values[field.key]"
|
||||
@update:value="(value) => updateFieldValue(field, value)"
|
||||
/>
|
||||
<div
|
||||
v-else-if="field.type === 'image'"
|
||||
class="placeholder-uploader"
|
||||
>
|
||||
<strong>上传节点图片</strong>
|
||||
<p>支持上传后直接回填图片地址,消费者端和预览页都可直接查看。</p>
|
||||
<div
|
||||
v-if="currentStep.values[field.key]"
|
||||
class="image-preview-wrap"
|
||||
>
|
||||
<img
|
||||
:src="String(currentStep.values[field.key])"
|
||||
alt="节点图片"
|
||||
class="image-preview"
|
||||
/>
|
||||
</div>
|
||||
<div class="upload-trigger">
|
||||
<input
|
||||
:id="getFieldInputId(field)"
|
||||
accept="image/*"
|
||||
class="upload-input"
|
||||
type="file"
|
||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||||
@change="(event) => handleImageUpload(field, event)"
|
||||
/>
|
||||
<Button
|
||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||||
:loading="uploadingFieldKey === getFieldUploadKey(field)"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="triggerImageSelect(field)"
|
||||
>
|
||||
{{ currentStep.values[field.key] ? '重新上传' : '选择图片' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="currentStep.values[field.key]"
|
||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||||
size="small"
|
||||
@click="clearImageValue(field)"
|
||||
>
|
||||
移除图片
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.type === 'video_url'"
|
||||
class="placeholder-uploader"
|
||||
>
|
||||
<strong>视频控件模板</strong>
|
||||
<p>这里先预留视频控件位置,后续你可以补充视频上传或选择逻辑。</p>
|
||||
</div>
|
||||
<Input.TextArea
|
||||
v-else
|
||||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||||
:placeholder="field.placeholder || '请输入内容'"
|
||||
:value="currentStep.values[field.key]"
|
||||
@update:value="(value) => updateFieldValue(field, value)"
|
||||
/>
|
||||
<div class="field-preview">
|
||||
当前值:{{ formatFieldValue(currentStep.values[field.key]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Empty v-else description="先新建批次或从左侧选择批次" />
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
v-model:open="createBatchVisible"
|
||||
title="新建批次"
|
||||
ok-text="创建批次"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="saving"
|
||||
@ok="createBatch"
|
||||
>
|
||||
<Row :gutter="[16, 16]">
|
||||
<Col :span="24">
|
||||
<label class="field-label">模板</label>
|
||||
<Select
|
||||
v-model:value="formState.templateId"
|
||||
:options="publishedTemplates.map((item) => ({ label: item.name, value: item.id }))"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Col>
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">批次名称</label>
|
||||
<Input v-model:value="formState.batchName" />
|
||||
</Col>
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">批次编码</label>
|
||||
<Input v-model:value="formState.batchCode" />
|
||||
</Col>
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">产品名称</label>
|
||||
<Input v-model:value="formState.productName" />
|
||||
</Col>
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">封面图</label>
|
||||
<Input v-model:value="formState.coverImage" />
|
||||
</Col>
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">标签</label>
|
||||
<Input v-model:value="formState.tagsText" placeholder="用逗号分隔" />
|
||||
</Col>
|
||||
<Col :span="24">
|
||||
<label class="field-label">批次概述</label>
|
||||
<Input.TextArea
|
||||
v-model:value="formState.summary"
|
||||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal>
|
||||
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trace-operator {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.batch-panel-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.batch-panel-card :deep(.ant-card-body) {
|
||||
height: calc(100% - 57px);
|
||||
}
|
||||
|
||||
.batch-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.batch-card {
|
||||
width: 100%;
|
||||
border: 1px solid #edf1f7;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.batch-card.active {
|
||||
border-color: #adc4ff;
|
||||
background: #f5f8ff;
|
||||
}
|
||||
|
||||
.batch-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.batch-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.batch-card p {
|
||||
margin: 10px 0 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.batch-card__meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.batch-card span,
|
||||
.batch-card small {
|
||||
color: #8b96a8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #556070;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-strip {
|
||||
margin-bottom: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.step-editor {
|
||||
border-top: 1px solid #f0f2f5;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.step-strip--published,
|
||||
.step-editor--published {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.step-header h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.step-header p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.step-hint {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.dynamic-fields {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.publish-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.publish-panel div,
|
||||
.field-entry__body,
|
||||
.readonly-box,
|
||||
.placeholder-uploader {
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.publish-panel span,
|
||||
.field-entry__body small,
|
||||
.field-modal__meta {
|
||||
color: #7d8899;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.publish-panel strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 16px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.readonly-box {
|
||||
min-height: 54px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #556070;
|
||||
}
|
||||
|
||||
.field-entry {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-entry__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field-entry__body {
|
||||
min-height: 120px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-entry__body strong {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
color: #1f2937;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.field-type-tag {
|
||||
display: inline-flex;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #edf3ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.placeholder-uploader strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
display: inline-flex;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.upload-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.image-preview-wrap {
|
||||
margin: 4px 0 2px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 220px;
|
||||
border: 1px solid #dbe3f0;
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.field-preview {
|
||||
color: #7d8899;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.placeholder-uploader p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { TraceabilityApi } from '#/api';
|
||||
|
||||
export interface TraceabilityNodeLibraryItem {
|
||||
id: string;
|
||||
category: 'business' | 'public';
|
||||
name: string;
|
||||
description: string;
|
||||
consumerVisible: boolean;
|
||||
fields: TraceabilityApi.FieldDefinition[];
|
||||
}
|
||||
|
||||
export 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' },
|
||||
];
|
||||
|
||||
export const nodeLibraryPresets: TraceabilityNodeLibraryItem[] = [
|
||||
{
|
||||
id: 'business-production',
|
||||
category: 'business',
|
||||
name: '生产加工节点',
|
||||
description: '记录原料、工艺、加工批次等业务过程信息。',
|
||||
consumerVisible: true,
|
||||
fields: [
|
||||
createField('process_name', '工艺名称'),
|
||||
createField('operator', '负责人'),
|
||||
createField('production_date', '生产日期', 'date'),
|
||||
createField('remark', '备注'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'business-quality',
|
||||
category: 'business',
|
||||
name: '质检检验节点',
|
||||
description: '记录质检结果、检验员和检验时间。',
|
||||
consumerVisible: true,
|
||||
fields: [
|
||||
createField('inspector', '检验员'),
|
||||
createField('inspection_date', '检验日期', 'date'),
|
||||
createField('inspection_result', '检验结果', 'select', {
|
||||
options: ['合格', '不合格', '复检中'],
|
||||
}),
|
||||
createField('inspection_note', '检验说明'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'public-company',
|
||||
category: 'public',
|
||||
name: '企业信息节点',
|
||||
description: '面向消费者展示企业名称、产地和联系方式等信息。',
|
||||
consumerVisible: true,
|
||||
fields: [
|
||||
createField('company_name', '企业名称'),
|
||||
createField('origin', '产地'),
|
||||
createField('contact_phone', '联系电话'),
|
||||
createField('company_intro', '企业简介'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'public-certification',
|
||||
category: 'public',
|
||||
name: '资质证书节点',
|
||||
description: '展示认证证书、证书编号和有效期。',
|
||||
consumerVisible: true,
|
||||
fields: [
|
||||
createField('certificate_name', '证书名称'),
|
||||
createField('certificate_no', '证书编号'),
|
||||
createField('valid_until', '有效期', 'date'),
|
||||
createField('certificate_image', '证书图片', 'image'),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function createField(
|
||||
key: string,
|
||||
label: string,
|
||||
type: string = 'string',
|
||||
extra: Partial<TraceabilityApi.FieldDefinition> = {},
|
||||
): TraceabilityApi.FieldDefinition {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
type,
|
||||
required: false,
|
||||
visible: true,
|
||||
placeholder: '',
|
||||
defaultValue: '',
|
||||
options: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyField(): TraceabilityApi.FieldDefinition {
|
||||
return {
|
||||
key: `field_${Date.now()}`,
|
||||
label: '新字段',
|
||||
type: 'string',
|
||||
required: false,
|
||||
visible: true,
|
||||
placeholder: '',
|
||||
defaultValue: '',
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyNode(
|
||||
category: TraceabilityApi.TemplateNode['category'] = 'business',
|
||||
): TraceabilityApi.TemplateNode {
|
||||
return {
|
||||
category,
|
||||
name: category === 'public' ? '公开资料节点' : '业务流程节点',
|
||||
description: '',
|
||||
consumerVisible: true,
|
||||
fields: [createEmptyField()],
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneNodeFromLibrary(
|
||||
preset: TraceabilityNodeLibraryItem,
|
||||
): TraceabilityApi.TemplateNode {
|
||||
return {
|
||||
category: preset.category,
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
locked: true,
|
||||
consumerVisible: preset.consumerVisible,
|
||||
fields: preset.fields.map((field) => ({
|
||||
...field,
|
||||
options: [...(field.options ?? [])],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneTemplateForSave(
|
||||
template: Partial<TraceabilityApi.TemplateDetail>,
|
||||
) {
|
||||
return {
|
||||
name: template.name ?? '',
|
||||
description: template.description ?? '',
|
||||
productName: template.productName ?? '',
|
||||
industryName: template.industryName ?? '',
|
||||
coverImage: template.coverImage ?? '',
|
||||
themeColor: template.themeColor ?? '#1f4fd6',
|
||||
status: template.status ?? 'draft',
|
||||
nodes: (template.nodes ?? []).map((node) => ({
|
||||
category: node.category ?? 'business',
|
||||
name: node.name ?? '',
|
||||
description: node.description ?? '',
|
||||
locked: node.locked ?? false,
|
||||
consumerVisible: node.consumerVisible ?? true,
|
||||
fields: (node.fields ?? []).map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
type: field.type ?? 'string',
|
||||
required: field.required ?? false,
|
||||
visible: field.visible ?? true,
|
||||
placeholder: field.placeholder ?? '',
|
||||
defaultValue: field.defaultValue ?? '',
|
||||
options: field.options ?? [],
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatFieldValue(value: any) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '未填写';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.join('、');
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function normalizeFieldInput(
|
||||
field: TraceabilityApi.FieldDefinition,
|
||||
value: any,
|
||||
) {
|
||||
if (field.type === 'integer') {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
return Number.isInteger(value) ? value : Math.trunc(Number(value));
|
||||
}
|
||||
if (field.type === 'decimal') {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
if (field.type === 'multi_select') {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getFieldTypeLabel(type: string) {
|
||||
return fieldTypeOptions.find((item) => item.value === type)?.label ?? type;
|
||||
}
|
||||
|
||||
export const groupedNodeLibrary = {
|
||||
business: nodeLibraryPresets
|
||||
.filter((item) => item.category === 'business')
|
||||
.map((item) => item),
|
||||
public: nodeLibraryPresets
|
||||
.filter((item) => item.category === 'public')
|
||||
.map((item) => item),
|
||||
};
|
||||
Reference in New Issue
Block a user