增加字符串兼容性;去除国外开源地图软件代码;批次发布后仍可编辑;

This commit is contained in:
BBIT-Kai
2026-04-15 15:20:51 +08:00
parent 55a2490e14
commit a23355ac5b
9 changed files with 493 additions and 395 deletions
@@ -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: '&copy; 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 {