692 lines
18 KiB
Vue
692 lines
18 KiB
Vue
<script lang="ts" setup>
|
|
import type { TraceabilityApi } from '#/api';
|
|
|
|
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 {
|
|
buildCoordinateEmbedUrl,
|
|
buildCoordinateMapUrl,
|
|
formatFieldValue,
|
|
getFieldDisplayStyle,
|
|
getImagePreviewSrc,
|
|
} 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,
|
|
});
|
|
const qrDownloadName = computed(
|
|
() => `${detail.value?.batch.batchCode || 'batch'}.png`,
|
|
);
|
|
|
|
function downloadQrCode(dataUrl: string, fileName: string) {
|
|
if (!dataUrl) return;
|
|
const link = document.createElement('a');
|
|
link.href = dataUrl;
|
|
link.download = fileName;
|
|
document.body.append(link);
|
|
link.click();
|
|
link.remove();
|
|
}
|
|
|
|
function downloadConsumerQrCode() {
|
|
downloadQrCode(qrCode.value, qrDownloadName.value);
|
|
}
|
|
|
|
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 getTraceabilityPreviewDetail(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',
|
|
field: step.fields.find((field) => field.key === key),
|
|
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="二维码">
|
|
<template #extra>
|
|
<Button
|
|
v-if="publicLink"
|
|
size="small"
|
|
type="primary"
|
|
@click="downloadConsumerQrCode"
|
|
>
|
|
保存二维码
|
|
</Button>
|
|
</template>
|
|
<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="
|
|
getImagePreviewSrc(
|
|
entry.value,
|
|
item.valuePreviewUrls?.[entry.key],
|
|
)
|
|
"
|
|
:alt="entry.label"
|
|
class="consumer-image"
|
|
/>
|
|
<div
|
|
v-else-if="entry.type === 'coordinate' && entry.value"
|
|
class="coordinate-preview-card"
|
|
>
|
|
<iframe
|
|
v-if="buildCoordinateEmbedUrl(entry.value)"
|
|
:src="buildCoordinateEmbedUrl(entry.value)"
|
|
class="coordinate-preview-map"
|
|
loading="lazy"
|
|
referrerpolicy="no-referrer-when-downgrade"
|
|
:title="entry.label"
|
|
></iframe>
|
|
<strong :style="getFieldDisplayStyle(entry.field)">
|
|
{{ formatFieldValue(entry.value) }}
|
|
</strong>
|
|
<a
|
|
v-if="buildCoordinateMapUrl(entry.value)"
|
|
:href="buildCoordinateMapUrl(entry.value)"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>打开地图</a
|
|
>
|
|
</div>
|
|
<strong
|
|
v-else
|
|
:style="getFieldDisplayStyle(entry.field)"
|
|
>
|
|
{{ 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="
|
|
getImagePreviewSrc(
|
|
entry.value,
|
|
item.valuePreviewUrls?.[entry.key],
|
|
)
|
|
"
|
|
:alt="entry.label"
|
|
class="consumer-image"
|
|
/>
|
|
<div
|
|
v-else-if="entry.type === 'coordinate' && entry.value"
|
|
class="coordinate-preview-card"
|
|
>
|
|
<iframe
|
|
v-if="buildCoordinateEmbedUrl(entry.value)"
|
|
:src="buildCoordinateEmbedUrl(entry.value)"
|
|
class="coordinate-preview-map"
|
|
loading="lazy"
|
|
referrerpolicy="no-referrer-when-downgrade"
|
|
:title="entry.label"
|
|
></iframe>
|
|
<strong :style="getFieldDisplayStyle(entry.field)">
|
|
{{ formatFieldValue(entry.value) }}
|
|
</strong>
|
|
<a
|
|
v-if="buildCoordinateMapUrl(entry.value)"
|
|
:href="buildCoordinateMapUrl(entry.value)"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>打开地图</a
|
|
>
|
|
</div>
|
|
<strong v-else :style="getFieldDisplayStyle(entry.field)">
|
|
{{ 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;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.timeline-card strong {
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.coordinate-preview-card {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.coordinate-preview-map {
|
|
width: 100%;
|
|
min-height: 180px;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 14px;
|
|
background: #fff;
|
|
}
|
|
|
|
.coordinate-preview-card a {
|
|
color: #1d4ed8;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.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>
|