溯源系统初版
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user