增加字符串兼容性;去除国外开源地图软件代码;批次发布后仍可编辑;
This commit is contained in:
@@ -8,7 +8,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "com.bbitcn"
|
||||
version = "0.0.4"
|
||||
version = "0.0.5"
|
||||
|
||||
application {
|
||||
mainClass = "io.ktor.server.netty.EngineMain"
|
||||
|
||||
@@ -110,7 +110,17 @@ class TraceabilityService(
|
||||
value.entries.joinToString(";") { "${it.key}: ${formatJsonValue(it.value)}" }
|
||||
}
|
||||
}
|
||||
else -> value.toString().trim('"').ifBlank { "未填写" }
|
||||
else -> normalizeDisplayText(value.toString().trim('"'))
|
||||
}
|
||||
|
||||
private fun normalizeDisplayText(text: String): String {
|
||||
if (text.isBlank()) {
|
||||
return "未填写"
|
||||
}
|
||||
return text
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\\t", "\t")
|
||||
}
|
||||
|
||||
private fun formatDateOnly(value: String): String {
|
||||
@@ -126,7 +136,7 @@ class TraceabilityService(
|
||||
val lng = coordinate["lng"]?.jsonPrimitive?.doubleOrNull
|
||||
val lat = coordinate["lat"]?.jsonPrimitive?.doubleOrNull
|
||||
return when {
|
||||
lng != null && lat != null -> "https://www.openstreetmap.org/?mlat=$lat&mlon=$lng#map=15/$lat/$lng"
|
||||
lng != null && lat != null -> "https://uri.amap.com/marker?position=$lng,$lat&name=坐标位置&src=traceability"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
@@ -135,7 +145,7 @@ class TraceabilityService(
|
||||
val coordinate = value as? JsonObject ?: return ""
|
||||
val lng = coordinate["lng"]?.jsonPrimitive?.doubleOrNull ?: return ""
|
||||
val lat = coordinate["lat"]?.jsonPrimitive?.doubleOrNull ?: return ""
|
||||
return "https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.01}%2C${lat - 0.01}%2C${lng + 0.01}%2C${lat + 0.01}&layer=mapnik&marker=$lat%2C$lng"
|
||||
return "https://uri.amap.com/marker?position=$lng,$lat&name=坐标位置&src=traceability&callnative=0"
|
||||
}
|
||||
|
||||
private fun buildPageViewModel(
|
||||
|
||||
@@ -7,5 +7,5 @@ ktor:
|
||||
|
||||
traceability:
|
||||
# 访问主服务的地址
|
||||
# core-base-url: "http://127.0.0.1:8089" # 开发
|
||||
core-base-url: "https://ai.ronsunny.cn:8090/api" # 生产
|
||||
core-base-url: "http://127.0.0.1:8089" # 开发
|
||||
# core-base-url: "https://ai.ronsunny.cn:8090/api" # 生产
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${page.batchName} - 溯源信息</title>
|
||||
<link rel="stylesheet" href="/static/traceability.css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@10.6.1/ol.css" />
|
||||
</head>
|
||||
<body style="--traceability-primary:${page.themeColor};">
|
||||
<div class="page-shell">
|
||||
@@ -78,9 +79,11 @@
|
||||
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
|
||||
</button>
|
||||
<#elseif entry.type == "coordinate" && entry.value?has_content && entry.value != "未填写">
|
||||
<#if entry.mapEmbedUrl?has_content>
|
||||
<iframe class="kv-map" src="${entry.mapEmbedUrl}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" title="${entry.label}"></iframe>
|
||||
</#if>
|
||||
<div
|
||||
class="kv-map"
|
||||
data-coordinate="${entry.value?html}"
|
||||
data-label="${entry.label?html}"
|
||||
></div>
|
||||
<strong<#if entry.bold || entry.color?has_content> style="<#if entry.bold>font-weight:700;</#if><#if entry.color?has_content>color:${entry.color};</#if>"</#if>>${entry.value}</strong>
|
||||
<#else>
|
||||
<strong<#if entry.bold || entry.color?has_content> style="<#if entry.bold>font-weight:700;</#if><#if entry.color?has_content>color:${entry.color};</#if>"</#if>>${entry.value}</strong>
|
||||
@@ -115,9 +118,11 @@
|
||||
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
|
||||
</button>
|
||||
<#elseif entry.type == "coordinate" && entry.value?has_content && entry.value != "未填写">
|
||||
<#if entry.mapEmbedUrl?has_content>
|
||||
<iframe class="kv-map" src="${entry.mapEmbedUrl}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" title="${entry.label}"></iframe>
|
||||
</#if>
|
||||
<div
|
||||
class="kv-map"
|
||||
data-coordinate="${entry.value?html}"
|
||||
data-label="${entry.label?html}"
|
||||
></div>
|
||||
<strong<#if entry.bold || entry.color?has_content> style="<#if entry.bold>font-weight:700;</#if><#if entry.color?has_content>color:${entry.color};</#if>"</#if>>${entry.value}</strong>
|
||||
<#else>
|
||||
<strong<#if entry.bold || entry.color?has_content> style="<#if entry.bold>font-weight:700;</#if><#if entry.color?has_content>color:${entry.color};</#if>"</#if>>${entry.value}</strong>
|
||||
@@ -190,6 +195,12 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const openLayersScript = document.createElement('script');
|
||||
openLayersScript.src = 'https://cdn.jsdelivr.net/npm/ol@10.6.1/dist/ol.js';
|
||||
openLayersScript.defer = true;
|
||||
openLayersScript.onload = initCoordinateMaps;
|
||||
document.head.appendChild(openLayersScript);
|
||||
|
||||
const themeColor = '${page.themeColor}';
|
||||
function hexToRgb(hex) {
|
||||
const normalized = (hex || '').replace('#', '').trim();
|
||||
@@ -287,6 +298,73 @@
|
||||
});
|
||||
});
|
||||
|
||||
function parseCoordinateValue(raw) {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const lng = Number(parsed.lng);
|
||||
const lat = Number(parsed.lat);
|
||||
if (Number.isFinite(lng) && Number.isFinite(lat)) {
|
||||
return { lng, lat };
|
||||
}
|
||||
} catch (error) {
|
||||
const parts = String(raw).split(',').map((item) => item.trim());
|
||||
if (parts.length >= 2) {
|
||||
const lng = Number(parts[0]);
|
||||
const lat = Number(parts[1]);
|
||||
if (Number.isFinite(lng) && Number.isFinite(lat)) {
|
||||
return { lng, lat };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function initCoordinateMaps() {
|
||||
if (!window.ol) return;
|
||||
const mapNodes = document.querySelectorAll('.kv-map[data-coordinate]');
|
||||
mapNodes.forEach((node) => {
|
||||
if (node.dataset.initialized === 'true') return;
|
||||
const parsed = parseCoordinateValue(node.dataset.coordinate);
|
||||
if (!parsed) return;
|
||||
node.dataset.initialized = 'true';
|
||||
|
||||
const map = new ol.Map({
|
||||
target: node,
|
||||
layers: [
|
||||
new ol.layer.Tile({
|
||||
source: new ol.source.XYZ({
|
||||
url: 'https://webrd02.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}',
|
||||
crossOrigin: 'anonymous',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
view: new ol.View({
|
||||
center: ol.proj.fromLonLat([parsed.lng, parsed.lat]),
|
||||
zoom: 13,
|
||||
}),
|
||||
controls: [],
|
||||
});
|
||||
|
||||
const marker = new ol.Feature({
|
||||
geometry: new ol.geom.Point(ol.proj.fromLonLat([parsed.lng, parsed.lat])),
|
||||
});
|
||||
const vectorLayer = new ol.layer.Vector({
|
||||
source: new ol.source.Vector({
|
||||
features: [marker],
|
||||
}),
|
||||
style: new ol.style.Style({
|
||||
image: new ol.style.Circle({
|
||||
radius: 7,
|
||||
fill: new ol.style.Fill({ color: primary }),
|
||||
stroke: new ol.style.Stroke({ color: '#ffffff', width: 2 }),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
map.addLayer(vectorLayer);
|
||||
});
|
||||
}
|
||||
|
||||
if (window.location.search.includes('result=')) {
|
||||
const nextUrl = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState({}, '', nextUrl);
|
||||
|
||||
@@ -102,7 +102,7 @@ object TraceabilityDao {
|
||||
|
||||
fun listPreviewPages(): List<TracePreviewPageSummaryResponse> = transaction {
|
||||
TraceabilityPreviewPagesTable.selectAll()
|
||||
.orderBy(TraceabilityPreviewPagesTable.updatedAt, SortOrder.DESC)
|
||||
.orderBy(TraceabilityPreviewPagesTable.createdAt, SortOrder.DESC)
|
||||
.map {
|
||||
val code = it[TraceabilityPreviewPagesTable.previewCode]
|
||||
TracePreviewPageSummaryResponse(
|
||||
@@ -353,7 +353,7 @@ object TraceabilityDao {
|
||||
fun listNodeLibrary(): List<TraceNodeLibraryResponse> = transaction {
|
||||
ensureDefaultNodeLibrarySeeded()
|
||||
TraceabilityNodeLibraryTable.selectAll()
|
||||
.orderBy(TraceabilityNodeLibraryTable.updatedAt, SortOrder.DESC)
|
||||
.orderBy(TraceabilityNodeLibraryTable.createdAt, SortOrder.DESC)
|
||||
.map {
|
||||
TraceNodeLibraryResponse(
|
||||
id = it[TraceabilityNodeLibraryTable.id].value.toString(),
|
||||
@@ -482,7 +482,7 @@ object TraceabilityDao {
|
||||
.mapValues { (_, rows) -> rows.size }
|
||||
|
||||
TraceabilityTemplatesTable.selectAll()
|
||||
.orderBy(TraceabilityTemplatesTable.updatedAt, SortOrder.DESC)
|
||||
.orderBy(TraceabilityTemplatesTable.createdAt, SortOrder.DESC)
|
||||
.map {
|
||||
TraceTemplateSummaryResponse(
|
||||
id = it[TraceabilityTemplatesTable.id].value.toString(),
|
||||
@@ -613,7 +613,7 @@ object TraceabilityDao {
|
||||
.associate { it[TraceabilityTemplatesTable.id].value to it[TraceabilityTemplatesTable.name] }
|
||||
|
||||
TraceabilityBatchesTable.selectAll()
|
||||
.orderBy(TraceabilityBatchesTable.updatedAt, SortOrder.DESC)
|
||||
.orderBy(TraceabilityBatchesTable.createdAt, SortOrder.DESC)
|
||||
.map {
|
||||
val code = it[TraceabilityBatchesTable.batchCode]
|
||||
TraceBatchSummaryResponse(
|
||||
|
||||
@@ -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)"
|
||||
@@ -1762,6 +1769,12 @@ onMounted(async () => {
|
||||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||||
:value="String(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
|
||||
@@ -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') {
|
||||
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"
|
||||
<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"
|
||||
:title="item.name"
|
||||
:description="getStepStatusText(index)"
|
||||
/>
|
||||
</Steps>
|
||||
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
|
||||
? '当前节点可填写并继续流转。'
|
||||
: '当前查看的是非进行中节点,仅供浏览。'
|
||||
? '当前节点可填写,保存后会推进当前分类的下一节点。'
|
||||
: '当前批次已发布,点击“编辑批次”后才能修改。'
|
||||
}}
|
||||
</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