溯源系统初版

This commit is contained in:
BBIT-Kai
2026-04-10 18:51:00 +08:00
parent 5971791038
commit 0a43f5e4b9
40 changed files with 7910 additions and 30 deletions
@@ -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>