增加溯源功能
This commit is contained in:
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>
|
||||
<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
|
||||
v-if="publicLink"
|
||||
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 '未填写';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# push_docker.ps1
|
||||
|
||||
# Set version
|
||||
$env:VERSION = "1.5.4"
|
||||
$env:VERSION = "1.5.5"
|
||||
|
||||
# Docker registry/repository
|
||||
$registry = "ai.ronsunny.cn:13011/bbit_ai/ce_vue"
|
||||
|
||||
Reference in New Issue
Block a user