增加溯源功能

This commit is contained in:
BBIT-Kai
2026-04-17 17:49:19 +08:00
parent a23355ac5b
commit ca6cdb8499
6 changed files with 1077 additions and 269 deletions
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { TraceabilityApi } from '#/api';
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
@@ -20,6 +20,7 @@ import {
import { getTraceabilityBatches, getTraceabilityPreviewDetail } from '#/api';
import {
buildColoredQrDataUrl,
buildCoordinateEmbedUrl,
buildCoordinateMapUrl,
formatFieldValue,
@@ -38,9 +39,27 @@ const qrCode = useQRCode(publicLink, {
margin: 2,
width: 220,
});
const qrDownloadName = computed(
() => `${detail.value?.batch.batchCode || 'batch'}.png`,
const qrColor = ref('#000000');
const customQrCode = ref('');
function buildQrFileName(
code?: string,
name?: string,
fallback: string = 'batch',
) {
const raw =
[code?.trim(), name?.trim()].filter(Boolean).join(' - ') || fallback;
return `${raw.replaceAll(/[\\/:*?"<>|]/g, '_')}.png`;
}
const qrDownloadName = computed(() =>
buildQrFileName(
detail.value?.batch.batchCode,
detail.value?.batch.batchName,
'batch',
),
);
const displayedQrCode = computed(() => customQrCode.value || qrCode.value);
function downloadQrCode(dataUrl: string, fileName: string) {
if (!dataUrl) return;
@@ -53,9 +72,26 @@ function downloadQrCode(dataUrl: string, fileName: string) {
}
function downloadConsumerQrCode() {
downloadQrCode(qrCode.value, qrDownloadName.value);
downloadQrCode(displayedQrCode.value, qrDownloadName.value);
}
async function generateConsumerQrCode() {
customQrCode.value = qrCode.value
? await buildColoredQrDataUrl(qrCode.value, {
color: qrColor.value,
})
: '';
}
watch(
qrCode,
() => {
customQrCode.value = '';
qrColor.value = '#000000';
},
{ immediate: true },
);
async function loadBatches() {
batches.value = await getTraceabilityBatches();
if (!queryCode.value && batches.value[0]) {
@@ -160,17 +196,26 @@ onMounted(loadBatches);
<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>
<div v-if="publicLink" class="qr-panel__actions">
<input
v-model="qrColor"
class="qr-color-input"
type="color"
/>
<Button size="small" @click="generateConsumerQrCode">
生成二维码
</Button>
<Button
size="small"
type="primary"
@click="downloadConsumerQrCode"
>
保存二维码
</Button>
</div>
</template>
<div class="qr-wrap">
<img :src="qrCode" alt="溯源二维码" class="qr-image" />
<img :src="displayedQrCode" alt="溯源二维码" class="qr-image" />
<div class="qr-meta">
<strong>扫码查看溯源页</strong>
<p>{{ publicLink }}</p>
@@ -272,13 +317,9 @@ onMounted(loadBatches);
:href="buildCoordinateMapUrl(entry.value)"
target="_blank"
rel="noreferrer"
>打开地图</a
>
>打开地图</a>
</div>
<strong
v-else
:style="getFieldDisplayStyle(entry.field)"
>
<strong v-else :style="getFieldDisplayStyle(entry.field)">
{{ formatFieldValue(entry.value) }}
</strong>
</div>
@@ -349,10 +390,12 @@ onMounted(loadBatches);
:href="buildCoordinateMapUrl(entry.value)"
target="_blank"
rel="noreferrer"
>打开地图</a
>
>打开地图</a>
</div>
<strong v-else :style="getFieldDisplayStyle(entry.field)">
<strong
v-else
:style="getFieldDisplayStyle(entry.field)"
>
{{ formatFieldValue(entry.value) }}
</strong>
</div>
@@ -459,6 +502,21 @@ onMounted(loadBatches);
min-height: 138px;
}
.qr-panel :deep(.ant-card-head-wrapper) {
align-items: center;
}
.qr-panel :deep(.ant-card-extra) {
padding-inline-start: 12px;
}
.qr-panel__actions {
display: flex;
align-items: center;
gap: 8px;
padding-inline: 4px;
}
.qr-wrap {
display: grid;
align-content: center;
@@ -616,6 +674,16 @@ onMounted(loadBatches);
background: #fff;
}
.qr-color-input {
width: 36px;
height: 32px;
padding: 0;
border: 1px solid #d7e1f0;
border-radius: 8px;
background: #fff;
flex: 0 0 auto;
}
.coordinate-preview-card {
display: grid;
gap: 10px;
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { TraceabilityApi } from '#/api';
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import {
@@ -35,6 +35,7 @@ import {
import CoordinateFieldEditor from './components/CoordinateFieldEditor.vue';
import {
buildOssStoredValue,
buildColoredQrDataUrl,
formatFieldValue,
getFieldTypeLabel,
getImagePreviewSrc,
@@ -113,9 +114,18 @@ const allStepsCompleted = computed(() =>
const canEditBaseInfo = computed(() => !isPublished.value || batchEditMode.value);
const canPublishBatch = computed(() => !isPublished.value && allStepsCompleted.value);
const publishButtonText = computed(() => (isPublished.value ? '更新批次' : '发布批次'));
const batchQrDownloadName = computed(
() => `${batchDetail.value?.batchCode || 'batch'}.png`,
function buildQrFileName(code?: string, name?: string, fallback: string = 'batch') {
const raw = [code?.trim(), name?.trim()].filter(Boolean).join(' - ') || fallback;
return `${raw.replace(/[\\/:*?"<>|]/g, '_')}.png`;
}
const batchQrDownloadName = computed(() =>
buildQrFileName(batchDetail.value?.batchCode, batchDetail.value?.batchName, 'batch'),
);
const qrColor = ref('#000000');
const customBatchQrCode = ref('');
const displayedBatchQrCode = computed(() => customBatchQrCode.value || batchQrCode.value);
const hasBaseInfoChanges = computed(() => {
if (!selectedBatchId.value || !batchDetail.value) return false;
@@ -180,9 +190,26 @@ function downloadQrCode(dataUrl: string, fileName: string) {
}
function downloadBatchQrCode() {
downloadQrCode(batchQrCode.value, batchQrDownloadName.value);
downloadQrCode(displayedBatchQrCode.value, batchQrDownloadName.value);
}
async function generateBatchQrCode() {
customBatchQrCode.value = batchQrCode.value
? await buildColoredQrDataUrl(batchQrCode.value, {
color: qrColor.value,
})
: '';
}
watch(
batchQrCode,
() => {
customBatchQrCode.value = '';
qrColor.value = '#000000';
},
{ immediate: true },
);
async function loadLists() {
loading.value = true;
try {
@@ -731,6 +758,19 @@ onMounted(async () => {
>
<template #extra>
<Space>
<input
v-if="batchDetail.publicUrl"
v-model="qrColor"
class="qr-color-input"
type="color"
/>
<Button
v-if="batchDetail.publicUrl"
size="small"
@click="generateBatchQrCode"
>
生成二维码
</Button>
<Button
v-if="batchDetail.publicUrl"
size="small"
@@ -769,7 +809,7 @@ onMounted(async () => {
<div class="publish-qr">
<img
v-if="batchDetail.publicUrl"
:src="batchQrCode"
:src="displayedBatchQrCode"
alt="批次二维码"
/>
<Empty v-else description="暂无可下载二维码" />
@@ -1427,6 +1467,15 @@ onMounted(async () => {
padding: 10px;
}
.qr-color-input {
width: 36px;
height: 32px;
padding: 0;
border: 1px solid #d7e1f0;
border-radius: 8px;
background: #fff;
}
.readonly-box {
min-height: 54px;
display: flex;
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import { Button, Card, Col, DatePicker, Empty, Input, message, Modal, Row, Select, Space, Switch, Tabs, Tag } from 'ant-design-vue';
import {
@@ -15,7 +15,7 @@ import {
} from '#/api';
import type { TraceabilityApi } from '#/api';
import CoordinateFieldEditor from './components/CoordinateFieldEditor.vue';
import { buildOssStoredValue, clonePreviewForSave, createEmptyField, createEmptyPreviewNode, fieldTypeOptions, getFieldTypeLabel, getImagePreviewSrc, normalizeFieldInput } from './shared';
import { buildColoredQrDataUrl, buildOssStoredValue, clonePreviewForSave, createEmptyField, createEmptyPreviewNode, fieldTypeOptions, getFieldTypeLabel, getImagePreviewSrc, normalizeFieldInput } from './shared';
const previews = ref<TraceabilityApi.PreviewPageSummary[]>([]);
const selectedPreviewId = ref('');
@@ -42,7 +42,16 @@ const editor = reactive<Partial<TraceabilityApi.PreviewPageDetail> & { coverImag
themeColor: '#1f4fd6', tags: [], publicUrl: '', updatedAt: '', nodes: [],
});
const qrCode = useQRCode(computed(() => editor.publicUrl || ''), { errorCorrectionLevel: 'M', margin: 1, width: 220 });
const qrDownloadName = computed(() => `${editor.previewCode || editor.publicUrl || editor.name || 'preview'}.png`);
const qrColor = ref('#000000');
const customQrCode = ref('');
function buildQrFileName(code?: string, name?: string, fallback: string = 'preview') {
const raw = [code?.trim(), name?.trim()].filter(Boolean).join(' - ') || fallback;
return `${raw.replace(/[\\/:*?"<>|]/g, '_')}.png`;
}
const qrDownloadName = computed(() => buildQrFileName(editor.previewCode, editor.name, 'preview'));
const displayedQrCode = computed(() => customQrCode.value || qrCode.value);
function downloadQrCode(dataUrl: string, fileName: string) {
if (!dataUrl) return;
@@ -386,8 +395,25 @@ async function handleTemplateCoverUpload(event: Event) {
}
function downloadPreviewQrCode() {
downloadQrCode(qrCode.value, qrDownloadName.value);
downloadQrCode(displayedQrCode.value, qrDownloadName.value);
}
async function generatePreviewQrCode() {
customQrCode.value = qrCode.value
? await buildColoredQrDataUrl(qrCode.value, {
color: qrColor.value,
})
: '';
}
watch(
qrCode,
() => {
customQrCode.value = '';
qrColor.value = '#000000';
},
{ immediate: true },
);
onMounted(loadPreviews);
</script>
@@ -465,11 +491,15 @@ onMounted(loadPreviews);
</div>
<div class="preview-link-card__qr">
<div class="preview-link-card__qr-head">
<input v-if="editor.publicUrl" v-model="qrColor" class="qr-color-input" type="color">
<Button v-if="editor.publicUrl" size="small" @click="generatePreviewQrCode">
生成二维码
</Button>
<Button v-if="editor.publicUrl" size="small" type="primary" @click="downloadPreviewQrCode">
保存二维码
</Button>
</div>
<img v-if="editor.publicUrl" :src="qrCode" alt="预演二维码">
<img v-if="editor.publicUrl" :src="displayedQrCode" alt="预演二维码">
<Empty v-else description="保存后生成二维码" />
</div>
</div>
@@ -722,6 +752,7 @@ onMounted(loadPreviews);
.preview-link-card__qr { position: relative; flex-direction: column; gap: 12px; padding: 12px; }
.preview-link-card__qr-head { display: flex; justify-content: flex-end; width: 100%; }
.preview-link-card__qr img { width: 220px; height: 220px; }
.qr-color-input { width: 36px; height: 32px; padding: 0; border: 1px solid #d7e1f0; border-radius: 8px; background: #fff; }
.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; }
@@ -430,6 +430,69 @@ export function getFieldDisplayStyle(field?: TraceabilityApi.FieldDefinition) {
};
}
export async function buildColoredQrDataUrl(
source: string,
options: {
color?: string;
backgroundColor?: string;
} = {},
) {
const dataUrl = source.trim();
if (!dataUrl) {
return '';
}
const color = options.color || '#000000';
const backgroundColor = options.backgroundColor || '#ffffff';
return await new Promise<string>((resolve) => {
const image = new Image();
image.onload = () => {
const canvas = document.createElement('canvas');
const width = image.naturalWidth || image.width || 220;
const height = image.naturalHeight || image.height || 220;
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
resolve(dataUrl);
return;
}
context.clearRect(0, 0, width, height);
context.fillStyle = backgroundColor;
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const qrImageData = context.getImageData(0, 0, width, height);
for (let index = 0; index < qrImageData.data.length; index += 4) {
const alpha = qrImageData.data[index + 3];
const isDark =
alpha > 0
&& qrImageData.data[index] < 200
&& qrImageData.data[index + 1] < 200
&& qrImageData.data[index + 2] < 200;
if (isDark) {
qrImageData.data[index] = Number.parseInt(color.slice(1, 3), 16);
qrImageData.data[index + 1] = Number.parseInt(color.slice(3, 5), 16);
qrImageData.data[index + 2] = Number.parseInt(color.slice(5, 7), 16);
qrImageData.data[index + 3] = 255;
} else {
qrImageData.data[index] = 255;
qrImageData.data[index + 1] = 255;
qrImageData.data[index + 2] = 255;
qrImageData.data[index + 3] = 255;
}
}
context.putImageData(qrImageData, 0, 0);
resolve(canvas.toDataURL('image/png'));
};
image.onerror = () => resolve(dataUrl);
image.src = dataUrl;
});
}
export function formatFieldValue(value: any) {
if (value === null || value === undefined || value === '') {
return '未填写';