增加溯源功能

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> <script lang="ts" setup>
import type { TraceabilityApi } from '#/api'; import type { TraceabilityApi } from '#/api';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
@@ -20,6 +20,7 @@ import {
import { getTraceabilityBatches, getTraceabilityPreviewDetail } from '#/api'; import { getTraceabilityBatches, getTraceabilityPreviewDetail } from '#/api';
import { import {
buildColoredQrDataUrl,
buildCoordinateEmbedUrl, buildCoordinateEmbedUrl,
buildCoordinateMapUrl, buildCoordinateMapUrl,
formatFieldValue, formatFieldValue,
@@ -38,9 +39,27 @@ const qrCode = useQRCode(publicLink, {
margin: 2, margin: 2,
width: 220, width: 220,
}); });
const qrDownloadName = computed( const qrColor = ref('#000000');
() => `${detail.value?.batch.batchCode || 'batch'}.png`, 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) { function downloadQrCode(dataUrl: string, fileName: string) {
if (!dataUrl) return; if (!dataUrl) return;
@@ -53,9 +72,26 @@ function downloadQrCode(dataUrl: string, fileName: string) {
} }
function downloadConsumerQrCode() { 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() { async function loadBatches() {
batches.value = await getTraceabilityBatches(); batches.value = await getTraceabilityBatches();
if (!queryCode.value && batches.value[0]) { if (!queryCode.value && batches.value[0]) {
@@ -160,17 +196,26 @@ onMounted(loadBatches);
<Col :lg="8" :xs="24"> <Col :lg="8" :xs="24">
<Card class="panel-card qr-panel" title="二维码"> <Card class="panel-card qr-panel" title="二维码">
<template #extra> <template #extra>
<Button <div v-if="publicLink" class="qr-panel__actions">
v-if="publicLink" <input
size="small" v-model="qrColor"
type="primary" class="qr-color-input"
@click="downloadConsumerQrCode" type="color"
> />
保存二维码 <Button size="small" @click="generateConsumerQrCode">
</Button> 生成二维码
</Button>
<Button
size="small"
type="primary"
@click="downloadConsumerQrCode"
>
保存二维码
</Button>
</div>
</template> </template>
<div class="qr-wrap"> <div class="qr-wrap">
<img :src="qrCode" alt="溯源二维码" class="qr-image" /> <img :src="displayedQrCode" alt="溯源二维码" class="qr-image" />
<div class="qr-meta"> <div class="qr-meta">
<strong>扫码查看溯源页</strong> <strong>扫码查看溯源页</strong>
<p>{{ publicLink }}</p> <p>{{ publicLink }}</p>
@@ -272,13 +317,9 @@ onMounted(loadBatches);
:href="buildCoordinateMapUrl(entry.value)" :href="buildCoordinateMapUrl(entry.value)"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
>打开地图</a >打开地图</a>
>
</div> </div>
<strong <strong v-else :style="getFieldDisplayStyle(entry.field)">
v-else
:style="getFieldDisplayStyle(entry.field)"
>
{{ formatFieldValue(entry.value) }} {{ formatFieldValue(entry.value) }}
</strong> </strong>
</div> </div>
@@ -349,10 +390,12 @@ onMounted(loadBatches);
:href="buildCoordinateMapUrl(entry.value)" :href="buildCoordinateMapUrl(entry.value)"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
>打开地图</a >打开地图</a>
>
</div> </div>
<strong v-else :style="getFieldDisplayStyle(entry.field)"> <strong
v-else
:style="getFieldDisplayStyle(entry.field)"
>
{{ formatFieldValue(entry.value) }} {{ formatFieldValue(entry.value) }}
</strong> </strong>
</div> </div>
@@ -459,6 +502,21 @@ onMounted(loadBatches);
min-height: 138px; 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 { .qr-wrap {
display: grid; display: grid;
align-content: center; align-content: center;
@@ -616,6 +674,16 @@ onMounted(loadBatches);
background: #fff; 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 { .coordinate-preview-card {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { TraceabilityApi } from '#/api'; 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 { useQRCode } from '@vueuse/integrations/useQRCode';
import { import {
@@ -35,6 +35,7 @@ import {
import CoordinateFieldEditor from './components/CoordinateFieldEditor.vue'; import CoordinateFieldEditor from './components/CoordinateFieldEditor.vue';
import { import {
buildOssStoredValue, buildOssStoredValue,
buildColoredQrDataUrl,
formatFieldValue, formatFieldValue,
getFieldTypeLabel, getFieldTypeLabel,
getImagePreviewSrc, getImagePreviewSrc,
@@ -113,9 +114,18 @@ const allStepsCompleted = computed(() =>
const canEditBaseInfo = computed(() => !isPublished.value || batchEditMode.value); const canEditBaseInfo = computed(() => !isPublished.value || batchEditMode.value);
const canPublishBatch = computed(() => !isPublished.value && allStepsCompleted.value); const canPublishBatch = computed(() => !isPublished.value && allStepsCompleted.value);
const publishButtonText = computed(() => (isPublished.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(() => { const hasBaseInfoChanges = computed(() => {
if (!selectedBatchId.value || !batchDetail.value) return false; if (!selectedBatchId.value || !batchDetail.value) return false;
@@ -180,9 +190,26 @@ function downloadQrCode(dataUrl: string, fileName: string) {
} }
function downloadBatchQrCode() { 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() { async function loadLists() {
loading.value = true; loading.value = true;
try { try {
@@ -731,6 +758,19 @@ onMounted(async () => {
> >
<template #extra> <template #extra>
<Space> <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 <Button
v-if="batchDetail.publicUrl" v-if="batchDetail.publicUrl"
size="small" size="small"
@@ -769,7 +809,7 @@ onMounted(async () => {
<div class="publish-qr"> <div class="publish-qr">
<img <img
v-if="batchDetail.publicUrl" v-if="batchDetail.publicUrl"
:src="batchQrCode" :src="displayedBatchQrCode"
alt="批次二维码" alt="批次二维码"
/> />
<Empty v-else description="暂无可下载二维码" /> <Empty v-else description="暂无可下载二维码" />
@@ -1427,6 +1467,15 @@ onMounted(async () => {
padding: 10px; padding: 10px;
} }
.qr-color-input {
width: 36px;
height: 32px;
padding: 0;
border: 1px solid #d7e1f0;
border-radius: 8px;
background: #fff;
}
.readonly-box { .readonly-box {
min-height: 54px; min-height: 54px;
display: flex; display: flex;
@@ -1,5 +1,5 @@
<script lang="ts" setup> <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 { 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 { Button, Card, Col, DatePicker, Empty, Input, message, Modal, Row, Select, Space, Switch, Tabs, Tag } from 'ant-design-vue';
import { import {
@@ -15,7 +15,7 @@ import {
} from '#/api'; } from '#/api';
import type { TraceabilityApi } from '#/api'; import type { TraceabilityApi } from '#/api';
import CoordinateFieldEditor from './components/CoordinateFieldEditor.vue'; 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 previews = ref<TraceabilityApi.PreviewPageSummary[]>([]);
const selectedPreviewId = ref(''); const selectedPreviewId = ref('');
@@ -42,7 +42,16 @@ const editor = reactive<Partial<TraceabilityApi.PreviewPageDetail> & { coverImag
themeColor: '#1f4fd6', tags: [], publicUrl: '', updatedAt: '', nodes: [], themeColor: '#1f4fd6', tags: [], publicUrl: '', updatedAt: '', nodes: [],
}); });
const qrCode = useQRCode(computed(() => editor.publicUrl || ''), { errorCorrectionLevel: 'M', margin: 1, width: 220 }); 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) { function downloadQrCode(dataUrl: string, fileName: string) {
if (!dataUrl) return; if (!dataUrl) return;
@@ -386,8 +395,25 @@ async function handleTemplateCoverUpload(event: Event) {
} }
function downloadPreviewQrCode() { 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); onMounted(loadPreviews);
</script> </script>
@@ -465,11 +491,15 @@ onMounted(loadPreviews);
</div> </div>
<div class="preview-link-card__qr"> <div class="preview-link-card__qr">
<div class="preview-link-card__qr-head"> <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 v-if="editor.publicUrl" size="small" type="primary" @click="downloadPreviewQrCode">
保存二维码 保存二维码
</Button> </Button>
</div> </div>
<img v-if="editor.publicUrl" :src="qrCode" alt="预演二维码"> <img v-if="editor.publicUrl" :src="displayedQrCode" alt="预演二维码">
<Empty v-else description="保存后生成二维码" /> <Empty v-else description="保存后生成二维码" />
</div> </div>
</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 { 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-head { display: flex; justify-content: flex-end; width: 100%; }
.preview-link-card__qr img { width: 220px; height: 220px; } .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 { 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 { 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; } .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) { export function formatFieldValue(value: any) {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return '未填写'; return '未填写';
+1 -1
View File
@@ -1,7 +1,7 @@
# push_docker.ps1 # push_docker.ps1
# Set version # Set version
$env:VERSION = "1.5.4" $env:VERSION = "1.5.5"
# Docker registry/repository # Docker registry/repository
$registry = "ai.ronsunny.cn:13011/bbit_ai/ce_vue" $registry = "ai.ronsunny.cn:13011/bbit_ai/ce_vue"