增加字符串兼容性;去除国外开源地图软件代码;批次发布后仍可编辑;
This commit is contained in:
@@ -946,7 +946,7 @@ onMounted(async () => {
|
||||
<Tabs v-model:active-key="activeTab">
|
||||
<Tabs.TabPane key="templates" tab="模板中心">
|
||||
<Row :gutter="[16, 16]" align="stretch">
|
||||
<Col :lg="8" :xs="24">
|
||||
<Col :lg="7" :xs="24">
|
||||
<Card :loading="loading" class="panel-card editor-panel" title="模板列表">
|
||||
<template #extra>
|
||||
<Button type="primary" @click="openCreateTemplateModal">
|
||||
@@ -988,12 +988,11 @@ onMounted(async () => {
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col :lg="16" :xs="24">
|
||||
<Col :lg="17" :xs="24">
|
||||
<div class="template-main">
|
||||
<Card
|
||||
v-if="selectedTemplateId"
|
||||
class="panel-card template-summary"
|
||||
:bordered="false"
|
||||
title="基础信息"
|
||||
>
|
||||
<template #extra>
|
||||
@@ -1445,6 +1444,14 @@ onMounted(async () => {
|
||||
:value="String(getPresetValue(currentTemplateField) || '')"
|
||||
@update:value="(value) => updatePresetValue(currentTemplateField, value)"
|
||||
/>
|
||||
<Input.TextArea
|
||||
v-else-if="currentTemplateField.type === 'string'"
|
||||
:auto-size="{ minRows: 2, maxRows: 6 }"
|
||||
:disabled="isFieldPresetLocked(currentTemplateField)"
|
||||
:placeholder="currentTemplateField.placeholder || '请输入字符串'"
|
||||
:value="String(getPresetValue(currentTemplateField) ?? '')"
|
||||
@update:value="(value) => updatePresetValue(currentTemplateField, value)"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:disabled="isFieldPresetLocked(currentTemplateField)"
|
||||
@@ -1763,11 +1770,17 @@ onMounted(async () => {
|
||||
:value="String(getPresetValue(currentLibraryField) || '')"
|
||||
@update:value="(value) => updatePresetValue(currentLibraryField, value)"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:value="getPresetValue(currentLibraryField)"
|
||||
@update:value="(value) => updatePresetValue(currentLibraryField, value)"
|
||||
/>
|
||||
<Input.TextArea
|
||||
v-else-if="currentLibraryField.type === 'string'"
|
||||
:auto-size="{ minRows: 2, maxRows: 6 }"
|
||||
:value="String(getPresetValue(currentLibraryField) ?? '')"
|
||||
@update:value="(value) => updatePresetValue(currentLibraryField, value)"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:value="getPresetValue(currentLibraryField)"
|
||||
@update:value="(value) => updatePresetValue(currentLibraryField, value)"
|
||||
/>
|
||||
</Col>
|
||||
<Col :md="10" :xs="24">
|
||||
<label class="field-label">文字颜色</label>
|
||||
@@ -2021,10 +2034,13 @@ onMounted(async () => {
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
padding: 14px 16px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.template-summary :deep(.ant-card-body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.summary-inline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -2170,7 +2186,7 @@ onMounted(async () => {
|
||||
|
||||
.template-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
@@ -2179,7 +2195,7 @@ onMounted(async () => {
|
||||
border: 1px solid #edf1f7;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.template-card.active {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TraceabilityApi } from '#/api';
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Button, Input, Modal, Space, message } from 'ant-design-vue';
|
||||
import { Input } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
buildCoordinateMapUrl,
|
||||
formatCoordinateValue,
|
||||
normalizeCoordinateValue,
|
||||
} from '../shared';
|
||||
@@ -25,12 +24,6 @@ const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: TraceabilityApi.CoordinateValue | null): void;
|
||||
}>();
|
||||
|
||||
const pickerVisible = ref(false);
|
||||
const mapLoading = ref(false);
|
||||
const mapContainer = ref<HTMLDivElement | null>(null);
|
||||
const leafletMap = ref<any>(null);
|
||||
const leafletMarker = ref<any>(null);
|
||||
|
||||
const currentValue = computed(() => normalizeCoordinateValue(props.modelValue));
|
||||
const lngText = ref('');
|
||||
const latText = ref('');
|
||||
@@ -70,130 +63,6 @@ function clearValue() {
|
||||
latText.value = '';
|
||||
emit('update:modelValue', null);
|
||||
}
|
||||
|
||||
function buildEmbedMapUrl() {
|
||||
const normalized = currentValue.value;
|
||||
if (normalized?.lng === null || normalized?.lng === undefined || normalized?.lat === null || normalized?.lat === undefined) {
|
||||
return '';
|
||||
}
|
||||
return `https://www.openstreetmap.org/export/embed.html?bbox=${normalized.lng - 0.01}%2C${normalized.lat - 0.01}%2C${normalized.lng + 0.01}%2C${normalized.lat + 0.01}&layer=mapnik&marker=${normalized.lat}%2C${normalized.lng}`;
|
||||
}
|
||||
|
||||
function openExternalMap() {
|
||||
const url = buildCoordinateMapUrl(currentValue.value);
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
|
||||
function loadValueFromMap(lng: number, lat: number, source: string = 'map') {
|
||||
lngText.value = String(lng);
|
||||
latText.value = String(lat);
|
||||
emitCurrent(source);
|
||||
}
|
||||
|
||||
async function loadLeaflet() {
|
||||
const windowAny = window as any;
|
||||
if (windowAny.L) return windowAny.L;
|
||||
|
||||
if (!document.querySelector('link[data-traceability-leaflet]')) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
link.setAttribute('data-traceability-leaflet', '1');
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const existing = document.querySelector('script[data-traceability-leaflet]');
|
||||
if (existing && windowAny.L) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve(), { once: true });
|
||||
existing.addEventListener('error', () => reject(new Error('leaflet load failed')), { once: true });
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||
script.async = true;
|
||||
script.setAttribute('data-traceability-leaflet', '1');
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('leaflet load failed'));
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
|
||||
return windowAny.L;
|
||||
}
|
||||
|
||||
function updateMarkerPosition(lng: number, lat: number) {
|
||||
if (!leafletMap.value) return;
|
||||
const leaflet = (window as any).L;
|
||||
if (!leafletMarker.value) {
|
||||
leafletMarker.value = leaflet.marker([lat, lng], { draggable: !props.disabled }).addTo(leafletMap.value);
|
||||
leafletMarker.value.on('dragend', (event: any) => {
|
||||
const point = event.target.getLatLng();
|
||||
loadValueFromMap(point.lng, point.lat);
|
||||
});
|
||||
} else {
|
||||
leafletMarker.value.setLatLng([lat, lng]);
|
||||
}
|
||||
leafletMap.value.setView([lat, lng], 15);
|
||||
}
|
||||
|
||||
async function initMap() {
|
||||
if (!pickerVisible.value || !mapContainer.value) return;
|
||||
mapLoading.value = true;
|
||||
try {
|
||||
const leaflet = await loadLeaflet();
|
||||
await nextTick();
|
||||
if (!mapContainer.value) return;
|
||||
|
||||
if (!leafletMap.value) {
|
||||
leafletMap.value = leaflet.map(mapContainer.value, {
|
||||
zoomControl: true,
|
||||
}).setView([39.9042, 116.4074], 5);
|
||||
leaflet.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
}).addTo(leafletMap.value);
|
||||
leafletMap.value.on('click', (event: any) => {
|
||||
if (props.disabled) return;
|
||||
loadValueFromMap(event.latlng.lng, event.latlng.lat);
|
||||
});
|
||||
}
|
||||
|
||||
const normalized = currentValue.value;
|
||||
if (normalized?.lng !== null && normalized?.lng !== undefined && normalized?.lat !== null && normalized?.lat !== undefined) {
|
||||
updateMarkerPosition(normalized.lng, normalized.lat);
|
||||
}
|
||||
|
||||
setTimeout(() => leafletMap.value?.invalidateSize(), 120);
|
||||
} catch {
|
||||
message.error('地图控件加载失败,请先手动输入经纬度');
|
||||
} finally {
|
||||
mapLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
pickerVisible.value = true;
|
||||
nextTick(() => {
|
||||
void initMap();
|
||||
});
|
||||
}
|
||||
|
||||
watch(currentValue, (value) => {
|
||||
if (value?.lng !== null && value?.lng !== undefined && value?.lat !== null && value?.lat !== undefined) {
|
||||
updateMarkerPosition(value.lng, value.lat);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
leafletMap.value?.remove?.();
|
||||
leafletMap.value = null;
|
||||
leafletMarker.value = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -213,55 +82,9 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Space wrap class="coordinate-editor__actions">
|
||||
<Button :disabled="disabled" size="small" @click="openPicker">
|
||||
{{ disabled ? '查看地图' : '地图选点' }}
|
||||
</Button>
|
||||
<Button v-if="currentValue" size="small" @click="openExternalMap">
|
||||
打开外部地图
|
||||
</Button>
|
||||
<Button v-if="currentValue && !disabled" size="small" @click="clearValue">
|
||||
清空
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<div v-if="currentValue" class="coordinate-editor__summary">
|
||||
{{ formatCoordinateValue(currentValue) }}
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
v-if="currentValue?.lng !== null && currentValue?.lng !== undefined && currentValue?.lat !== null && currentValue?.lat !== undefined"
|
||||
:src="buildEmbedMapUrl()"
|
||||
class="coordinate-editor__embed"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
title="坐标预览地图"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
v-model:open="pickerVisible"
|
||||
:footer="null"
|
||||
:title="disabled ? '查看坐标' : '地图选点'"
|
||||
width="920px"
|
||||
@after-open-change="(open) => open && initMap()"
|
||||
>
|
||||
<div class="coordinate-picker">
|
||||
<div class="coordinate-picker__sidebar">
|
||||
<div class="coordinate-picker__tips">
|
||||
<span>使用说明:</span>
|
||||
<p>支持手动输入经纬度,或在地图上点击/拖拽标记点来确定位置。</p>
|
||||
</div>
|
||||
<div class="coordinate-picker__meta">
|
||||
<span>经度:{{ lngText || '-' }}</span>
|
||||
<span>纬度:{{ latText || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="coordinate-picker__map-wrap">
|
||||
<div ref="mapContainer" class="coordinate-picker__map" />
|
||||
<div v-if="mapLoading" class="coordinate-picker__mask">地图加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -276,93 +99,10 @@ onBeforeUnmount(() => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.coordinate-editor__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.coordinate-editor__summary {
|
||||
color: #526277;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.coordinate-editor__embed {
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
border: 1px solid #e7edf7;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.coordinate-picker {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.coordinate-picker__sidebar {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.coordinate-picker__tips {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e7edf7;
|
||||
border-radius: 12px;
|
||||
background: #fafcff;
|
||||
color: #5f6b7c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.coordinate-picker__tips p {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.coordinate-picker__meta {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e7edf7;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: #4f6078;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.coordinate-picker__map-wrap {
|
||||
position: relative;
|
||||
min-height: 520px;
|
||||
border: 1px solid #e7edf7;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.coordinate-picker__map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.coordinate-picker__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: #4f6078;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.coordinate-picker {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.coordinate-picker__map-wrap,
|
||||
.coordinate-picker__map {
|
||||
min-height: 360px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Steps,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
getTraceabilityTemplates,
|
||||
publishTraceabilityBatch,
|
||||
resetTraceabilityBatchScanCount,
|
||||
updateTraceabilityBatchBase,
|
||||
updateTraceabilityBatchStep,
|
||||
uploadTraceabilityImage,
|
||||
} from '#/api';
|
||||
@@ -47,13 +47,11 @@ const selectedBatchId = ref('');
|
||||
const templates = ref<TraceabilityApi.TemplateSummary[]>([]);
|
||||
const batches = ref<TraceabilityApi.BatchSummary[]>([]);
|
||||
const batchDetail = ref<null | TraceabilityApi.BatchDetail>(null);
|
||||
const stepIndex = ref(0);
|
||||
const editableStartIndex = ref(0);
|
||||
const selectedStepId = ref('');
|
||||
const createBatchVisible = ref(false);
|
||||
const formState = reactive({
|
||||
batchCode: '',
|
||||
batchName: '',
|
||||
coverImage: '',
|
||||
productName: '',
|
||||
summary: '',
|
||||
tagsText: '',
|
||||
@@ -66,6 +64,7 @@ const batchSnapshot = ref('');
|
||||
const leaveDialogVisible = ref(false);
|
||||
const pendingBatchSelectId = ref('');
|
||||
const bypassBatchLeaveGuard = ref(false);
|
||||
const batchEditMode = ref(false);
|
||||
const publishedTemplates = computed(() =>
|
||||
templates.value.filter((item) => item.status === 'active'),
|
||||
);
|
||||
@@ -88,7 +87,16 @@ const filteredBatches = computed(() =>
|
||||
);
|
||||
|
||||
const currentStep = computed(
|
||||
() => batchDetail.value?.steps?.[stepIndex.value] ?? null,
|
||||
() =>
|
||||
(batchDetail.value?.steps ?? []).find((item) => item.id === selectedStepId.value)
|
||||
?? batchDetail.value?.steps?.[0]
|
||||
?? null,
|
||||
);
|
||||
const publicSteps = computed(() =>
|
||||
(batchDetail.value?.steps ?? []).filter((item) => item.category === 'public'),
|
||||
);
|
||||
const businessSteps = computed(() =>
|
||||
(batchDetail.value?.steps ?? []).filter((item) => item.category !== 'public'),
|
||||
);
|
||||
const batchQrCode = useQRCode(
|
||||
computed(() => batchDetail.value?.publicUrl || ''),
|
||||
@@ -96,28 +104,71 @@ const batchQrCode = useQRCode(
|
||||
);
|
||||
const isPublished = computed(() => batchDetail.value?.status === 'published');
|
||||
const isLockedStep = computed(() => !!currentStep.value?.locked);
|
||||
const actualCurrentStepIndex = computed(() =>
|
||||
isPublished.value
|
||||
? (batchDetail.value?.currentStep ?? 0)
|
||||
: editableStartIndex.value,
|
||||
);
|
||||
const isCurrentEditableStep = computed(
|
||||
() => !!batchDetail.value && !isPublished.value,
|
||||
() => !!currentStep.value && (!isPublished.value || batchEditMode.value),
|
||||
);
|
||||
const isLastStep = computed(() => {
|
||||
if (!batchDetail.value?.steps?.length) return false;
|
||||
return actualCurrentStepIndex.value >= batchDetail.value.steps.length - 1;
|
||||
});
|
||||
const allStepsCompleted = computed(() =>
|
||||
(batchDetail.value?.steps ?? []).every((item) => item.status === 'completed'),
|
||||
);
|
||||
const stepActionText = computed(() =>
|
||||
isLastStep.value ? '保存并发布' : '保存并切换至下一节点',
|
||||
);
|
||||
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`,
|
||||
);
|
||||
|
||||
const hasBaseInfoChanges = computed(() => {
|
||||
if (!selectedBatchId.value || !batchDetail.value) return false;
|
||||
return JSON.stringify({
|
||||
batchCode: formState.batchCode,
|
||||
batchName: formState.batchName,
|
||||
productName: formState.productName,
|
||||
summary: formState.summary,
|
||||
tagsText: formState.tagsText,
|
||||
templateId: formState.templateId,
|
||||
}) !== JSON.stringify({
|
||||
batchCode: batchDetail.value.batchCode,
|
||||
batchName: batchDetail.value.batchName,
|
||||
productName: batchDetail.value.productName,
|
||||
summary: batchDetail.value.summary,
|
||||
tagsText: batchDetail.value.tags.join(','),
|
||||
templateId: batchDetail.value.templateId,
|
||||
});
|
||||
});
|
||||
|
||||
function getNextPendingStep(category?: string) {
|
||||
const steps = batchDetail.value?.steps ?? [];
|
||||
if (category) {
|
||||
const laneStep = steps.find(
|
||||
(item) => item.category === category && item.status !== 'completed',
|
||||
);
|
||||
if (laneStep) return laneStep;
|
||||
}
|
||||
return steps.find((item) => item.status !== 'completed') ?? steps[0] ?? null;
|
||||
}
|
||||
|
||||
function syncSelectedStep(preferredCategory?: string) {
|
||||
selectedStepId.value =
|
||||
getNextPendingStep(preferredCategory)?.id
|
||||
?? batchDetail.value?.steps?.[0]?.id
|
||||
?? '';
|
||||
}
|
||||
|
||||
function selectStep(stepId: string) {
|
||||
selectedStepId.value = stepId;
|
||||
}
|
||||
|
||||
function normalizeCompletedAt(value?: string) {
|
||||
const text = value?.trim();
|
||||
if (!text) return new Date().toISOString();
|
||||
if (text.includes('T')) {
|
||||
const parsed = new Date(text);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
|
||||
}
|
||||
const parsed = new Date(text.replace(' ', 'T'));
|
||||
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
|
||||
}
|
||||
|
||||
function downloadQrCode(dataUrl: string, fileName: string) {
|
||||
if (!dataUrl) return;
|
||||
const link = document.createElement('a');
|
||||
@@ -158,17 +209,15 @@ function applyBatch(detail: TraceabilityApi.BatchDetail) {
|
||||
batchDetail.value = structuredClone(detail);
|
||||
formState.batchCode = detail.batchCode;
|
||||
formState.batchName = detail.batchName;
|
||||
formState.coverImage = detail.coverImage;
|
||||
formState.productName = detail.productName;
|
||||
formState.summary = detail.summary;
|
||||
formState.tagsText = detail.tags.join(',');
|
||||
formState.templateId = detail.templateId;
|
||||
stepIndex.value = detail.currentStep ?? 0;
|
||||
editableStartIndex.value = detail.currentStep ?? 0;
|
||||
batchEditMode.value = false;
|
||||
syncSelectedStep();
|
||||
batchSnapshot.value = JSON.stringify({
|
||||
batchCode: formState.batchCode,
|
||||
batchName: formState.batchName,
|
||||
coverImage: formState.coverImage,
|
||||
productName: formState.productName,
|
||||
summary: formState.summary,
|
||||
tagsText: formState.tagsText,
|
||||
@@ -184,7 +233,6 @@ const hasBatchChanges = computed(() => {
|
||||
JSON.stringify({
|
||||
batchCode: formState.batchCode,
|
||||
batchName: formState.batchName,
|
||||
coverImage: formState.coverImage,
|
||||
productName: formState.productName,
|
||||
summary: formState.summary,
|
||||
tagsText: formState.tagsText,
|
||||
@@ -193,6 +241,9 @@ const hasBatchChanges = computed(() => {
|
||||
})
|
||||
);
|
||||
});
|
||||
const canUpdateBatch = computed(
|
||||
() => isPublished.value && batchEditMode.value && hasBatchChanges.value,
|
||||
);
|
||||
|
||||
function openBatchLeaveDialog(nextId: string) {
|
||||
pendingBatchSelectId.value = nextId;
|
||||
@@ -205,7 +256,11 @@ async function confirmBatchLeave(action: 'cancel' | 'discard' | 'save') {
|
||||
pendingBatchSelectId.value = '';
|
||||
if (action === 'cancel' || !nextId) return;
|
||||
if (action === 'save') {
|
||||
await saveStep();
|
||||
if (currentStep.value && isCurrentEditableStep.value) {
|
||||
await saveStep();
|
||||
} else {
|
||||
await saveBatchBase();
|
||||
}
|
||||
}
|
||||
bypassBatchLeaveGuard.value = true;
|
||||
try {
|
||||
@@ -239,12 +294,10 @@ function openCreateBatchModal() {
|
||||
batchDetail.value = null;
|
||||
formState.batchCode = `TR-${new Date().getFullYear()}-${String(Date.now()).slice(-6)}`;
|
||||
formState.batchName = '新建批次';
|
||||
formState.coverImage = '';
|
||||
formState.productName = '';
|
||||
formState.summary = '';
|
||||
formState.tagsText = '';
|
||||
formState.templateId = publishedTemplates.value[0]?.id ?? '';
|
||||
stepIndex.value = 0;
|
||||
createBatchVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -258,7 +311,6 @@ async function createBatch() {
|
||||
const detail = await createTraceabilityBatch({
|
||||
batchCode: formState.batchCode,
|
||||
batchName: formState.batchName,
|
||||
coverImage: formState.coverImage,
|
||||
productName: formState.productName,
|
||||
summary: formState.summary,
|
||||
tags: formState.tagsText
|
||||
@@ -286,11 +338,8 @@ function getBatchStatusText(status: string) {
|
||||
return status || '进行中';
|
||||
}
|
||||
|
||||
function getStepStatusText(index: number) {
|
||||
if (allStepsCompleted.value) {
|
||||
return '已完成';
|
||||
}
|
||||
return index < actualCurrentStepIndex.value ? '已完成' : '进行中';
|
||||
function getStepStatusText(step: TraceabilityApi.BatchStep) {
|
||||
return step.status === 'completed' ? '已完成' : '待填写';
|
||||
}
|
||||
|
||||
function updateFieldValue(field: TraceabilityApi.FieldDefinition, value: any) {
|
||||
@@ -310,9 +359,7 @@ function buildPersistedStepValues(step: TraceabilityApi.BatchStep) {
|
||||
}
|
||||
|
||||
function isFieldValueLocked(field: TraceabilityApi.FieldDefinition) {
|
||||
return (
|
||||
!isCurrentEditableStep.value || !!isPublished.value || !!field.fixedPreset
|
||||
);
|
||||
return !isCurrentEditableStep.value || !!field.fixedPreset;
|
||||
}
|
||||
|
||||
function sanitizeIntegerInput(value: string) {
|
||||
@@ -401,34 +448,125 @@ function removeBatch(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
async function saveBatchBase() {
|
||||
if (!selectedBatchId.value || !batchDetail.value || !canEditBaseInfo.value) {
|
||||
return batchDetail.value;
|
||||
}
|
||||
const detail = await updateTraceabilityBatchBase(selectedBatchId.value, {
|
||||
batchCode: formState.batchCode,
|
||||
batchName: formState.batchName,
|
||||
productName: formState.productName,
|
||||
summary: formState.summary,
|
||||
tags: formState.tagsText
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
currentStep: batchDetail.value.currentStep,
|
||||
});
|
||||
batchDetail.value = structuredClone(detail);
|
||||
batchSnapshot.value = JSON.stringify({
|
||||
batchCode: detail.batchCode,
|
||||
batchName: detail.batchName,
|
||||
productName: detail.productName,
|
||||
summary: detail.summary,
|
||||
tagsText: detail.tags.join(','),
|
||||
templateId: detail.templateId,
|
||||
steps: detail.steps,
|
||||
});
|
||||
return detail;
|
||||
}
|
||||
|
||||
async function saveBatchInfo() {
|
||||
if (!selectedBatchId.value || !hasBaseInfoChanges.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveBatchBase();
|
||||
message.success('基础信息已保存');
|
||||
await loadLists();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function publishOrUpdateBatch() {
|
||||
if (!selectedBatchId.value) return;
|
||||
if (!isPublished.value && !allStepsCompleted.value) {
|
||||
message.warning('请先完成业务节点和公共资料节点的全部填报');
|
||||
return;
|
||||
}
|
||||
const actionLabel = isPublished.value ? '更新' : '发布';
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveBatchBase();
|
||||
if (isPublished.value && batchEditMode.value && currentStep.value) {
|
||||
const updatedDetail = await updateTraceabilityBatchStep(
|
||||
selectedBatchId.value,
|
||||
currentStep.value.id,
|
||||
{
|
||||
completedAt:
|
||||
normalizeCompletedAt(currentStep.value.completedAt),
|
||||
operatorName: currentStep.value.operatorName,
|
||||
status: currentStep.value.status || 'completed',
|
||||
values: buildPersistedStepValues(currentStep.value),
|
||||
},
|
||||
);
|
||||
batchDetail.value = structuredClone(updatedDetail);
|
||||
}
|
||||
const detail = await publishTraceabilityBatch(selectedBatchId.value);
|
||||
applyBatch(detail);
|
||||
message.success(`批次已${actionLabel}`);
|
||||
await loadLists();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startBatchEdit(id?: string) {
|
||||
if (id && id !== selectedBatchId.value) {
|
||||
await selectBatch(id);
|
||||
}
|
||||
if (!batchDetail.value) return;
|
||||
batchEditMode.value = true;
|
||||
selectedStepId.value = currentStep.value?.id || batchDetail.value.steps[0]?.id || '';
|
||||
}
|
||||
|
||||
async function saveStep() {
|
||||
if (!selectedBatchId.value || !currentStep.value) return;
|
||||
if (!isCurrentEditableStep.value || isPublished.value) {
|
||||
message.warning('请先完成当前进行中的节点');
|
||||
if (!isCurrentEditableStep.value) {
|
||||
message.warning('请先进入可编辑节点后再保存');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveBatchBase();
|
||||
const targetStepId = currentStep.value.id;
|
||||
const targetCategory = currentStep.value.category;
|
||||
const detail = await updateTraceabilityBatchStep(
|
||||
selectedBatchId.value,
|
||||
currentStep.value.id,
|
||||
targetStepId,
|
||||
{
|
||||
completedAt: new Date().toISOString(),
|
||||
completedAt:
|
||||
currentStep.value.status === 'completed'
|
||||
? normalizeCompletedAt(currentStep.value.completedAt)
|
||||
: new Date().toISOString(),
|
||||
operatorName: currentStep.value.operatorName,
|
||||
status: 'completed',
|
||||
status: isPublished.value ? (currentStep.value.status || 'completed') : 'completed',
|
||||
values: buildPersistedStepValues(currentStep.value),
|
||||
},
|
||||
);
|
||||
applyBatch(detail);
|
||||
if (detail.steps.every((item) => item.status === 'completed')) {
|
||||
const published = await publishTraceabilityBatch(selectedBatchId.value);
|
||||
applyBatch(published);
|
||||
message.success('最后一个节点已保存并发布');
|
||||
if (isPublished.value) {
|
||||
batchEditMode.value = true;
|
||||
selectedStepId.value = targetStepId;
|
||||
message.success('当前节点已保存,可继续修订后更新批次');
|
||||
} else {
|
||||
const nextIndex = detail.currentStep ?? 0;
|
||||
editableStartIndex.value = nextIndex;
|
||||
stepIndex.value = nextIndex;
|
||||
message.success('当前节点已保存,已切换至下一节点');
|
||||
selectedStepId.value =
|
||||
getNextPendingStep(targetCategory)?.id ?? detail.steps[0]?.id ?? '';
|
||||
message.success(
|
||||
detail.steps.every((item) => item.status === 'completed')
|
||||
? '当前节点已保存,全部节点已完成,可手动发布'
|
||||
: '当前节点已保存,已切换到本条线的下一节点',
|
||||
);
|
||||
}
|
||||
await loadLists();
|
||||
} finally {
|
||||
@@ -468,7 +606,7 @@ onMounted(async () => {
|
||||
<div class="trace-operator-page">
|
||||
<div class="trace-operator">
|
||||
<Row :gutter="[16, 16]">
|
||||
<Col :lg="7" :md="8" :sm="24" :xs="24">
|
||||
<Col :lg="6" :md="7" :sm="24" :xs="24">
|
||||
<Card
|
||||
:loading="loading"
|
||||
class="panel-card batch-panel-card"
|
||||
@@ -481,7 +619,7 @@ onMounted(async () => {
|
||||
allow-clear
|
||||
placeholder="全部模板"
|
||||
class="batch-filter-select"
|
||||
style="width: 260px"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<Button type="primary" @click="openCreateBatchModal">
|
||||
新建批次
|
||||
@@ -525,10 +663,69 @@ onMounted(async () => {
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col :lg="17" :md="16" :sm="24" :xs="24">
|
||||
<Col :lg="18" :md="17" :sm="24" :xs="24">
|
||||
<Space direction="vertical" size="middle" style="width: 100%">
|
||||
<Card v-if="batchDetail" class="panel-card" title="基础信息设置">
|
||||
<template #extra>
|
||||
<Space>
|
||||
<Button
|
||||
v-if="isPublished"
|
||||
@click="startBatchEdit()"
|
||||
>
|
||||
编辑批次
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canEditBaseInfo"
|
||||
:disabled="!hasBaseInfoChanges"
|
||||
:loading="saving"
|
||||
@click="saveBatchInfo"
|
||||
>
|
||||
保存基础信息
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="isPublished ? !canUpdateBatch : !canPublishBatch"
|
||||
:loading="saving"
|
||||
@click="publishOrUpdateBatch"
|
||||
>
|
||||
{{ publishButtonText }}
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
<Row :gutter="[16, 16]">
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">批次名称</label>
|
||||
<Input v-model:value="formState.batchName" :disabled="!canEditBaseInfo" />
|
||||
</Col>
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">批次编码</label>
|
||||
<Input v-model:value="formState.batchCode" :disabled="!canEditBaseInfo" />
|
||||
</Col>
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">产品名称</label>
|
||||
<Input v-model:value="formState.productName" :disabled="!canEditBaseInfo" />
|
||||
</Col>
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">标签</label>
|
||||
<Input
|
||||
v-model:value="formState.tagsText"
|
||||
:disabled="!canEditBaseInfo"
|
||||
placeholder="用逗号分隔"
|
||||
/>
|
||||
</Col>
|
||||
<Col :span="24">
|
||||
<label class="field-label">批次概述</label>
|
||||
<Input.TextArea
|
||||
v-model:value="formState.summary"
|
||||
:disabled="!canEditBaseInfo"
|
||||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
v-if="batchDetail?.publishedAt"
|
||||
v-if="batchDetail?.publishedAt && !batchEditMode"
|
||||
class="panel-card"
|
||||
title="发布信息"
|
||||
>
|
||||
@@ -565,8 +762,8 @@ onMounted(async () => {
|
||||
<strong>{{ batchDetail.publishedAt || '未发布' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>当前状态</span>
|
||||
<strong>{{ getBatchStatusText(batchDetail.status) }}</strong>
|
||||
<span>扫码次数</span>
|
||||
<strong>{{ batchDetail.scanCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="publish-qr">
|
||||
@@ -582,43 +779,60 @@ onMounted(async () => {
|
||||
<Card class="panel-card" title="节点填报">
|
||||
<template #extra>
|
||||
<Button
|
||||
v-if="!isPublished"
|
||||
type="primary"
|
||||
:disabled="!currentStep || !isCurrentEditableStep"
|
||||
:loading="saving"
|
||||
@click="saveStep"
|
||||
>
|
||||
{{ stepActionText }}
|
||||
保存当前节点
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template v-if="batchDetail">
|
||||
<Steps
|
||||
class="step-strip"
|
||||
:class="{ 'step-strip--published': isPublished }"
|
||||
:current="stepIndex"
|
||||
size="small"
|
||||
@change="
|
||||
(value) => {
|
||||
stepIndex = value;
|
||||
if (!isPublished) {
|
||||
editableStartIndex = value;
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<Steps.Step
|
||||
v-for="(item, index) in batchDetail.steps"
|
||||
:key="item.id"
|
||||
:title="item.name"
|
||||
:description="getStepStatusText(index)"
|
||||
/>
|
||||
</Steps>
|
||||
<div class="step-lanes">
|
||||
<div class="node-lane">
|
||||
<div class="node-lane__title">公共资料</div>
|
||||
<div class="node-strip">
|
||||
<button
|
||||
v-for="item in publicSteps"
|
||||
:key="item.id"
|
||||
class="node-pill"
|
||||
:class="{ active: currentStep?.id === item.id, 'node-pill--completed': item.status === 'completed' }"
|
||||
type="button"
|
||||
@click="selectStep(item.id)"
|
||||
>
|
||||
<span>公共资料</span>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<small>{{ getStepStatusText(item) }}</small>
|
||||
</button>
|
||||
<div v-if="publicSteps.length === 0" class="lane-empty">还没有公共资料节点</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="node-lane">
|
||||
<div class="node-lane__title">业务流程</div>
|
||||
<div class="node-strip">
|
||||
<button
|
||||
v-for="item in businessSteps"
|
||||
:key="item.id"
|
||||
class="node-pill"
|
||||
:class="{ active: currentStep?.id === item.id, 'node-pill--completed': item.status === 'completed' }"
|
||||
type="button"
|
||||
@click="selectStep(item.id)"
|
||||
>
|
||||
<span>业务流程</span>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<small>{{ getStepStatusText(item) }}</small>
|
||||
</button>
|
||||
<div v-if="businessSteps.length === 0" class="lane-empty">还没有业务流程节点</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="currentStep"
|
||||
class="step-editor"
|
||||
:class="{ 'step-editor--published': isPublished }"
|
||||
:class="{ 'step-editor--published': isPublished && !batchEditMode }"
|
||||
>
|
||||
<div class="step-header">
|
||||
<div>
|
||||
@@ -631,12 +845,14 @@ onMounted(async () => {
|
||||
<small class="step-hint">
|
||||
{{
|
||||
isPublished
|
||||
? '当前批次已发布,溯源链已锁定为只读。'
|
||||
? batchEditMode
|
||||
? '当前批次处于更新模式,可逐个节点修订内容。'
|
||||
: '当前批次已发布,点击“编辑批次”后才能修改。'
|
||||
: isLockedStep
|
||||
? '当前节点来自节点库,字段和值固定,不可修改。'
|
||||
: isCurrentEditableStep
|
||||
? '当前节点可填写并继续流转。'
|
||||
: '当前查看的是非进行中节点,仅供浏览。'
|
||||
: isCurrentEditableStep
|
||||
? '当前节点可填写,保存后会推进当前分类的下一节点。'
|
||||
: '当前批次已发布,点击“编辑批次”后才能修改。'
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
@@ -660,7 +876,7 @@ onMounted(async () => {
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">节点状态</label>
|
||||
<div class="readonly-box">
|
||||
{{ getStepStatusText(stepIndex) }}
|
||||
{{ getStepStatusText(currentStep) }}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -932,10 +1148,6 @@ onMounted(async () => {
|
||||
<label class="field-label">产品名称</label>
|
||||
<Input v-model:value="formState.productName" />
|
||||
</Col>
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">封面图</label>
|
||||
<Input v-model:value="formState.coverImage" />
|
||||
</Col>
|
||||
<Col :md="12" :xs="24">
|
||||
<label class="field-label">标签</label>
|
||||
<Input v-model:value="formState.tagsText" placeholder="用逗号分隔" />
|
||||
@@ -985,7 +1197,7 @@ onMounted(async () => {
|
||||
|
||||
.batch-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.batch-toolbar {
|
||||
@@ -1022,7 +1234,7 @@ onMounted(async () => {
|
||||
border: 1px solid #edf1f7;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -1069,24 +1281,67 @@ onMounted(async () => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-strip {
|
||||
.step-lanes {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-bottom: 20px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.step-strip :deep(.ant-steps) {
|
||||
.node-lane__title {
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.node-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 12px;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.step-strip :deep(.ant-steps-item) {
|
||||
flex: 1 1 220px;
|
||||
.node-pill {
|
||||
min-width: 180px;
|
||||
border: 1px solid #edf1f7;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.node-pill.active {
|
||||
border-color: #adc4ff;
|
||||
background: #f5f8ff;
|
||||
}
|
||||
|
||||
.node-pill--completed {
|
||||
border-color: #d6e4d3;
|
||||
background: #f6fbf4;
|
||||
}
|
||||
|
||||
.node-pill span,
|
||||
.node-pill small {
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-pill strong,
|
||||
.node-pill small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.node-pill small {
|
||||
margin-top: 6px;
|
||||
color: #8b96a8;
|
||||
}
|
||||
|
||||
.lane-empty {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.step-strip :deep(.ant-steps-item-container) {
|
||||
padding-right: 12px;
|
||||
border: 1px dashed #dbe3f0;
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
color: #8b96a8;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.step-editor {
|
||||
@@ -1095,7 +1350,6 @@ onMounted(async () => {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
|
||||
}
|
||||
|
||||
.step-strip--published,
|
||||
.step-editor--published {
|
||||
opacity: 0.58;
|
||||
}
|
||||
@@ -1249,10 +1503,6 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.step-strip :deep(.ant-steps-item) {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.field-entry__head {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1265,6 +1515,10 @@ onMounted(async () => {
|
||||
.batch-toolbar :deep(.ant-select) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.node-pill {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
|
||||
@@ -247,7 +247,7 @@ export function buildCoordinateMapUrl(value: any) {
|
||||
return '';
|
||||
}
|
||||
if (normalized.lng !== null && normalized.lng !== undefined && normalized.lat !== null && normalized.lat !== undefined) {
|
||||
return `https://www.openstreetmap.org/?mlat=${normalized.lat}&mlon=${normalized.lng}#map=15/${normalized.lat}/${normalized.lng}`;
|
||||
return `https://uri.amap.com/marker?position=${normalized.lng},${normalized.lat}&name=坐标位置&src=traceability`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -257,7 +257,7 @@ export function buildCoordinateEmbedUrl(value: any) {
|
||||
if (!normalized || normalized.lng === null || normalized.lng === undefined || normalized.lat === null || normalized.lat === undefined) {
|
||||
return '';
|
||||
}
|
||||
return `https://www.openstreetmap.org/export/embed.html?bbox=${normalized.lng - 0.01}%2C${normalized.lat - 0.01}%2C${normalized.lng + 0.01}%2C${normalized.lat + 0.01}&layer=mapnik&marker=${normalized.lat}%2C${normalized.lng}`;
|
||||
return `https://uri.amap.com/marker?position=${normalized.lng},${normalized.lat}&name=坐标位置&src=traceability&callnative=0`;
|
||||
}
|
||||
|
||||
export function createEmptyField(): TraceabilityApi.FieldDefinition {
|
||||
|
||||
Reference in New Issue
Block a user