完善各种需求

This commit is contained in:
BBIT-Kai
2026-04-14 16:52:30 +08:00
parent 1c68762421
commit 44181bcf5a
19 changed files with 1848 additions and 282 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ plugins {
}
group = "com.bbitcn"
version = "0.0.3"
version = "0.0.4"
application {
mainClass = "io.ktor.server.netty.EngineMain"
-1
View File
@@ -14,7 +14,6 @@ fun Application.module() {
monitor.subscribe(ApplicationStopped) {
traceabilityClient.close()
}
attributes.put(TraceabilityAttributes.ServiceKey, traceabilityService)
configureHTTP()
@@ -59,6 +59,7 @@ data class TraceBatchDetailResponse(
val summary: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val themeColor: String = "#1f4fd6",
val tags: List<String>,
val status: String,
val currentStep: Int,
@@ -108,6 +109,8 @@ data class DisplayEntry(
val type: String = "string",
val bold: Boolean = false,
val color: String = "",
val mapUrl: String = "",
val mapEmbedUrl: String = "",
)
data class PublicSectionView(
@@ -133,6 +136,7 @@ data class PageViewModel(
val templateName: String,
val summary: String,
val coverImage: String,
val themeColor: String,
val scanCount: Int,
val publishedAt: String,
val tagsText: String,
+72 -27
View File
@@ -4,6 +4,9 @@ import io.ktor.server.config.ApplicationConfig
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonPrimitive
data class TraceabilityPublicConfig(
val coreBaseUrl: String,
@@ -15,38 +18,21 @@ class TraceabilityService(
) {
suspend fun loadPage(code: String): PageViewModel? {
val detail = client.fetchPublicDetail(code, increaseScan = true) ?: return null
val batch = detail.batch
return PageViewModel(
code = batch.batchCode,
batchName = batch.batchName,
productName = batch.productName.ifBlank { batch.templateName },
templateName = batch.templateName,
summary = batch.summary.ifBlank { "该批次已完成关键环节留痕,可查看公开资料与业务流程。" },
coverImage = batch.coverImagePreviewUrl.ifBlank { batch.coverImage },
scanCount = batch.scanCount,
publishedAt = formatDateOnly(batch.publishedAt),
tagsText = batch.tags.joinToString("").ifBlank { "暂无标签" },
publicSections = detail.publicSections.map(::toPublicSectionView),
businessSections = detail.businessSections.map(::toTimelineSectionView),
return buildPageViewModel(
detail = detail,
summary = detail.batch.summary.ifBlank { "暂无说明" },
templateName = detail.batch.templateName,
publishedAt = detail.batch.publishedAt,
)
}
suspend fun loadPreviewPage(code: String): PageViewModel? {
val detail = client.fetchPreviewDetail(code) ?: return null
val batch = detail.batch
return PageViewModel(
code = batch.batchCode,
batchName = batch.batchName,
productName = batch.productName.ifBlank { batch.templateName },
return buildPageViewModel(
detail = detail,
summary = detail.batch.summary.ifBlank { "暂无说明" },
templateName = "预演页",
summary = batch.summary.ifBlank { "当前为预演页内容,可持续调整字段和值供客户确认。" },
coverImage = batch.coverImagePreviewUrl.ifBlank { batch.coverImage },
scanCount = batch.scanCount,
publishedAt = formatDateOnly(batch.updatedAt),
tagsText = batch.tags.joinToString("").ifBlank { "暂无标签" },
publicSections = detail.publicSections.map(::toPublicSectionView),
businessSections = detail.businessSections.map(::toTimelineSectionView),
publishedAt = detail.batch.updatedAt,
)
}
@@ -97,19 +83,33 @@ class TraceabilityService(
return step.values.entries.map { (key, value) ->
val field = step.fields.find { it.key == key }
val imageUrl = step.valuePreviewUrls[key].orEmpty()
val mapUrl = if ((field?.type ?: "string") == "coordinate") buildCoordinateMapUrl(value) else ""
val mapEmbedUrl = if ((field?.type ?: "string") == "coordinate") buildCoordinateEmbedUrl(value) else ""
DisplayEntry(
label = field?.label ?: key,
value = if ((field?.type ?: "string") == "image" && imageUrl.isNotBlank()) imageUrl else formatJsonValue(value),
type = field?.type ?: "string",
bold = field?.fieldStyle?.bold ?: false,
color = field?.fieldStyle?.color.orEmpty(),
mapUrl = mapUrl,
mapEmbedUrl = mapEmbedUrl,
)
}
}
private fun formatJsonValue(value: JsonElement): String = when (value) {
is JsonArray -> value.joinToString("") { formatJsonValue(it) }
is JsonObject -> value.entries.joinToString("") { "${it.key}: ${formatJsonValue(it.value)}" }
is JsonObject -> {
val lng = value["lng"]?.jsonPrimitive?.doubleOrNull
val lat = value["lat"]?.jsonPrimitive?.doubleOrNull
if (lng != null || lat != null) {
listOfNotNull(
if (lng != null && lat != null) "$lng, $lat" else null,
).joinToString(" | ").ifBlank { "未填写" }
} else {
value.entries.joinToString("") { "${it.key}: ${formatJsonValue(it.value)}" }
}
}
else -> value.toString().trim('"').ifBlank { "未填写" }
}
@@ -120,6 +120,51 @@ class TraceabilityService(
}
return text.substringBefore(" ").substringBefore("T")
}
private fun buildCoordinateMapUrl(value: JsonElement): String {
val coordinate = value as? JsonObject ?: return ""
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"
else -> ""
}
}
private fun buildCoordinateEmbedUrl(value: JsonElement): String {
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"
}
private fun buildPageViewModel(
detail: TraceabilityPublicDetailResponse,
summary: String,
templateName: String,
publishedAt: String,
): PageViewModel {
val batch = detail.batch
return PageViewModel(
code = batch.batchCode,
batchName = batch.batchName,
productName = batch.productName.ifBlank { batch.templateName },
templateName = templateName,
summary = summary,
coverImage = batch.coverImagePreviewUrl.ifBlank { batch.coverImage },
themeColor = normalizeThemeColor(batch.themeColor),
scanCount = batch.scanCount,
publishedAt = formatDateOnly(publishedAt),
tagsText = batch.tags.joinToString("").ifBlank { "暂无标签" },
publicSections = detail.publicSections.map(::toPublicSectionView),
businessSections = detail.businessSections.map(::toTimelineSectionView),
)
}
private fun normalizeThemeColor(value: String): String {
val text = value.trim()
return if (text.matches(Regex("^#([0-9a-fA-F]{6})$"))) text else "#1f4fd6"
}
}
fun ApplicationConfig.toTraceabilityPublicConfig(): TraceabilityPublicConfig {
+2 -2
View File
@@ -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" # 生产
+152 -49
View File
@@ -7,19 +7,27 @@ body {
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
color: #182235;
background:
radial-gradient(circle at top left, rgba(23, 92, 230, 0.12), transparent 32%),
radial-gradient(circle at top left, var(--traceability-primary-glow), transparent 32%),
linear-gradient(180deg, #f9fbff 0%, #f3f6fb 100%);
--traceability-primary: #1d4ed8;
--traceability-primary-strong: #163fae;
--traceability-primary-soft: rgba(29, 78, 216, 0.12);
--traceability-primary-border: rgba(29, 78, 216, 0.18);
--traceability-primary-glow: rgba(29, 78, 216, 0.08);
--traceability-primary-tint: #edf3ff;
--traceability-primary-tint-strong: #e5eefc;
--traceability-primary-tint-deep: #d7e6fa;
}
a {
color: #1d4ed8;
color: var(--traceability-primary);
text-decoration: none;
}
.page-shell {
max-width: 1440px;
max-width: 1520px;
margin: 0 auto;
padding: 36px 40px 56px;
padding: 20px 14px 44px;
}
.hero,
@@ -51,9 +59,9 @@ a {
.hero {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
padding: 26px;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.6fr);
gap: 16px;
padding: 22px;
}
.hero h1,
@@ -64,8 +72,8 @@ a {
}
.hero h1 {
margin-top: 16px;
font-size: 34px;
margin-top: 8px;
font-size: 30px;
line-height: 1.2;
}
@@ -78,14 +86,14 @@ a {
}
.hero p {
margin: 14px 0 0;
margin: 12px 0 0;
}
.hero__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
gap: 10px;
margin-top: 14px;
}
.stat-card,
@@ -107,8 +115,8 @@ a {
}
.summary-card {
min-height: 104px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
min-height: 88px;
background: linear-gradient(180deg, #ffffff 0%, var(--traceability-primary-tint) 100%);
}
.stat-card span,
@@ -131,29 +139,29 @@ a {
.hero__aside {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: 1fr;
gap: 12px;
align-content: start;
}
.panel {
margin-top: 18px;
padding: 24px;
margin-top: 14px;
padding: 18px;
}
.tabs-panel {
padding-top: 18px;
padding-top: 14px;
}
.tabs-nav {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 8px;
padding: 6px;
border: 1px solid #e8eef7;
border-radius: 999px;
background: #f7faff;
margin-bottom: 18px;
background: var(--traceability-primary-tint);
margin-bottom: 14px;
}
.tab-btn {
@@ -176,9 +184,9 @@ a {
}
.tab-btn.active {
background: linear-gradient(135deg, #2b63e3, #1f4fd6);
background: linear-gradient(135deg, var(--traceability-primary), var(--traceability-primary-strong));
color: #fff;
box-shadow: 0 10px 24px rgba(29, 78, 216, 0.2);
box-shadow: 0 10px 24px var(--traceability-primary-soft);
}
.tab-panel {
@@ -194,29 +202,29 @@ a {
}
.empty-state {
border: 1px dashed #d7e1f0;
border: 1px dashed var(--traceability-primary-border);
border-radius: 18px;
background: #fafcff;
color: #7d8899;
background: linear-gradient(180deg, #fafcff 0%, var(--traceability-primary-tint) 100%);
color: #60708a;
padding: 24px 18px;
}
.notice {
margin-top: 18px;
border-radius: 18px;
background: #ecfdf3;
border: 1px solid #ccebd9;
color: #0b7a4b;
background: linear-gradient(180deg, var(--traceability-primary-tint-strong) 0%, var(--traceability-primary-tint) 100%);
border: 1px solid var(--traceability-primary-border);
color: var(--traceability-primary-strong);
padding: 14px 16px;
}
.public-grid {
display: grid;
gap: 16px;
gap: 12px;
}
.info-card {
padding: 18px;
padding: 14px;
}
.info-card__desc {
@@ -226,24 +234,24 @@ a {
.kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
gap: 10px;
margin-top: 12px;
}
.kv-card {
padding: 12px 14px;
background: #fafcff;
padding: 11px 12px;
background: linear-gradient(180deg, #fafcff 0%, var(--traceability-primary-tint) 100%);
}
.timeline {
display: grid;
gap: 18px;
gap: 12px;
}
.timeline-item {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
gap: 16px;
grid-template-columns: 24px minmax(0, 1fr);
gap: 12px;
}
.timeline-item__rail {
@@ -256,16 +264,16 @@ a {
width: 14px;
height: 14px;
border-radius: 50%;
background: #1d4ed8;
box-shadow: 0 0 0 5px rgba(29, 78, 216, 0.12);
background: var(--traceability-primary);
box-shadow: 0 0 0 5px var(--traceability-primary-soft);
}
.line {
width: 2px;
flex: 1;
min-height: 70px;
min-height: 52px;
margin-top: 8px;
background: linear-gradient(180deg, rgba(29, 78, 216, 0.32), rgba(29, 78, 216, 0.04));
background: linear-gradient(180deg, var(--traceability-primary-border), rgba(29, 78, 216, 0.04));
}
.timeline-item:last-child .line {
@@ -273,8 +281,8 @@ a {
}
.timeline-item__body {
padding: 18px;
background: linear-gradient(180deg, #fff 0%, #fbfcff 100%);
padding: 14px;
background: linear-gradient(180deg, #fff 0%, var(--traceability-primary-tint) 100%);
}
.timeline-item__head {
@@ -287,14 +295,52 @@ a {
.kv-image {
display: block;
width: 100%;
max-height: 280px;
max-height: 240px;
object-fit: cover;
border-radius: 14px;
margin-top: 10px;
border: 1px solid #e6edf8;
border: 1px solid var(--traceability-primary-border);
background: #fff;
}
.kv-image-button {
display: grid;
gap: 8px;
width: 100%;
padding: 0;
border: none;
background: transparent;
text-align: left;
cursor: zoom-in;
}
.kv-image-button span {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 4px 8px;
border-radius: 999px;
background: var(--traceability-primary-soft);
color: var(--traceability-primary);
font-size: 12px;
}
.kv-map {
display: block;
width: 100%;
min-height: 220px;
margin-top: 10px;
border: 1px solid var(--traceability-primary-border);
border-radius: 14px;
background: #fff;
}
.kv-link {
display: inline-flex;
margin-top: 10px;
font-size: 12px;
}
.feedback-form {
display: grid;
gap: 14px;
@@ -338,7 +384,7 @@ a {
padding: 0 18px;
border: none;
border-radius: 14px;
background: linear-gradient(135deg, #2b63e3, #1f4fd6);
background: linear-gradient(135deg, var(--traceability-primary), var(--traceability-primary-strong));
color: #fff;
font: inherit;
cursor: pointer;
@@ -354,13 +400,66 @@ a {
}
.page-footer {
padding: 24px 4px 8px;
padding: 18px 4px 8px;
text-align: center;
color: #7d8899;
font-size: 13px;
line-height: 1.8;
}
.image-viewer {
position: fixed;
inset: 0;
display: none;
z-index: 1000;
}
.image-viewer.is-open {
display: grid;
place-items: center;
}
.image-viewer__mask {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.78);
backdrop-filter: blur(4px);
}
.image-viewer__content {
position: relative;
z-index: 1;
width: min(92vw, 1080px);
max-height: 90vh;
margin: 0;
padding: 18px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32);
}
.image-viewer__content img {
display: block;
width: 100%;
max-height: 84vh;
object-fit: contain;
}
.image-viewer__close {
position: absolute;
top: 10px;
right: 12px;
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: var(--traceability-primary-tint);
color: var(--traceability-primary-strong);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
@media (max-width: 992px) {
.hero,
.form-grid,
@@ -380,7 +479,7 @@ a {
@media (max-width: 768px) {
.page-shell {
padding: 24px 18px 44px;
padding: 16px 12px 32px;
}
.tabs-nav {
@@ -393,4 +492,8 @@ a {
padding: 0 10px;
font-size: 13px;
}
.hero h1 {
font-size: 26px;
}
}
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
@@ -6,7 +6,7 @@
<title>${page.batchName} - 溯源信息</title>
<link rel="stylesheet" href="/static/traceability.css" />
</head>
<body>
<body style="--traceability-primary:${page.themeColor};">
<div class="page-shell">
<#if page.coverImage?has_content>
<section class="cover-panel">
@@ -37,10 +37,6 @@
</div>
<div class="hero__aside">
<div class="summary-card">
<span>发布时间</span>
<strong>${page.publishedAt}</strong>
</div>
<#if page.tagsText != "暂无标签">
<div class="summary-card">
<span>标签</span>
@@ -78,7 +74,14 @@
<div class="kv-card">
<span>${entry.label}</span>
<#if entry.type == "image" && entry.value?has_content && entry.value != "未填写">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
<button class="kv-image-button" type="button" data-image-src="${entry.value}" data-image-alt="${entry.label}">
<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>
<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>
</#if>
@@ -108,7 +111,14 @@
<div class="kv-card">
<span>${entry.label}</span>
<#if entry.type == "image" && entry.value?has_content && entry.value != "未填写">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
<button class="kv-image-button" type="button" data-image-src="${entry.value}" data-image-alt="${entry.label}">
<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>
<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>
</#if>
@@ -180,8 +190,64 @@
</div>
<script>
const themeColor = '${page.themeColor}';
function hexToRgb(hex) {
const normalized = (hex || '').replace('#', '').trim();
if (!/^[0-9a-fA-F]{6}$/.test(normalized)) {
return null;
}
return {
r: parseInt(normalized.slice(0, 2), 16),
g: parseInt(normalized.slice(2, 4), 16),
b: parseInt(normalized.slice(4, 6), 16),
};
}
function rgbToHex(r, g, b) {
return '#' + [r, g, b]
.map((value) => Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, '0'))
.join('');
}
function mixColor(baseHex, targetHex, ratio) {
const base = hexToRgb(baseHex);
const target = hexToRgb(targetHex);
if (!base || !target) {
return baseHex;
}
const mix = (from, to) => from + (to - from) * ratio;
return rgbToHex(mix(base.r, target.r), mix(base.g, target.g), mix(base.b, target.b));
}
function hexToRgba(hex, alpha) {
const rgb = hexToRgb(hex);
if (!rgb) {
return 'rgba(29, 78, 216, ' + alpha + ')';
}
return 'rgba(' + rgb.r + ', ' + rgb.g + ', ' + rgb.b + ', ' + alpha + ')';
}
const primary = hexToRgb(themeColor) ? themeColor : '#1f4fd6';
const strong = mixColor(primary, '#000000', 0.18);
const soft = hexToRgba(primary, 0.12);
const border = hexToRgba(primary, 0.18);
const glow = hexToRgba(primary, 0.08);
const tint = mixColor(primary, '#ffffff', 0.92);
const tintStrong = mixColor(primary, '#ffffff', 0.86);
const tintDeep = mixColor(primary, '#ffffff', 0.78);
const body = document.body;
body.style.setProperty('--traceability-primary', primary);
body.style.setProperty('--traceability-primary-strong', strong);
body.style.setProperty('--traceability-primary-soft', soft);
body.style.setProperty('--traceability-primary-border', border);
body.style.setProperty('--traceability-primary-glow', glow);
body.style.setProperty('--traceability-primary-tint', tint);
body.style.setProperty('--traceability-primary-tint-strong', tintStrong);
body.style.setProperty('--traceability-primary-tint-deep', tintDeep);
const tabButtons = document.querySelectorAll('.tab-btn');
const tabPanels = document.querySelectorAll('.tab-panel');
const imageButtons = document.querySelectorAll('.kv-image-button');
tabButtons.forEach((button) => {
button.addEventListener('click', () => {
@@ -193,6 +259,34 @@
});
});
const viewer = document.createElement('div');
viewer.className = 'image-viewer';
viewer.innerHTML = '<div class="image-viewer__mask"></div><figure class="image-viewer__content"><button class="image-viewer__close" type="button" aria-label="关闭放大图">×</button><img alt=""></figure>';
document.body.appendChild(viewer);
const viewerImage = viewer.querySelector('.image-viewer__content img');
const closeViewer = () => viewer.classList.remove('is-open');
viewer.addEventListener('click', (event) => {
const target = event.target;
if (target === viewer || target.classList.contains('image-viewer__mask') || target.classList.contains('image-viewer__close')) {
closeViewer();
}
});
imageButtons.forEach((button) => {
button.addEventListener('click', () => {
const src = button.dataset.imageSrc || '';
const alt = button.dataset.imageAlt || '';
if (!src) {
return;
}
viewerImage.src = src;
viewerImage.alt = alt;
viewer.classList.add('is-open');
});
});
if (window.location.search.includes('result=')) {
const nextUrl = window.location.pathname + window.location.hash;
window.history.replaceState({}, '', nextUrl);
@@ -200,3 +294,4 @@
</script>
</body>
</html>
@@ -6,6 +6,7 @@ import org.jetbrains.exposed.v1.datetime.timestamp
object TraceabilityTemplatesTable : UUIDTable("traceability_templates") {
val name = varchar("name", 120)
val description = text("description").default("")
val remark = text("remark").default("")
val productName = varchar("product_name", 120).default("")
val industryName = varchar("industry_name", 120).default("")
val coverImage = text("cover_image").default("")
@@ -32,6 +33,7 @@ object TraceabilityPreviewPagesTable : UUIDTable("traceability_preview_pages") {
val name = varchar("name", 120)
val previewCode = varchar("preview_code", 120).uniqueIndex()
val description = text("description").default("")
val remark = text("remark").default("")
val productName = varchar("product_name", 120).default("")
val coverImage = text("cover_image").default("")
val themeColor = varchar("theme_color", 20).default("#1f4fd6")
@@ -13,6 +13,7 @@ data class TraceFieldStyleRequest(
@Serializable
data class TraceFieldDefinitionRequest(
val sort: Int = 0,
val key: String,
val label: String,
val type: String = "string",
@@ -51,6 +52,7 @@ data class TracePreviewNodeRequest(
data class SaveTraceTemplateRequest(
val name: String,
val description: String = "",
val remark: String = "",
val productName: String = "",
val industryName: String = "",
val coverImage: String = "",
@@ -63,6 +65,7 @@ data class SaveTraceTemplateRequest(
data class SaveTracePreviewPageRequest(
val name: String,
val description: String = "",
val remark: String = "",
val productName: String = "",
val coverImage: String = "",
val themeColor: String = "#1f4fd6",
@@ -21,6 +21,7 @@ data class TraceFieldStyleResponse(
@Serializable
data class TraceFieldDefinitionResponse(
val sort: Int = 0,
val key: String,
val label: String,
val type: String = "string",
@@ -62,6 +63,7 @@ data class TraceTemplateSummaryResponse(
val id: String,
val name: String,
val description: String,
val remark: String,
val productName: String,
val industryName: String,
val coverImage: String,
@@ -78,6 +80,7 @@ data class TraceTemplateDetailResponse(
val id: String,
val name: String,
val description: String,
val remark: String,
val productName: String,
val industryName: String,
val coverImage: String,
@@ -107,6 +110,7 @@ data class TracePreviewPageSummaryResponse(
val name: String,
val previewCode: String,
val description: String,
val remark: String,
val productName: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
@@ -122,6 +126,7 @@ data class TracePreviewPageDetailResponse(
val name: String,
val previewCode: String,
val description: String,
val remark: String,
val productName: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
@@ -110,6 +110,7 @@ object TraceabilityDao {
name = it[TraceabilityPreviewPagesTable.name],
previewCode = code,
description = it[TraceabilityPreviewPagesTable.description],
remark = it[TraceabilityPreviewPagesTable.remark],
productName = it[TraceabilityPreviewPagesTable.productName],
coverImage = it[TraceabilityPreviewPagesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(it[TraceabilityPreviewPagesTable.coverImage]).orEmpty(),
@@ -131,6 +132,7 @@ object TraceabilityDao {
name = row[TraceabilityPreviewPagesTable.name],
previewCode = code,
description = row[TraceabilityPreviewPagesTable.description],
remark = row[TraceabilityPreviewPagesTable.remark],
productName = row[TraceabilityPreviewPagesTable.productName],
coverImage = row[TraceabilityPreviewPagesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(row[TraceabilityPreviewPagesTable.coverImage]).orEmpty(),
@@ -148,6 +150,7 @@ object TraceabilityDao {
it[name] = request.name
it[previewCode] = buildPreviewCode()
it[description] = request.description
it[remark] = request.remark
it[productName] = request.productName
it[coverImage] = request.coverImage
it[themeColor] = request.themeColor
@@ -160,6 +163,7 @@ object TraceabilityDao {
TraceabilityPreviewPagesTable.update({ TraceabilityPreviewPagesTable.id eq currentId }) {
it[name] = request.name
it[description] = request.description
it[remark] = request.remark
it[productName] = request.productName
it[coverImage] = request.coverImage
it[themeColor] = request.themeColor
@@ -196,6 +200,7 @@ object TraceabilityDao {
val request = SaveTraceTemplateRequest(
name = detail.name,
description = detail.description,
remark = detail.remark,
productName = detail.productName,
coverImage = detail.coverImage,
themeColor = detail.themeColor,
@@ -483,6 +488,7 @@ object TraceabilityDao {
id = it[TraceabilityTemplatesTable.id].value.toString(),
name = it[TraceabilityTemplatesTable.name],
description = it[TraceabilityTemplatesTable.description],
remark = it[TraceabilityTemplatesTable.remark],
productName = it[TraceabilityTemplatesTable.productName],
industryName = it[TraceabilityTemplatesTable.industryName],
coverImage = it[TraceabilityTemplatesTable.coverImage],
@@ -505,6 +511,7 @@ object TraceabilityDao {
id = templateRow[TraceabilityTemplatesTable.id].value.toString(),
name = templateRow[TraceabilityTemplatesTable.name],
description = templateRow[TraceabilityTemplatesTable.description],
remark = templateRow[TraceabilityTemplatesTable.remark],
productName = templateRow[TraceabilityTemplatesTable.productName],
industryName = templateRow[TraceabilityTemplatesTable.industryName],
coverImage = templateRow[TraceabilityTemplatesTable.coverImage],
@@ -521,6 +528,7 @@ object TraceabilityDao {
val currentId = templateId ?: TraceabilityTemplatesTable.insertAndGetId {
it[name] = request.name
it[description] = request.description
it[remark] = request.remark
it[productName] = request.productName
it[industryName] = request.industryName
it[coverImage] = request.coverImage
@@ -534,6 +542,7 @@ object TraceabilityDao {
TraceabilityTemplatesTable.update({ TraceabilityTemplatesTable.id eq currentId }) {
it[name] = request.name
it[description] = request.description
it[remark] = request.remark
it[productName] = request.productName
it[industryName] = request.industryName
it[coverImage] = request.coverImage
@@ -952,6 +961,7 @@ object TraceabilityDao {
private fun decodeFields(raw: String): List<TraceFieldDefinitionResponse> {
return json.decodeFromString<List<TraceFieldDefinitionRequest>>(raw).map {
TraceFieldDefinitionResponse(
sort = it.sort,
key = it.key,
label = it.label,
type = it.type,
@@ -1,6 +1,13 @@
import { requestClient } from '#/api/request';
export namespace TraceabilityApi {
export interface CoordinateValue {
lng?: number | null;
lat?: number | null;
address?: string;
source?: 'address' | 'manual' | 'map' | string;
}
export interface OssStoredValue {
bucketName: string;
objectName: string;
@@ -21,6 +28,7 @@ export namespace TraceabilityApi {
}
export interface FieldDefinition {
sort?: number;
key: string;
label: string;
type: string;
@@ -71,6 +79,7 @@ export namespace TraceabilityApi {
id: string;
name: string;
description: string;
remark: string;
productName: string;
industryName: string;
coverImage: string;
@@ -91,6 +100,7 @@ export namespace TraceabilityApi {
name: string;
previewCode: string;
description: string;
remark: string;
productName: string;
coverImage: string;
coverImagePreviewUrl?: string;
@@ -1,7 +1,8 @@
<script lang="ts" setup>
import type { TraceabilityApi } from '#/api';
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import {
@@ -16,8 +17,8 @@ import {
message,
Modal,
Row,
Space,
Select,
Space,
Switch,
Tabs,
Tag,
@@ -34,21 +35,21 @@ import {
getTraceabilityOverview,
getTraceabilityTemplate,
getTraceabilityTemplates,
uploadTraceabilityImage,
updateTraceabilityNodeLibrary,
updateTraceabilityTemplate,
uploadTraceabilityImage,
} from '#/api';
import type { TraceabilityApi } from '#/api';
import CoordinateFieldEditor from './components/CoordinateFieldEditor.vue';
import {
buildOssStoredValue,
createLocalUniqueId,
cloneTemplateForSave,
createEmptyField,
createEmptyNode,
createLocalUniqueId,
fieldTypeOptions,
getImagePreviewSrc,
getFieldTypeLabel,
getImagePreviewSrc,
serializeImageValue,
stripOssTempUrl,
} from './shared';
@@ -69,6 +70,8 @@ const selectedLibraryNodeId = ref('');
const selectedTemplateFieldKey = ref('');
const selectedLibraryFieldKey = ref('');
const draggingTemplateNodeId = ref('');
const draggingTemplateFieldKey = ref('');
const draggingLibraryFieldKey = ref('');
const overview = reactive<TraceabilityApi.Overview>({
batchCount: 0,
feedbackCount: 0,
@@ -91,6 +94,10 @@ const editor = reactive<
themeColor: '#1f4fd6',
});
const nodeIndex = ref(0);
const templateSnapshot = ref('');
const leaveDialogVisible = ref(false);
const pendingTemplateSelectId = ref('');
const bypassTemplateLeaveGuard = ref(false);
const createTemplateVisible = ref(false);
const coverHistoryVisible = ref(false);
@@ -104,11 +111,13 @@ const createTemplateForm = reactive<{
industryName: string;
name: string;
productName: string;
remark: string;
themeColor: string;
}>({
coverImage: '',
coverImagePreviewUrl: '',
description: '',
remark: '',
industryName: '',
name: '新建溯源模板',
productName: '',
@@ -133,6 +142,7 @@ const selectedTemplateSummary = computed(() =>
const isTemplatePublished = computed(
() => selectedTemplateSummary.value?.status === 'active',
);
const isTemplateDraft = computed(() => !isTemplatePublished.value);
const currentLibraryNode = computed(() =>
nodeLibrary.value.find((item) => item.libraryId === selectedLibraryNodeId.value),
);
@@ -162,8 +172,9 @@ function toEditableLibraryNode(item: TraceabilityApi.NodeLibraryItem): EditableT
category: item.category,
consumerVisible: item.consumerVisible,
description: item.description,
fields: item.fields.map((field) => ({
fields: item.fields.map((field, fieldIndex) => ({
...field,
sort: field.sort ?? fieldIndex,
defaultPreviewUrl: field.defaultPreviewUrl,
options: [...(field.options ?? [])],
fieldStyle: {
@@ -183,7 +194,8 @@ function buildLibraryPayload(node: EditableTemplateNode) {
category: node.category,
consumerVisible: node.consumerVisible,
description: node.description,
fields: (node.fields ?? []).map((field) => ({
fields: (node.fields ?? []).map((field, fieldIndex) => ({
sort: field.sort ?? fieldIndex,
defaultValue:
field.type === 'image'
? stripOssTempUrl(field.defaultValue ?? '')
@@ -236,13 +248,53 @@ function applyEditor(detail: TraceabilityApi.TemplateDetail) {
editor.nodes = (editor.nodes ?? []).map((node) => ({
...node,
id: node.id || createLocalUniqueId('node'),
fields: (node.fields ?? []).map((field, fieldIndex) => ({
...field,
sort: field.sort ?? fieldIndex,
})),
locked: !!node.locked,
}));
nodeIndex.value = 0;
syncSelectedTemplateField();
templateSnapshot.value = JSON.stringify(cloneTemplateForSave(editor));
}
const hasTemplateChanges = computed(() => {
if (!selectedTemplateId.value) return false;
return templateSnapshot.value !== JSON.stringify(cloneTemplateForSave(editor));
});
function openTemplateLeaveDialog(nextId: string) {
pendingTemplateSelectId.value = nextId;
leaveDialogVisible.value = true;
}
async function confirmTemplateLeave(action: 'cancel' | 'discard' | 'save') {
leaveDialogVisible.value = false;
const nextId = pendingTemplateSelectId.value;
pendingTemplateSelectId.value = '';
if (action === 'cancel' || !nextId) return;
if (action === 'save') {
await saveTemplate();
}
bypassTemplateLeaveGuard.value = true;
try {
await selectTemplate(nextId);
} finally {
bypassTemplateLeaveGuard.value = false;
}
}
async function selectTemplate(id: string) {
if (
!bypassTemplateLeaveGuard.value &&
selectedTemplateId.value &&
id !== selectedTemplateId.value &&
hasTemplateChanges.value
) {
openTemplateLeaveDialog(id);
return;
}
selectedTemplateId.value = id;
const detail = await getTraceabilityTemplate(id);
applyEditor(detail);
@@ -252,6 +304,7 @@ function openCreateTemplateModal() {
createTemplateForm.coverImage = '';
createTemplateForm.coverImagePreviewUrl = '';
createTemplateForm.description = '';
createTemplateForm.remark = '';
createTemplateForm.industryName = '';
createTemplateForm.name = '新建溯源模板';
createTemplateForm.productName = '';
@@ -337,7 +390,7 @@ async function handleTemplateCoverUpload(event: Event) {
}
function triggerTemplateCoverSelect() {
const input = document.getElementById('template-cover-upload');
const input = document.querySelector('#template-cover-upload');
input?.click();
}
@@ -347,6 +400,7 @@ async function createTemplate() {
const created = await createTraceabilityTemplate({
coverImage: serializeImageValue(createTemplateForm.coverImage),
description: createTemplateForm.description,
remark: createTemplateForm.remark,
industryName: createTemplateForm.industryName,
name: createTemplateForm.name.trim() || '新建溯源模板',
nodes: [createEmptyNode('public'), createEmptyNode('business')],
@@ -436,8 +490,9 @@ function addNodeFromLibrary(category: 'business' | 'public', libraryId?: string)
category: source.category,
consumerVisible: source.consumerVisible,
description: source.description,
fields: source.fields.map((field) => ({
fields: source.fields.map((field, fieldIndex) => ({
...field,
sort: field.sort ?? fieldIndex,
options: [...(field.options ?? [])],
})),
libraryId: source.libraryId,
@@ -489,14 +544,14 @@ function moveTemplateNode(
const [movedNode] = movedNodes.splice(fromIndex, 1);
movedNodes.splice(toIndex, 0, movedNode);
const reordered = nodes.slice();
const reordered = [...nodes];
let cursor = 0;
editor.nodes = reordered.map((node) =>
node.category === category ? movedNodes[cursor++] : node,
);
const nextIndex = (editor.nodes ?? []).findIndex((node) => node.id === draggedId);
if (nextIndex >= 0) {
if (nextIndex !== -1) {
nodeIndex.value = nextIndex;
}
}
@@ -526,6 +581,7 @@ function clearTemplateNodeDragState() {
function addField(target: EditableTemplateNode | null | undefined) {
if (target?.locked) return;
const field = createEmptyField();
field.sort = target?.fields.length ?? 0;
target?.fields.push(field);
if (target === currentNode.value) {
selectedTemplateFieldKey.value = field.key;
@@ -539,6 +595,9 @@ function removeField(target: EditableTemplateNode | null | undefined, index: num
if (target?.locked) return;
const removed = target?.fields[index];
target?.fields.splice(index, 1);
target?.fields.forEach((field, fieldIndex) => {
field.sort = fieldIndex;
});
if (target === currentNode.value && removed?.key === selectedTemplateFieldKey.value) {
selectedTemplateFieldKey.value = target?.fields[0]?.key || '';
}
@@ -559,6 +618,54 @@ function confirmRemoveField(target: EditableTemplateNode | null | undefined, ind
});
}
function moveField(
target: EditableTemplateNode | null | undefined,
draggedKey: string,
targetKey: string,
) {
const fields = target?.fields ?? [];
if (!draggedKey || !targetKey || draggedKey === targetKey) return;
const fromIndex = fields.findIndex((field) => field.key === draggedKey);
const toIndex = fields.findIndex((field) => field.key === targetKey);
if (fromIndex === -1 || toIndex === -1) return;
const movedFields = [...fields];
const [movedField] = movedFields.splice(fromIndex, 1);
if (!movedField) return;
movedFields.splice(toIndex, 0, movedField);
target!.fields = movedFields.map((field, fieldIndex) => ({
...field,
sort: fieldIndex,
}));
}
function handleTemplateFieldDragStart(fieldKey?: string) {
if (isTemplatePublished.value || currentNodeLocked.value) return;
draggingTemplateFieldKey.value = fieldKey || '';
}
function handleTemplateFieldDrop(targetFieldKey?: string) {
if (isTemplatePublished.value || currentNodeLocked.value) return;
moveField(currentNode.value, draggingTemplateFieldKey.value, targetFieldKey || '');
draggingTemplateFieldKey.value = '';
}
function clearTemplateFieldDragState() {
draggingTemplateFieldKey.value = '';
}
function handleLibraryFieldDragStart(fieldKey?: string) {
draggingLibraryFieldKey.value = fieldKey || '';
}
function handleLibraryFieldDrop(targetFieldKey?: string) {
moveField(currentLibraryNode.value, draggingLibraryFieldKey.value, targetFieldKey || '');
draggingLibraryFieldKey.value = '';
}
function clearLibraryFieldDragState() {
draggingLibraryFieldKey.value = '';
}
function getFieldUploadKey(scope: string, field: TraceabilityApi.FieldDefinition) {
return `${scope}:${field.key}`;
}
@@ -644,12 +751,12 @@ function getPresetValue(field: TraceabilityApi.FieldDefinition) {
if (field.type === 'multi_select') {
return Array.isArray(field.defaultValue)
? field.defaultValue
: field.defaultValue
: (field.defaultValue
? String(field.defaultValue)
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
: []);
}
return field.defaultValue;
}
@@ -726,11 +833,11 @@ async function removeLibraryNode() {
title: '删除节点库节点',
content: `确认删除节点库节点“${currentName}”吗?`,
async onOk() {
if (!currentLibraryNode.value?.persisted) {
nodeLibrary.value = nodeLibrary.value.filter((item) => item.libraryId !== currentId);
} else {
if (currentLibraryNode.value?.persisted) {
await deleteTraceabilityNodeLibrary(currentId);
await loadNodeLibrary();
} else {
nodeLibrary.value = nodeLibrary.value.filter((item) => item.libraryId !== currentId);
}
selectedLibraryNodeId.value = nodeLibrary.value[0]?.libraryId || '';
message.success('节点库节点已删除');
@@ -758,11 +865,7 @@ async function saveLibraryNode() {
message.success('节点库节点已保存');
}
async function saveTemplate() {
if (isTemplatePublished.value) {
message.warning('模板已发布,不可修改');
return;
}
async function persistTemplate(status?: 'active' | 'draft') {
if (!editor.name?.trim()) {
message.warning('请先填写模板名称');
return;
@@ -773,13 +876,16 @@ async function saveTemplate() {
}
saving.value = true;
try {
const payload = cloneTemplateForSave(editor);
const payload = {
...cloneTemplateForSave(editor),
status: status ?? editor.status ?? 'draft',
};
if (selectedTemplateId.value) {
await updateTraceabilityTemplate(selectedTemplateId.value, payload);
message.success('模板已更新');
message.success(status === 'active' ? '模板已发布' : '模板已更新');
} else {
await createTraceabilityTemplate(payload);
message.success('模板已创建');
message.success(status === 'active' ? '模板已创建并发布' : '模板已创建');
}
await loadAll();
} finally {
@@ -787,22 +893,20 @@ async function saveTemplate() {
}
}
async function saveTemplate() {
if (isTemplatePublished.value) {
message.warning('模板已发布,不可修改');
return;
}
await persistTemplate('draft');
}
async function publishTemplate() {
if (!selectedTemplateId.value) {
message.warning('请先创建并选择模板');
return;
}
saving.value = true;
try {
await updateTraceabilityTemplate(selectedTemplateId.value, {
...cloneTemplateForSave(editor),
status: 'active',
});
message.success('模板已发布');
await loadAll();
} finally {
saving.value = false;
}
await persistTemplate('active');
}
function removeTemplate(id: string) {
@@ -837,9 +941,9 @@ onMounted(async () => {
</script>
<template>
<Page auto-content-height>
<div class="trace-admin-page">
<div class="trace-admin">
<Tabs v-model:active-key="activeTab">
<Tabs v-model:active-key="activeTab">
<Tabs.TabPane key="templates" tab="模板中心">
<Row :gutter="[16, 16]" align="stretch">
<Col :lg="8" :xs="24">
@@ -868,6 +972,9 @@ onMounted(async () => {
<span>{{ item.nodeCount }} 节点</span>
<span>{{ item.batchCount }} 批次</span>
</div>
<p class="template-card__remark">
{{ item.remark || '暂无备注' }}
</p>
<p>{{ item.description || '暂无模板说明' }}</p>
<div class="template-card__footer">
<span>{{ item.productName || '未设置产品名称' }}</span>
@@ -883,30 +990,59 @@ onMounted(async () => {
<Col :lg="16" :xs="24">
<div class="template-main">
<div class="template-summary" v-if="editor.name">
<div class="summary-inline">
<div class="summary-inline__item">
<span>模板名称</span>
<strong>{{ editor.name }}</strong>
</div>
<div class="summary-inline__item">
<span>产品名称</span>
<strong>{{ editor.productName || '未设置' }}</strong>
</div>
<div class="summary-inline__item">
<span>行业名称</span>
<strong>{{ editor.industryName || '未设置' }}</strong>
</div>
<div class="summary-inline__item">
<span>模板状态</span>
<strong>{{ selectedTemplateSummary?.status === 'active' ? '已发布' : '草稿' }}</strong>
</div>
<div class="summary-inline__item summary-inline__item--wide">
<span>模板说明</span>
<strong>{{ editor.description || '暂无模板说明' }}</strong>
</div>
</div>
</div>
<Card
v-if="selectedTemplateId"
class="panel-card template-summary"
:bordered="false"
title="基础信息"
>
<template #extra>
<Space>
<Button
type="primary"
:disabled="!isTemplateDraft"
:loading="saving"
@click="saveTemplate"
>
保存草稿
</Button>
<Button
type="primary"
:disabled="!isTemplateDraft"
:loading="saving"
@click="publishTemplate"
>
发布模板
</Button>
</Space>
</template>
<Row :gutter="[12, 12]">
<Col :md="12" :xs="24">
<label class="field-label">模板名称</label>
<Input v-model:value="editor.name" :disabled="!isTemplateDraft" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">产品名称</label>
<Input v-model:value="editor.productName" :disabled="!isTemplateDraft" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">行业名称</label>
<Input v-model:value="editor.industryName" :disabled="!isTemplateDraft" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">模板备注</label>
<Input v-model:value="editor.remark" :disabled="!isTemplateDraft" placeholder="仅内部可见" />
</Col>
<Col :span="24">
<label class="field-label">模板说明</label>
<Input.TextArea
v-model:value="editor.description"
:disabled="!isTemplateDraft"
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</Col>
</Row>
</Card>
<Empty v-else description="点击左侧模板或新建模板开始配置" />
<Card class="panel-card editor-panel template-main__editor" title="节点编排">
@@ -929,7 +1065,7 @@ onMounted(async () => {
>
{{ item.name }}
</Menu.Item>
<Menu.Item v-if="!businessLibraryNodes.length" key="business-empty" disabled>
<Menu.Item v-if="businessLibraryNodes.length === 0" key="business-empty" disabled>
暂无业务节点
</Menu.Item>
</Menu.SubMenu>
@@ -953,7 +1089,7 @@ onMounted(async () => {
>
{{ item.name }}
</Menu.Item>
<Menu.Item v-if="!publicLibraryNodes.length" key="public-empty" disabled>
<Menu.Item v-if="publicLibraryNodes.length === 0" key="public-empty" disabled>
暂无公共资料节点
</Menu.Item>
</Menu.SubMenu>
@@ -961,21 +1097,7 @@ onMounted(async () => {
</template>
</Dropdown>
</Space>
<Space wrap class="editor-toolbar__right">
<Button :loading="saving" :disabled="isTemplatePublished" @click="saveTemplate">
保存草稿
</Button>
<Button
v-if="!isTemplatePublished"
type="primary"
:loading="saving"
@click="publishTemplate"
>
发布模板
</Button>
</Space>
</div>
</div>
</template>
<div class="node-lane">
@@ -1011,7 +1133,7 @@ onMounted(async () => {
删除节点
</Button>
</button>
<div v-if="!publicNodes.length" class="lane-empty">还没有公共资料节点</div>
<div v-if="publicNodes.length === 0" class="lane-empty">还没有公共资料节点</div>
</div>
</div>
@@ -1048,7 +1170,7 @@ onMounted(async () => {
删除节点
</Button>
</button>
<div v-if="!businessNodes.length" class="lane-empty">还没有业务流程节点</div>
<div v-if="businessNodes.length === 0" class="lane-empty">还没有业务流程节点</div>
</div>
</div>
@@ -1107,7 +1229,12 @@ onMounted(async () => {
:key="`${field.key}-${fieldIndex}`"
class="field-pill"
:class="{ active: field.key === currentTemplateField?.key }"
:draggable="!isTemplatePublished && !currentNodeLocked"
type="button"
@dragstart="handleTemplateFieldDragStart(field.key)"
@dragover.prevent
@drop.prevent="handleTemplateFieldDrop(field.key)"
@dragend="clearTemplateFieldDragState"
@click="selectedTemplateFieldKey = field.key"
>
<span>{{ getFieldTypeLabel(field.type) }}</span>
@@ -1124,7 +1251,7 @@ onMounted(async () => {
删除字段
</Button>
</button>
<div v-if="!currentNode.fields.length" class="lane-empty">还没有字段请先新增字段</div>
<div v-if="currentNode.fields.length === 0" class="lane-empty">还没有字段请先新增字段</div>
</div>
<div v-if="currentTemplateField" class="field-card">
<div class="field-card__header">
@@ -1305,6 +1432,12 @@ onMounted(async () => {
value-format="YYYY-MM-DD HH:mm:ss"
@update:value="(value) => updatePresetValue(currentTemplateField, value)"
/>
<CoordinateFieldEditor
v-else-if="currentTemplateField.type === 'coordinate'"
:disabled="isFieldPresetLocked(currentTemplateField)"
:model-value="getPresetValue(currentTemplateField)"
@update:model-value="(value) => updatePresetValue(currentTemplateField, value)"
/>
<Input.TextArea
v-else-if="currentTemplateField.type === 'json'"
:auto-size="{ minRows: 3, maxRows: 5 }"
@@ -1459,7 +1592,12 @@ onMounted(async () => {
:key="`${field.key}-${fieldIndex}`"
class="field-pill"
:class="{ active: field.key === currentLibraryField?.key }"
draggable="true"
type="button"
@dragstart="handleLibraryFieldDragStart(field.key)"
@dragover.prevent
@drop.prevent="handleLibraryFieldDrop(field.key)"
@dragend="clearLibraryFieldDragState"
@click="selectedLibraryFieldKey = field.key"
>
<span>{{ getFieldTypeLabel(field.type) }}</span>
@@ -1469,7 +1607,7 @@ onMounted(async () => {
删除字段
</Button>
</button>
<div v-if="!currentLibraryNode.fields.length" class="lane-empty">还没有字段请先新增字段</div>
<div v-if="currentLibraryNode.fields.length === 0" class="lane-empty">还没有字段,请先新增字段</div>
</div>
<div v-if="currentLibraryField" class="field-card">
<div class="field-card__header">
@@ -1614,6 +1752,11 @@ onMounted(async () => {
value-format="YYYY-MM-DD HH:mm:ss"
@update:value="(value) => updatePresetValue(currentLibraryField, value)"
/>
<CoordinateFieldEditor
v-else-if="currentLibraryField.type === 'coordinate'"
:model-value="getPresetValue(currentLibraryField)"
@update:model-value="(value) => updatePresetValue(currentLibraryField, value)"
/>
<Input.TextArea
v-else-if="currentLibraryField.type === 'json'"
:auto-size="{ minRows: 3, maxRows: 5 }"
@@ -1710,7 +1853,7 @@ onMounted(async () => {
v-model="createTemplateForm.themeColor"
class="color-input"
type="color"
>
/>
<span>{{ createTemplateForm.themeColor }}</span>
</div>
</Col>
@@ -1724,7 +1867,7 @@ onMounted(async () => {
<img
:src="getImagePreviewSrc(createTemplateForm.coverImage, createTemplateForm.coverImagePreviewUrl)"
alt="模板封面图"
>
/>
</div>
<div class="cover-selector__actions">
<input
@@ -1733,7 +1876,7 @@ onMounted(async () => {
type="file"
accept="image/*"
@change="handleTemplateCoverUpload"
>
/>
<Button
:loading="uploadingFieldKey === 'template-cover'"
@click="triggerTemplateCoverSelect"
@@ -1761,6 +1904,14 @@ onMounted(async () => {
placeholder="说明此模板适用对象展示重点和字段规范"
/>
</Col>
<Col :span="24">
<label class="field-label">备注</label>
<Input.TextArea
v-model:value="createTemplateForm.remark"
:auto-size="{ minRows: 2, maxRows: 4 }"
placeholder="仅内部可见用于记录模板背景使用限制或补充说明"
/>
</Col>
</Row>
</Modal>
@@ -1769,13 +1920,13 @@ onMounted(async () => {
title="选择历史封面图"
:footer="null"
>
<div v-if="coverHistoryItems.length" class="cover-history-grid">
<div v-if="coverHistoryItems.length > 0" class="cover-history-grid">
<div
v-for="item in coverHistoryItems"
:key="item.id"
class="cover-history-card"
>
<img :src="item.previewUrl" :alt="item.fileName">
<img :src="item.previewUrl" :alt="item.fileName" />
<div class="cover-history-card__meta">
<strong>{{ item.fileName || item.objectName }}</strong>
<span>{{ item.createdAt }}</span>
@@ -1798,8 +1949,23 @@ onMounted(async () => {
</div>
<Empty v-else :description="coverHistoryLoading ? '正在加载历史封面图…' : '暂无历史封面图'" />
</Modal>
<Modal
v-model:open="leaveDialogVisible"
title="有未保存的改动"
:mask-closable="false"
:closable="true"
:footer="null"
@cancel="confirmTemplateLeave('cancel')"
>
<p>当前模板有未保存的修改,切换前要怎么处理?</p>
<div class="leave-dialog__actions">
<Button block @click="confirmTemplateLeave('save')">保存</Button>
<Button block @click="confirmTemplateLeave('discard')">不保存</Button>
<Button block @click="confirmTemplateLeave('cancel')">取消</Button>
</div>
</Modal>
</div>
</Page>
</template>
<style scoped>
@@ -2037,6 +2203,13 @@ onMounted(async () => {
margin: 8px 0 6px;
}
.template-card__remark {
margin: 6px 0 0;
color: #556070;
font-size: 12px;
line-height: 1.5;
}
.template-card__footer {
display: flex;
justify-content: space-between;
@@ -2169,6 +2342,7 @@ onMounted(async () => {
}
.field-pill {
cursor: grab;
min-width: 200px;
max-width: 220px;
border: 1px solid #e5ebf5;
@@ -2366,6 +2540,16 @@ onMounted(async () => {
gap: 8px;
}
.leave-dialog__actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.leave-dialog__actions :deep(.ant-btn) {
width: 100%;
}
@media (max-width: 960px) {
.field-toggle-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -0,0 +1,368 @@
<script lang="ts" setup>
import type { TraceabilityApi } from '#/api';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { Button, Input, Modal, Space, message } from 'ant-design-vue';
import {
buildCoordinateMapUrl,
formatCoordinateValue,
normalizeCoordinateValue,
} from '../shared';
const props = withDefaults(defineProps<{
disabled?: boolean;
modelValue?: any;
placeholder?: string;
}>(), {
disabled: false,
modelValue: null,
placeholder: '',
});
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('');
function syncLocalValue() {
lngText.value = currentValue.value?.lng === null || currentValue.value?.lng === undefined
? ''
: String(currentValue.value.lng);
latText.value = currentValue.value?.lat === null || currentValue.value?.lat === undefined
? ''
: String(currentValue.value.lat);
}
watch(() => props.modelValue, syncLocalValue, { immediate: true, deep: true });
function emitCurrent(source: string = 'manual') {
const normalized = normalizeCoordinateValue({
lng: lngText.value === '' ? null : Number(lngText.value),
lat: latText.value === '' ? null : Number(latText.value),
source,
});
emit('update:modelValue', normalized);
}
function updateLng(value: string) {
lngText.value = value.trim();
emitCurrent();
}
function updateLat(value: string) {
latText.value = value.trim();
emitCurrent();
}
function clearValue() {
lngText.value = '';
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>
<div class="coordinate-editor">
<div class="coordinate-editor__inputs">
<Input
:disabled="disabled"
placeholder="请输入经度,如 104.0668"
:value="lngText"
@update:value="(value) => updateLng(String(value ?? ''))"
/>
<Input
:disabled="disabled"
placeholder="请输入纬度,如 30.5728"
:value="latText"
@update:value="(value) => updateLat(String(value ?? ''))"
/>
</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>
<style scoped>
.coordinate-editor {
display: grid;
gap: 10px;
}
.coordinate-editor__inputs {
display: grid;
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>
@@ -19,7 +19,13 @@ import {
import { getTraceabilityBatches, getTraceabilityPreviewDetail } from '#/api';
import { formatFieldValue, getFieldDisplayStyle, getImagePreviewSrc } from './shared';
import {
buildCoordinateEmbedUrl,
buildCoordinateMapUrl,
formatFieldValue,
getFieldDisplayStyle,
getImagePreviewSrc,
} from './shared';
const loading = ref(false);
const batches = ref<TraceabilityApi.BatchSummary[]>([]);
@@ -32,6 +38,23 @@ const qrCode = useQRCode(publicLink, {
margin: 2,
width: 220,
});
const qrDownloadName = computed(
() => `${detail.value?.batch.batchCode || 'batch'}.png`,
);
function downloadQrCode(dataUrl: string, fileName: string) {
if (!dataUrl) return;
const link = document.createElement('a');
link.href = dataUrl;
link.download = fileName;
document.body.append(link);
link.click();
link.remove();
}
function downloadConsumerQrCode() {
downloadQrCode(qrCode.value, qrDownloadName.value);
}
async function loadBatches() {
batches.value = await getTraceabilityBatches();
@@ -136,6 +159,16 @@ onMounted(loadBatches);
<Row :gutter="[16, 16]" class="feature-row">
<Col :lg="8" :xs="24">
<Card class="panel-card qr-panel" title="二维码">
<template #extra>
<Button
v-if="publicLink"
size="small"
type="primary"
@click="downloadConsumerQrCode"
>
保存二维码
</Button>
</template>
<div class="qr-wrap">
<img :src="qrCode" alt="溯源二维码" class="qr-image" />
<div class="qr-meta">
@@ -210,10 +243,38 @@ onMounted(loadBatches);
<span>{{ entry.label }}</span>
<img
v-if="entry.type === 'image' && entry.value"
:src="getImagePreviewSrc(entry.value, item.valuePreviewUrls?.[entry.key])"
:src="
getImagePreviewSrc(
entry.value,
item.valuePreviewUrls?.[entry.key],
)
"
:alt="entry.label"
class="consumer-image"
/>
<div
v-else-if="entry.type === 'coordinate' && entry.value"
class="coordinate-preview-card"
>
<iframe
v-if="buildCoordinateEmbedUrl(entry.value)"
:src="buildCoordinateEmbedUrl(entry.value)"
class="coordinate-preview-map"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
:title="entry.label"
></iframe>
<strong :style="getFieldDisplayStyle(entry.field)">
{{ formatFieldValue(entry.value) }}
</strong>
<a
v-if="buildCoordinateMapUrl(entry.value)"
:href="buildCoordinateMapUrl(entry.value)"
target="_blank"
rel="noreferrer"
>打开地图</a
>
</div>
<strong v-else :style="getFieldDisplayStyle(entry.field)">
{{ formatFieldValue(entry.value) }}
</strong>
@@ -256,10 +317,38 @@ onMounted(loadBatches);
<span>{{ entry.label }}</span>
<img
v-if="entry.type === 'image' && entry.value"
:src="getImagePreviewSrc(entry.value, item.valuePreviewUrls?.[entry.key])"
:src="
getImagePreviewSrc(
entry.value,
item.valuePreviewUrls?.[entry.key],
)
"
:alt="entry.label"
class="consumer-image"
/>
<div
v-else-if="entry.type === 'coordinate' && entry.value"
class="coordinate-preview-card"
>
<iframe
v-if="buildCoordinateEmbedUrl(entry.value)"
:src="buildCoordinateEmbedUrl(entry.value)"
class="coordinate-preview-map"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
:title="entry.label"
></iframe>
<strong :style="getFieldDisplayStyle(entry.field)">
{{ formatFieldValue(entry.value) }}
</strong>
<a
v-if="buildCoordinateMapUrl(entry.value)"
:href="buildCoordinateMapUrl(entry.value)"
target="_blank"
rel="noreferrer"
>打开地图</a
>
</div>
<strong
v-else
:style="getFieldDisplayStyle(entry.field)"
@@ -521,6 +610,24 @@ onMounted(loadBatches);
background: #fff;
}
.coordinate-preview-card {
display: grid;
gap: 10px;
}
.coordinate-preview-map {
width: 100%;
min-height: 180px;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: #fff;
}
.coordinate-preview-card a {
color: #1d4ed8;
font-size: 12px;
}
.timeline {
display: grid;
gap: 16px;
@@ -1,8 +1,9 @@
<script lang="ts" setup>
import type { TraceabilityApi } from '#/api';
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import {
Button,
Card,
@@ -26,11 +27,11 @@ import {
getTraceabilityBatches,
getTraceabilityTemplates,
publishTraceabilityBatch,
uploadTraceabilityImage,
updateTraceabilityBatchStep,
uploadTraceabilityImage,
} from '#/api';
import type { TraceabilityApi } from '#/api';
import CoordinateFieldEditor from './components/CoordinateFieldEditor.vue';
import {
buildOssStoredValue,
formatFieldValue,
@@ -59,19 +60,49 @@ const formState = reactive({
});
const saving = ref(false);
const uploadingFieldKey = ref('');
const templateFilterId = ref('');
const batchSnapshot = ref('');
const leaveDialogVisible = ref(false);
const pendingBatchSelectId = ref('');
const bypassBatchLeaveGuard = ref(false);
const publishedTemplates = computed(() =>
templates.value.filter((item) => item.status === 'active'),
);
const templateFilterOptions = computed(() => {
const seen = new Map<string, string>();
batches.value.forEach((item) => {
if (item.templateId && item.templateName) {
seen.set(item.templateId, item.templateName);
}
});
return [
{ label: '全部模板', value: '' },
...[...seen.entries()].map(([value, label]) => ({ label, value })),
];
});
const filteredBatches = computed(() =>
templateFilterId.value
? batches.value.filter((item) => item.templateId === templateFilterId.value)
: batches.value,
);
const currentStep = computed(
() => batchDetail.value?.steps?.[stepIndex.value] ?? null,
);
const batchQrCode = useQRCode(
computed(() => batchDetail.value?.publicUrl || ''),
{ errorCorrectionLevel: 'M', margin: 1, width: 220 },
);
const isPublished = computed(() => batchDetail.value?.status === 'published');
const isLockedStep = computed(() => !!currentStep.value?.locked);
const actualCurrentStepIndex = computed(() =>
isPublished.value ? (batchDetail.value?.currentStep ?? 0) : editableStartIndex.value,
isPublished.value
? (batchDetail.value?.currentStep ?? 0)
: editableStartIndex.value,
);
const isCurrentEditableStep = computed(
() => !!batchDetail.value && !isPublished.value,
);
const isCurrentEditableStep = computed(() => !!batchDetail.value && !isPublished.value);
const isLastStep = computed(() => {
if (!batchDetail.value?.steps?.length) return false;
return actualCurrentStepIndex.value >= batchDetail.value.steps.length - 1;
@@ -82,12 +113,38 @@ const allStepsCompleted = computed(() =>
const stepActionText = computed(() =>
isLastStep.value ? '保存并发布' : '保存并切换至下一节点',
);
const batchQrDownloadName = computed(
() => `${batchDetail.value?.batchCode || 'batch'}.png`,
);
function downloadQrCode(dataUrl: string, fileName: string) {
if (!dataUrl) return;
const link = document.createElement('a');
link.href = dataUrl;
link.download = fileName;
document.body.append(link);
link.click();
link.remove();
}
function downloadBatchQrCode() {
downloadQrCode(batchQrCode.value, batchQrDownloadName.value);
}
async function loadLists() {
loading.value = true;
try {
templates.value = await getTraceabilityTemplates();
batches.value = await getTraceabilityBatches();
const validTemplateIds = new Set(
templateFilterOptions.value.map((item) => item.value).filter(Boolean),
);
if (
templateFilterId.value &&
!validTemplateIds.has(templateFilterId.value)
) {
templateFilterId.value = '';
}
if (!selectedBatchId.value && batches.value[0]) {
await selectBatch(batches.value[0].id);
}
@@ -107,9 +164,66 @@ function applyBatch(detail: TraceabilityApi.BatchDetail) {
formState.templateId = detail.templateId;
stepIndex.value = detail.currentStep ?? 0;
editableStartIndex.value = detail.currentStep ?? 0;
batchSnapshot.value = JSON.stringify({
batchCode: formState.batchCode,
batchName: formState.batchName,
coverImage: formState.coverImage,
productName: formState.productName,
summary: formState.summary,
tagsText: formState.tagsText,
templateId: formState.templateId,
steps: batchDetail.value?.steps,
});
}
const hasBatchChanges = computed(() => {
if (!selectedBatchId.value || !batchDetail.value) return false;
return (
batchSnapshot.value !==
JSON.stringify({
batchCode: formState.batchCode,
batchName: formState.batchName,
coverImage: formState.coverImage,
productName: formState.productName,
summary: formState.summary,
tagsText: formState.tagsText,
templateId: formState.templateId,
steps: batchDetail.value?.steps,
})
);
});
function openBatchLeaveDialog(nextId: string) {
pendingBatchSelectId.value = nextId;
leaveDialogVisible.value = true;
}
async function confirmBatchLeave(action: 'cancel' | 'discard' | 'save') {
leaveDialogVisible.value = false;
const nextId = pendingBatchSelectId.value;
pendingBatchSelectId.value = '';
if (action === 'cancel' || !nextId) return;
if (action === 'save') {
await saveStep();
}
bypassBatchLeaveGuard.value = true;
try {
await selectBatch(nextId);
} finally {
bypassBatchLeaveGuard.value = false;
}
}
async function selectBatch(id: string) {
if (
!bypassBatchLeaveGuard.value &&
selectedBatchId.value &&
id !== selectedBatchId.value &&
hasBatchChanges.value
) {
openBatchLeaveDialog(id);
return;
}
selectedBatchId.value = id;
const detail = await getTraceabilityBatch(id);
applyBatch(detail);
@@ -195,13 +309,17 @@ function buildPersistedStepValues(step: TraceabilityApi.BatchStep) {
}
function isFieldValueLocked(field: TraceabilityApi.FieldDefinition) {
return !isCurrentEditableStep.value || !!isPublished.value || !!field.fixedPreset;
return (
!isCurrentEditableStep.value || !!isPublished.value || !!field.fixedPreset
);
}
function sanitizeIntegerInput(value: string) {
const cleaned = value.replaceAll(/[^\d-]/g, '');
const hasLeadingMinus = cleaned.startsWith('-');
const unsigned = hasLeadingMinus ? cleaned.slice(1).replaceAll('-', '') : cleaned.replaceAll('-', '');
const unsigned = hasLeadingMinus
? cleaned.slice(1).replaceAll('-', '')
: cleaned.replaceAll('-', '');
return hasLeadingMinus ? `-${unsigned}` : unsigned;
}
@@ -237,7 +355,10 @@ function clearImageValue(field: TraceabilityApi.FieldDefinition) {
updateFieldValue(field, '');
}
async function handleImageUpload(field: TraceabilityApi.FieldDefinition, event: Event) {
async function handleImageUpload(
field: TraceabilityApi.FieldDefinition,
event: Event,
) {
const files = (event.target as HTMLInputElement).files;
const file = files?.[0];
if (!file || !currentStep.value || !selectedBatchId.value) return;
@@ -320,17 +441,31 @@ onMounted(async () => {
</script>
<template>
<Page auto-content-height>
<div class="trace-operator-page">
<div class="trace-operator">
<Row :gutter="[16, 16]">
<Col :lg="7" :md="8" :sm="24" :xs="24">
<Card :loading="loading" class="panel-card batch-panel-card" title="批次流程">
<Card
:loading="loading"
class="panel-card batch-panel-card"
title="批次流程"
>
<template #extra>
<Button type="primary" @click="openCreateBatchModal">新建批次</Button>
<Select
v-model:value="templateFilterId"
:options="templateFilterOptions"
allow-clear
placeholder="全部模板"
class="batch-filter-select"
style="width: 260px"
/>
<Button type="primary" @click="openCreateBatchModal">
新建批次
</Button>
</template>
<div class="batch-list">
<button
v-for="item in batches"
v-for="item in filteredBatches"
:key="item.id"
class="batch-card"
:class="{ active: item.id === selectedBatchId }"
@@ -340,7 +475,12 @@ onMounted(async () => {
<strong>{{ item.batchName }}</strong>
<div class="batch-card__actions">
<Tag>{{ getBatchStatusText(item.status) }}</Tag>
<Button danger size="small" type="link" @click.stop="removeBatch(item.id)">
<Button
danger
size="small"
type="link"
@click.stop="removeBatch(item.id)"
>
删除
</Button>
</div>
@@ -353,13 +493,31 @@ onMounted(async () => {
</div>
<small>{{ item.summary || '暂无批次概述' }}</small>
</button>
<Empty
v-if="filteredBatches.length === 0"
description="当前模板下暂无批次"
/>
</div>
</Card>
</Col>
<Col :lg="17" :md="16" :sm="24" :xs="24">
<Space direction="vertical" size="middle" style="width: 100%">
<Card v-if="batchDetail?.publishedAt" class="panel-card" title="发布信息">
<Card
v-if="batchDetail?.publishedAt"
class="panel-card"
title="发布信息"
>
<template #extra>
<Button
v-if="batchDetail.publicUrl"
size="small"
type="primary"
@click="downloadBatchQrCode"
>
保存二维码
</Button>
</template>
<div class="publish-panel">
<div>
<span>溯源码</span>
@@ -378,6 +536,14 @@ onMounted(async () => {
<strong>{{ getBatchStatusText(batchDetail.status) }}</strong>
</div>
</div>
<div class="publish-qr">
<img
v-if="batchDetail.publicUrl"
:src="batchQrCode"
alt="批次二维码"
/>
<Empty v-else description="暂无可下载二维码" />
</div>
</Card>
<Card class="panel-card" title="节点填报">
@@ -424,32 +590,45 @@ onMounted(async () => {
<div class="step-header">
<div>
<h3>{{ currentStep.name }}</h3>
<p>{{ currentStep.description || '请填报此环节的过程记录。' }}</p>
<p>
{{
currentStep.description || '请填报此环节的过程记录。'
}}
</p>
<small class="step-hint">
{{
isPublished
? '当前批次已发布,溯源链已锁定为只读。'
: isLockedStep
? '当前节点来自节点库,字段和值固定,不可修改。'
: isCurrentEditableStep
? '当前节点可填写并继续流转。'
: '当前查看的是非进行中节点,仅供浏览。'
? '当前节点来自节点库,字段和值固定,不可修改。'
: isCurrentEditableStep
? '当前节点可填写并继续流转。'
: '当前查看的是非进行中节点,仅供浏览。'
}}
</small>
</div>
<Tag color="blue">
{{ currentStep.category === 'public' ? '公开节点' : '业务节点' }}
{{
currentStep.category === 'public'
? '公开节点'
: '业务节点'
}}
</Tag>
</div>
<Row :gutter="[16, 16]">
<Col :md="12" :xs="24">
<label class="field-label">操作人</label>
<Input v-model:value="currentStep.operatorName" :disabled="!isCurrentEditableStep" />
<Input
v-model:value="currentStep.operatorName"
:disabled="!isCurrentEditableStep"
/>
</Col>
<Col :md="12" :xs="24">
<label class="field-label">节点状态</label>
<div class="readonly-box">{{ getStepStatusText(stepIndex) }}</div>
<div class="readonly-box">
{{ getStepStatusText(stepIndex) }}
</div>
</Col>
</Row>
@@ -464,30 +643,50 @@ onMounted(async () => {
<div class="field-entry__head">
<div class="field-entry__title">
<label class="field-label">{{ field.label }}</label>
<small v-if="field.placeholder">{{ field.placeholder }}</small>
<small v-if="field.placeholder">{{
field.placeholder
}}</small>
</div>
<div class="field-head-tags">
<span class="field-type-tag">{{ getFieldTypeLabel(field.type) }}</span>
<Tag v-if="field.fixedPreset" color="gold">固定预设值</Tag>
<span class="field-type-tag">{{
getFieldTypeLabel(field.type)
}}</span>
<Tag v-if="field.fixedPreset" color="gold">
固定预设值
</Tag>
</div>
</div>
<div class="field-entry__body">
<Select
v-if="field.type === 'select'"
<Select
v-if="field.type === 'select'"
:disabled="isFieldValueLocked(field)"
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
:options="
(field.options || []).map((item) => ({
label: item,
value: item,
}))
"
:value="currentStep.values[field.key]"
style="width: 100%"
@update:value="(value) => updateFieldValue(field, value)"
@update:value="
(value) => updateFieldValue(field, value)
"
/>
<Select
v-else-if="field.type === 'multi_select'"
<Select
v-else-if="field.type === 'multi_select'"
:disabled="isFieldValueLocked(field)"
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
:options="
(field.options || []).map((item) => ({
label: item,
value: item,
}))
"
:value="currentStep.values[field.key]"
mode="multiple"
style="width: 100%"
@update:value="(value) => updateFieldValue(field, value)"
@update:value="
(value) => updateFieldValue(field, value)
"
/>
<Input
v-else-if="field.type === 'integer'"
@@ -497,7 +696,10 @@ onMounted(async () => {
style="width: 100%"
@update:value="
(value) =>
updateFieldValue(field, sanitizeIntegerInput(String(value ?? '')))
updateFieldValue(
field,
sanitizeIntegerInput(String(value ?? '')),
)
"
/>
<Input
@@ -508,47 +710,68 @@ onMounted(async () => {
style="width: 100%"
@update:value="
(value) =>
updateFieldValue(field, sanitizeDecimalInput(String(value ?? '')))
updateFieldValue(
field,
sanitizeDecimalInput(String(value ?? '')),
)
"
/>
<DatePicker
v-else-if="field.type === 'date'"
<CoordinateFieldEditor
v-else-if="field.type === 'coordinate'"
:disabled="isFieldValueLocked(field)"
:model-value="currentStep.values[field.key]"
@update:model-value="
(value) => updateFieldValue(field, value)
"
/>
<DatePicker
v-else-if="field.type === 'date'"
:disabled="isFieldValueLocked(field)"
:value="currentStep.values[field.key]"
style="width: 100%"
value-format="YYYY-MM-DD"
@update:value="(value) => updateFieldValue(field, value)"
@update:value="
(value) => updateFieldValue(field, value)
"
/>
<DatePicker
v-else-if="field.type === 'datetime'"
<DatePicker
v-else-if="field.type === 'datetime'"
:disabled="isFieldValueLocked(field)"
:value="currentStep.values[field.key]"
format="YYYY-MM-DD HH:mm:ss"
show-time
style="width: 100%"
value-format="YYYY-MM-DD HH:mm:ss"
@update:value="(value) => updateFieldValue(field, value)"
@update:value="
(value) => updateFieldValue(field, value)
"
/>
<Input
v-else-if="field.type === 'link'"
<Input
v-else-if="field.type === 'link'"
:disabled="isFieldValueLocked(field)"
:placeholder="field.placeholder || '请输入链接地址'"
:value="currentStep.values[field.key]"
@update:value="(value) => updateFieldValue(field, value)"
@update:value="
(value) => updateFieldValue(field, value)
"
/>
<Input
v-else-if="field.type === 'string'"
<Input
v-else-if="field.type === 'string'"
:disabled="isFieldValueLocked(field)"
:placeholder="field.placeholder || '请输入内容'"
:value="currentStep.values[field.key]"
@update:value="(value) => updateFieldValue(field, value)"
@update:value="
(value) => updateFieldValue(field, value)
"
/>
<div
v-else-if="field.type === 'image'"
class="placeholder-uploader"
>
<strong>上传节点图片</strong>
<p>支持上传后直接回填图片地址消费者端和预览页都可直接查看</p>
<p>
支持上传后直接回填图片地址,消费者端和预览页都可直接查看。
</p>
<div
v-if="currentStep.values[field.key]"
class="image-preview-wrap"
@@ -571,16 +794,24 @@ onMounted(async () => {
class="upload-input"
type="file"
:disabled="isFieldValueLocked(field)"
@change="(event) => handleImageUpload(field, event)"
@change="
(event) => handleImageUpload(field, event)
"
/>
<Button
:disabled="isFieldValueLocked(field)"
:loading="uploadingFieldKey === getFieldUploadKey(field)"
:loading="
uploadingFieldKey === getFieldUploadKey(field)
"
size="small"
type="primary"
@click="triggerImageSelect(field)"
>
{{ currentStep.values[field.key] ? '重新上传' : '选择图片' }}
{{
currentStep.values[field.key]
? '重新上传'
: '选择图片'
}}
</Button>
<Button
v-if="currentStep.values[field.key]"
@@ -597,21 +828,27 @@ onMounted(async () => {
class="placeholder-uploader"
>
<strong>视频控件模板</strong>
<p>这里先预留视频控件位置后续你可以补充视频上传或选择逻辑</p>
<p>
这里先预留视频控件位置,后续你可以补充视频上传或选择逻辑。
</p>
</div>
<Input.TextArea
<Input.TextArea
v-else
:auto-size="{ minRows: 3, maxRows: 5 }"
:disabled="isFieldValueLocked(field)"
:placeholder="field.placeholder || '请输入内容'"
:value="currentStep.values[field.key]"
@update:value="(value) => updateFieldValue(field, value)"
@update:value="
(value) => updateFieldValue(field, value)
"
/>
<div v-if="field.fixedPreset" class="field-fixed-tip">
当前字段已启用固定预设值,模板和批次中不可改动。
</div>
<div class="field-preview">
当前值{{ formatFieldValue(currentStep.values[field.key]) }}
当前值{{
formatFieldValue(currentStep.values[field.key])
}}
</div>
</div>
</div>
@@ -640,7 +877,12 @@ onMounted(async () => {
<label class="field-label">模板</label>
<Select
v-model:value="formState.templateId"
:options="publishedTemplates.map((item) => ({ label: item.name, value: item.id }))"
:options="
publishedTemplates.map((item) => ({
label: item.name,
value: item.id,
}))
"
style="width: 100%"
/>
</Col>
@@ -673,8 +915,21 @@ onMounted(async () => {
</Col>
</Row>
</Modal>
</Page>
<Modal
v-model:open="leaveDialogVisible"
title="有未保存的改动"
:mask-closable="false"
:footer="null"
@cancel="confirmBatchLeave('cancel')"
>
<p>当前批次有未保存的修改,切换前要怎么处理?</p>
<div class="leave-dialog__actions">
<Button block @click="confirmBatchLeave('save')">保存</Button>
<Button block @click="confirmBatchLeave('discard')">不保存</Button>
<Button block @click="confirmBatchLeave('cancel')">取消</Button>
</div>
</Modal>
</div>
</template>
<style scoped>
@@ -699,6 +954,35 @@ onMounted(async () => {
gap: 12px;
}
.batch-toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin: 12px 0 14px;
padding: 14px 16px;
border: 1px solid #edf1f7;
border-radius: 16px;
background: linear-gradient(180deg, #ffffff 0%, #fafcff 100%);
}
.batch-toolbar__label {
display: block;
color: #556070;
font-size: 13px;
font-weight: 600;
}
.batch-toolbar p {
margin: 6px 0 0;
color: #8b96a8;
font-size: 12px;
}
.batch-filter-select {
margin-right: 12px;
}
.batch-card {
width: 100%;
border: 1px solid #edf1f7;
@@ -838,6 +1122,23 @@ onMounted(async () => {
word-break: break-all;
}
.publish-qr {
display: flex;
justify-content: center;
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed #e6edf8;
}
.publish-qr img {
width: 220px;
height: 220px;
border-radius: 16px;
border: 1px solid #e6edf8;
background: #fff;
padding: 10px;
}
.readonly-box {
min-height: 54px;
display: flex;
@@ -921,6 +1222,15 @@ onMounted(async () => {
.field-entry__head {
flex-direction: column;
}
.batch-toolbar {
align-items: stretch;
flex-direction: column;
}
.batch-toolbar :deep(.ant-select) {
width: 100% !important;
}
}
.upload-trigger {
@@ -967,4 +1277,14 @@ onMounted(async () => {
color: #6b7280;
line-height: 1.7;
}
.leave-dialog__actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.leave-dialog__actions :deep(.ant-btn) {
width: 100%;
}
</style>
@@ -1,8 +1,7 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import { Page } from '@vben/common-ui';
import { Button, Card, Col, Empty, Input, message, Modal, Row, Select, Space, Switch, Tabs, Tag } from 'ant-design-vue';
import { Button, Card, Col, DatePicker, Empty, Input, message, Modal, Row, Select, Space, Switch, Tabs, Tag } from 'ant-design-vue';
import {
createTraceabilityPreview,
deleteTraceabilityFileAsset,
@@ -15,7 +14,8 @@ import {
uploadTraceabilityImage,
} from '#/api';
import type { TraceabilityApi } from '#/api';
import { buildOssStoredValue, clonePreviewForSave, createEmptyField, createEmptyPreviewNode, getFieldTypeLabel, getImagePreviewSrc, normalizeFieldInput } from './shared';
import CoordinateFieldEditor from './components/CoordinateFieldEditor.vue';
import { buildOssStoredValue, clonePreviewForSave, createEmptyField, createEmptyPreviewNode, fieldTypeOptions, getFieldTypeLabel, getImagePreviewSrc, normalizeFieldInput } from './shared';
const previews = ref<TraceabilityApi.PreviewPageSummary[]>([]);
const selectedPreviewId = ref('');
@@ -32,18 +32,27 @@ const activeNodeTab = ref<'business' | 'public'>('business');
const selectedNodeId = ref('');
const selectedFieldKey = ref('');
const draggingNodeId = ref('');
const draggingFieldKey = ref('');
const previewSnapshot = ref('');
const leaveDialogVisible = ref(false);
const pendingPreviewSelectId = ref('');
const bypassPreviewLeaveGuard = ref(false);
const editor = reactive<Partial<TraceabilityApi.PreviewPageDetail> & { coverImage: any; coverImagePreviewUrl?: string }>({
id: '', name: '', previewCode: '', description: '', productName: '', coverImage: '', coverImagePreviewUrl: '',
id: '', name: '', previewCode: '', description: '', remark: '', productName: '', coverImage: '', coverImagePreviewUrl: '',
themeColor: '#1f4fd6', tags: [], publicUrl: '', updatedAt: '', nodes: [],
});
const qrCode = useQRCode(computed(() => editor.publicUrl || ''), { errorCorrectionLevel: 'M', margin: 1, width: 220 });
const fieldTypeOptions = [
{ label: '字符串', value: 'string' }, { label: '整数', value: 'integer' }, { label: '小数', value: 'decimal' },
{ label: '日期', value: 'date' }, { label: '日期时间', value: 'datetime' }, { label: '单选', value: 'select' },
{ label: '多选', value: 'multi_select' }, { label: '图片', value: 'image' }, { label: '链接', value: 'link' },
{ label: '视频', value: 'video_url' }, { label: 'JSON', value: 'json' },
];
const qrDownloadName = computed(() => `${editor.previewCode || editor.publicUrl || editor.name || 'preview'}.png`);
function downloadQrCode(dataUrl: string, fileName: string) {
if (!dataUrl) return;
const link = document.createElement('a');
link.href = dataUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
}
function nodesByCategory(category: 'business' | 'public') {
return (editor.nodes ?? []).filter((item) => item.category === category);
}
@@ -55,17 +64,51 @@ function syncSelectedFieldFromNode(node: TraceabilityApi.PreviewNode | null) {
}
function resetEditor() {
Object.assign(editor, {
id: '', name: '', previewCode: '', description: '', productName: '', coverImage: '', coverImagePreviewUrl: '',
id: '', name: '', previewCode: '', description: '', remark: '', productName: '', coverImage: '', coverImagePreviewUrl: '',
themeColor: '#1f4fd6', tags: [], publicUrl: '', updatedAt: '', nodes: [createEmptyPreviewNode('public'), createEmptyPreviewNode('business')],
});
}
function applyPreview(detail: TraceabilityApi.PreviewPageDetail) {
Object.assign(editor, structuredClone(detail));
editor.nodes = (editor.nodes ?? []).map((node) => ({
...node,
fields: (node.fields ?? []).map((field, index) => ({
...field,
sort: field.sort ?? index,
})),
}));
const businessNode = detail.nodes.find((item) => item.category !== 'public');
const publicNode = detail.nodes.find((item) => item.category === 'public');
activeNodeTab.value = businessNode ? 'business' : 'public';
selectedNodeId.value = (businessNode ?? publicNode)?.id ?? '';
syncSelectedFieldFromNode(businessNode ?? publicNode ?? null);
previewSnapshot.value = JSON.stringify(clonePreviewForSave(editor as TraceabilityApi.PreviewPageDetail));
}
const hasPreviewChanges = computed(() => {
if (!selectedPreviewId.value) return false;
return previewSnapshot.value !== JSON.stringify(clonePreviewForSave(editor as TraceabilityApi.PreviewPageDetail));
});
function openPreviewLeaveDialog(nextId: string) {
pendingPreviewSelectId.value = nextId;
leaveDialogVisible.value = true;
}
async function confirmPreviewLeave(action: 'save' | 'discard' | 'cancel') {
leaveDialogVisible.value = false;
const nextId = pendingPreviewSelectId.value;
pendingPreviewSelectId.value = '';
if (action === 'cancel' || !nextId) return;
if (action === 'save') {
await savePreview();
}
bypassPreviewLeaveGuard.value = true;
try {
await selectPreview(nextId);
} finally {
bypassPreviewLeaveGuard.value = false;
}
}
async function loadPreviews() {
loading.value = true;
@@ -76,6 +119,15 @@ async function loadPreviews() {
} finally { loading.value = false; }
}
async function selectPreview(id: string) {
if (
!bypassPreviewLeaveGuard.value &&
selectedPreviewId.value &&
id !== selectedPreviewId.value &&
hasPreviewChanges.value
) {
openPreviewLeaveDialog(id);
return;
}
selectedPreviewId.value = id;
applyPreview(await getTraceabilityPreview(id));
}
@@ -83,7 +135,7 @@ async function createPreview() {
saving.value = true;
try {
const created = await createTraceabilityPreview({
name: '新建预演页', description: '', productName: '', coverImage: '', themeColor: '#1f4fd6', tags: [],
name: '新建预演页', description: '', remark: '', productName: '', coverImage: '', themeColor: '#1f4fd6', tags: [],
nodes: [createEmptyPreviewNode('public'), createEmptyPreviewNode('business')],
});
await loadPreviews();
@@ -91,6 +143,21 @@ async function createPreview() {
message.success('预演页已创建');
} finally { saving.value = false; }
}
async function copyPreview(id: string) {
saving.value = true;
try {
const detail = await getTraceabilityPreview(id);
const created = await createTraceabilityPreview({
...clonePreviewForSave(detail),
name: `${detail.name || '未命名预演页'} 副本`,
});
await loadPreviews();
if (created?.id) {
await selectPreview(created.id);
}
message.success('预演页已复制');
} finally { saving.value = false; }
}
async function savePreview() {
if (!editor.id) return message.warning('请先新建预演页');
saving.value = true;
@@ -164,13 +231,14 @@ function movePreviewNode(
const movedNodes = [...categoryNodes];
const [movedNode] = movedNodes.splice(fromIndex, 1);
if (!movedNode) return;
movedNodes.splice(toIndex, 0, movedNode);
const reordered = nodes.slice();
let cursor = 0;
editor.nodes = reordered.map((node) =>
node.category === category ? movedNodes[cursor++] : node,
);
editor.nodes = reordered
.map((node) => (node.category === category ? movedNodes[cursor++] : node))
.filter((node): node is TraceabilityApi.PreviewNode => !!node);
selectedNodeId.value = draggedId;
}
@@ -186,8 +254,61 @@ function handleNodeDrop(targetNodeId?: string) {
draggingNodeId.value = '';
}
function clearNodeDragState() { draggingNodeId.value = ''; }
function addField(node: TraceabilityApi.PreviewNode) { const field = createEmptyField(); node.fields.push(field); node.values[field.key] = ''; selectedFieldKey.value = field.key; }
function removeField(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) { node.fields = node.fields.filter((item) => item !== field); delete node.values[field.key]; if (selectedFieldKey.value === field.key) selectedFieldKey.value = node.fields[0]?.key ?? ''; }
function addField(node: TraceabilityApi.PreviewNode) { const field = createEmptyField(); field.sort = node.fields.length; node.fields.push(field); node.values[field.key] = ''; selectedFieldKey.value = field.key; }
function removeField(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) { node.fields = node.fields.filter((item) => item !== field).map((item, index) => ({ ...item, sort: index })); delete node.values[field.key]; if (selectedFieldKey.value === field.key) selectedFieldKey.value = node.fields[0]?.key ?? ''; }
function moveField(node: TraceabilityApi.PreviewNode, draggedKey: string, targetKey: string) {
if (!draggedKey || !targetKey || draggedKey === targetKey) return;
const fromIndex = node.fields.findIndex((field) => field.key === draggedKey);
const toIndex = node.fields.findIndex((field) => field.key === targetKey);
if (fromIndex === -1 || toIndex === -1) return;
const movedFields = [...node.fields];
const [movedField] = movedFields.splice(fromIndex, 1);
if (!movedField) return;
movedFields.splice(toIndex, 0, movedField);
node.fields = movedFields.map((field, index) => ({ ...field, sort: index }));
}
function handleFieldDragStart(fieldKey?: string) { draggingFieldKey.value = fieldKey ?? ''; }
function handleFieldDrop(targetFieldKey?: string) {
if (!currentNode.value) return;
moveField(currentNode.value, draggingFieldKey.value, targetFieldKey ?? '');
draggingFieldKey.value = '';
}
function clearFieldDragState() { draggingFieldKey.value = ''; }
function getFieldStyleConfig(field?: TraceabilityApi.FieldDefinition | null) {
if (!field) return { bold: false, color: '' };
field.fieldStyle ??= { bold: false, color: '' };
return field.fieldStyle;
}
function updateFieldStyleBold(field: TraceabilityApi.FieldDefinition | null | undefined, checked: boolean) {
if (!field) return;
getFieldStyleConfig(field).bold = checked;
}
function updateFieldStyleColor(field: TraceabilityApi.FieldDefinition | null | undefined, color: string) {
if (!field) return;
getFieldStyleConfig(field).color = color;
}
function getPreviewFieldValue(node?: TraceabilityApi.PreviewNode | null, field?: TraceabilityApi.FieldDefinition | null) {
if (!node || !field) return '';
const value = node.values?.[field.key];
if (value !== undefined) return value;
return field.type === 'multi_select' ? [] : field.defaultValue ?? '';
}
function updatePreviewFieldType(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition, nextType: string) {
field.type = nextType || 'string';
if (field.type !== 'select' && field.type !== 'multi_select') {
field.options = [];
}
const currentValue = node.values?.[field.key];
if (currentValue === undefined) {
node.values[field.key] = field.type === 'multi_select' ? [] : '';
return;
}
if (field.type === 'image') {
node.values[field.key] = '';
return;
}
node.values[field.key] = normalizeFieldInput(field, currentValue);
}
function confirmRemovePreviewField(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) {
Modal.confirm({
title: '删除字段',
@@ -263,19 +384,30 @@ async function handleTemplateCoverUpload(event: Event) {
message.success('封面图上传成功');
} finally { uploadingFieldKey.value = ''; (event.target as HTMLInputElement).value = ''; }
}
function downloadPreviewQrCode() {
downloadQrCode(qrCode.value, qrDownloadName.value);
}
onMounted(loadPreviews);
</script>
<template>
<Page auto-content-height>
<div class="trace-preview-page-root">
<div class="trace-preview-page">
<div class="preview-layout">
<Card class="panel-card preview-sidebar" :loading="loading" title="预演页列表">
<Card class="panel-card preview-sidebar" :loading="loading" title="预演页列表">
<Button block type="primary" @click="createPreview">新建预演页</Button>
<div v-if="previews.length" class="preview-list">
<button v-for="item in previews" :key="item.id" :class="['preview-list__item', { 'is-active': item.id === selectedPreviewId }]" type="button" @click="selectPreview(item.id)">
<div><strong>{{ item.name }}</strong><p>{{ item.previewCode }}</p></div>
<Button danger size="small" @click.stop="removePreview(item.id)">删除</Button>
<div>
<strong>{{ item.name }}</strong>
<p>{{ item.previewCode }}</p>
<p class="preview-list__remark">{{ item.remark || '暂无备注' }}</p>
</div>
<Space>
<Button size="small" @click.stop="copyPreview(item.id)">复制</Button>
<Button danger size="small" @click.stop="removePreview(item.id)">删除</Button>
</Space>
</button>
</div>
<Empty v-else description="暂无预演页" />
@@ -298,6 +430,7 @@ onMounted(loadPreviews);
<Col :md="12" :xs="24"><label class="field-label">预演页名称</label><Input v-model:value="editor.name" /></Col>
<Col :md="12" :xs="24"><label class="field-label">产品名称</label><Input v-model:value="editor.productName" /></Col>
<Col :span="24"><label class="field-label">说明</label><Input.TextArea v-model:value="editor.description" :auto-size="{ minRows: 2, maxRows: 4 }" /></Col>
<Col :span="24"><label class="field-label">备注</label><Input.TextArea v-model:value="editor.remark" :auto-size="{ minRows: 2, maxRows: 4 }" placeholder="仅内部可见,用于记录预演页背景、限制或补充说明" /></Col>
<Col :md="12" :xs="24"><label class="field-label">主题色</label><div class="color-picker-line"><input v-model="editor.themeColor" class="color-input" type="color"><span>{{ editor.themeColor }}</span></div></Col>
<Col :md="12" :xs="24"><label class="field-label">标签</label><Select :value="editor.tags" mode="tags" style="width: 100%" @update:value="(value) => (editor.tags = Array.isArray(value) ? value.map((item) => String(item)) : [])" /></Col>
<Col :span="24">
@@ -330,7 +463,15 @@ onMounted(loadPreviews);
<Button :loading="syncing" @click="syncToTemplate">同步为模板</Button>
</div>
</div>
<div class="preview-link-card__qr"><img v-if="editor.publicUrl" :src="qrCode" alt="预演二维码"><Empty v-else description="保存后生成二维码" /></div>
<div class="preview-link-card__qr">
<div class="preview-link-card__qr-head">
<Button v-if="editor.publicUrl" size="small" type="primary" @click="downloadPreviewQrCode">
保存二维码
</Button>
</div>
<img v-if="editor.publicUrl" :src="qrCode" alt="预演二维码">
<Empty v-else description="保存后生成二维码" />
</div>
</div>
</Card>
</Tabs.TabPane>
@@ -415,7 +556,12 @@ onMounted(loadPreviews);
:key="field.key"
class="field-pill"
:class="{ active: field.key === currentField?.key }"
draggable="true"
type="button"
@dragstart="handleFieldDragStart(field.key)"
@dragover.prevent
@drop.prevent="handleFieldDrop(field.key)"
@dragend="clearFieldDragState"
@click="selectedFieldKey = field.key"
>
<span>{{ getFieldTypeLabel(field.type) }}</span>
@@ -438,29 +584,61 @@ onMounted(loadPreviews);
<Row :gutter="[12, 12]">
<Col :md="8" :xs="24"><label class="field-label">字段名称</label><Input v-model:value="currentField.label" /></Col>
<Col :md="8" :xs="24"><label class="field-label">字段 Key</label><Input :value="currentField.key" @update:value="(value) => updateFieldKey(currentNode, currentField, String(value ?? ''))" /></Col>
<Col :md="8" :xs="24"><label class="field-label">字段类型</label><Select :options="fieldTypeOptions" :value="currentField.type" style="width: 100%" @update:value="(value) => (currentField.type = String(value || 'string'))" /></Col>
<Col :md="8" :xs="24"><label class="field-label">字段 Key</label><Input :value="currentField.key" @update:value="(value) => updateFieldKey(currentNode!, currentField!, String(value ?? ''))" /></Col>
<Col :md="8" :xs="24"><label class="field-label">字段类型</label><Select :options="fieldTypeOptions" :value="currentField.type" style="width: 100%" @update:value="(value) => updatePreviewFieldType(currentNode!, currentField!, String(value || 'string'))" /></Col>
<Col :md="12" :xs="24"><label class="field-label">占位提示</label><Input v-model:value="currentField.placeholder" /></Col>
<Col :md="6" :xs="24"><label class="field-label">字体颜色</label><Input v-model:value="currentField.fieldStyle!.color" placeholder="#1f4fd6" /></Col>
<Col :md="6" :xs="24"><label class="field-label">文字加粗</label><div class="switch-line"><Switch v-model:checked="currentField.fieldStyle!.bold" /></div></Col>
</Row>
<div class="field-toggle-row">
<div class="field-toggle-chip">
<span>必填</span>
<Switch v-model:checked="currentField.required" />
</div>
<div class="field-toggle-chip">
<span>消费者可见</span>
<Switch v-model:checked="currentField.visible" />
</div>
<div class="field-toggle-chip">
<span>固定预设值</span>
<Switch v-model:checked="currentField.fixedPreset" />
</div>
<div class="field-toggle-chip">
<span>文字加粗</span>
<Switch :checked="getFieldStyleConfig(currentField).bold" @update:checked="(checked) => updateFieldStyleBold(currentField!, Boolean(checked))" />
</div>
</div>
<Row :gutter="[12, 12]">
<Col :md="10" :xs="24">
<label class="field-label">文字颜色</label>
<div class="color-picker-line compact-color-line field-color-inline">
<input :value="getFieldStyleConfig(currentField).color || '#1f2937'" class="color-input" type="color" @input="(event) => updateFieldStyleColor(currentField!, (event.target as HTMLInputElement).value)" />
<span>{{ getFieldStyleConfig(currentField).color || '#1f2937' }}</span>
</div>
</Col>
<Col v-if="['select', 'multi_select'].includes(currentField.type)" :md="14" :xs="24">
<label class="field-label">选项</label>
<Select v-model:value="currentField.options" mode="tags" placeholder="Options" style="width: 100%" />
</Col>
<Col :span="24">
<label class="field-label">字段值</label>
<template v-if="currentField.type === 'image'">
<div class="image-uploader">
<input :id="getFieldInputId(currentNode, currentField)" hidden type="file" accept="image/*" @change="handleImageUpload(currentNode, currentField, $event)">
<div v-if="getImagePreviewSrc(currentNode.values[currentField.key], currentNode.valuePreviewUrls?.[currentField.key])" class="image-uploader__preview"><img :src="getImagePreviewSrc(currentNode.values[currentField.key], currentNode.valuePreviewUrls?.[currentField.key])" :alt="currentField.label"></div>
<input :id="getFieldInputId(currentNode!, currentField!)" hidden type="file" accept="image/*" @change="handleImageUpload(currentNode!, currentField!, $event)">
<div v-if="getImagePreviewSrc(getPreviewFieldValue(currentNode, currentField), currentNode?.valuePreviewUrls?.[currentField.key])" class="image-uploader__preview"><img :src="getImagePreviewSrc(getPreviewFieldValue(currentNode, currentField), currentNode?.valuePreviewUrls?.[currentField.key])" :alt="currentField.label"></div>
<Space>
<Button :loading="uploadingFieldKey === getFieldUploadKey(currentNode, currentField)" @click="triggerImageSelect(currentNode, currentField)">上传图片</Button>
<Button v-if="getImagePreviewSrc(currentNode.values[currentField.key], currentNode.valuePreviewUrls?.[currentField.key])" danger @click="clearImageValue(currentNode, currentField)">清空</Button>
<Button :loading="uploadingFieldKey === getFieldUploadKey(currentNode!, currentField!)" @click="triggerImageSelect(currentNode!, currentField!)">上传图片</Button>
<Button v-if="getImagePreviewSrc(getPreviewFieldValue(currentNode, currentField), currentNode?.valuePreviewUrls?.[currentField.key])" danger @click="clearImageValue(currentNode!, currentField!)">清空</Button>
</Space>
</div>
</template>
<Input v-else-if="currentField.type === 'integer'" :value="String(currentNode.values[currentField.key] ?? '')" @update:value="(value) => updateFieldValue(currentNode, currentField, sanitizeIntegerInput(String(value ?? '')))" />
<Input v-else-if="currentField.type === 'decimal'" :value="String(currentNode.values[currentField.key] ?? '')" @update:value="(value) => updateFieldValue(currentNode, currentField, sanitizeDecimalInput(String(value ?? '')))" />
<Select v-else-if="currentField.type === 'select'" :options="(currentField.options ?? []).map((item) => ({ label: item, value: item }))" :value="currentNode.values[currentField.key]" style="width: 100%" @update:value="(value) => updateFieldValue(currentNode, currentField, value)" />
<Select v-else-if="currentField.type === 'multi_select'" mode="multiple" :options="(currentField.options ?? []).map((item) => ({ label: item, value: item }))" :value="currentNode.values[currentField.key] ?? []" style="width: 100%" @update:value="(value) => updateFieldValue(currentNode, currentField, value)" />
<Input.TextArea v-else-if="currentField.type === 'json'" :value="typeof currentNode.values[currentField.key] === 'string' ? currentNode.values[currentField.key] : JSON.stringify(currentNode.values[currentField.key] ?? {}, null, 2)" :auto-size="{ minRows: 3, maxRows: 6 }" @update:value="(value) => updateFieldValue(currentNode, currentField, value)" />
<Input v-else :value="currentNode.values[currentField.key]" @update:value="(value) => updateFieldValue(currentNode, currentField, value)" />
<Input v-else-if="currentField.type === 'integer'" :value="String(getPreviewFieldValue(currentNode, currentField) ?? '')" @update:value="(value) => updateFieldValue(currentNode!, currentField!, sanitizeIntegerInput(String(value ?? '')))" />
<Input v-else-if="currentField.type === 'decimal'" :value="String(getPreviewFieldValue(currentNode, currentField) ?? '')" @update:value="(value) => updateFieldValue(currentNode!, currentField!, sanitizeDecimalInput(String(value ?? '')))" />
<DatePicker v-else-if="currentField.type === 'date'" :value="getPreviewFieldValue(currentNode, currentField)" style="width: 100%" value-format="YYYY-MM-DD" @update:value="(value) => updateFieldValue(currentNode!, currentField!, value)" />
<DatePicker v-else-if="currentField.type === 'datetime'" :value="getPreviewFieldValue(currentNode, currentField)" format="YYYY-MM-DD HH:mm:ss" show-time style="width: 100%" value-format="YYYY-MM-DD HH:mm:ss" @update:value="(value) => updateFieldValue(currentNode!, currentField!, value)" />
<CoordinateFieldEditor v-else-if="currentField.type === 'coordinate'" :model-value="getPreviewFieldValue(currentNode, currentField)" @update:model-value="(value) => updateFieldValue(currentNode!, currentField!, value)" />
<Select v-else-if="currentField.type === 'select'" :options="(currentField.options ?? []).map((item) => ({ label: item, value: item }))" :value="getPreviewFieldValue(currentNode, currentField)" style="width: 100%" @update:value="(value) => updateFieldValue(currentNode!, currentField!, value)" />
<Select v-else-if="currentField.type === 'multi_select'" mode="multiple" :options="(currentField.options ?? []).map((item) => ({ label: item, value: item }))" :value="getPreviewFieldValue(currentNode, currentField) ?? []" style="width: 100%" @update:value="(value) => updateFieldValue(currentNode!, currentField!, value)" />
<Input.TextArea v-else-if="currentField.type === 'json'" :value="typeof getPreviewFieldValue(currentNode, currentField) === 'string' ? getPreviewFieldValue(currentNode, currentField) : JSON.stringify(getPreviewFieldValue(currentNode, currentField) ?? {}, null, 2)" :auto-size="{ minRows: 3, maxRows: 6 }" @update:value="(value) => updateFieldValue(currentNode!, currentField!, value)" />
<Input v-else :value="getPreviewFieldValue(currentNode, currentField)" @update:value="(value) => updateFieldValue(currentNode!, currentField!, value)" />
</Col>
</Row>
</div>
@@ -478,7 +656,10 @@ onMounted(loadPreviews);
<div v-if="coverHistoryItems.length" class="cover-history-grid">
<div v-for="item in coverHistoryItems" :key="item.id" class="cover-history-card">
<img :src="item.previewUrl" :alt="item.fileName">
<div class="cover-history-card__meta"><strong>{{ item.fileName || item.objectName }}</strong><span>{{ item.createdAt }}</span></div>
<div class="cover-history-card__meta">
<strong>{{ item.fileName || item.objectName }}</strong>
<span>{{ item.createdAt }}</span>
</div>
<div class="cover-history-card__actions">
<Button size="small" type="primary" @click="selectHistoryCover(item)">选择</Button>
<Button size="small" danger :disabled="item.id.startsWith('legacy-')" :loading="deletingCoverAssetId === item.id" @click="deleteHistoryCover(item)">删除</Button>
@@ -487,7 +668,22 @@ onMounted(loadPreviews);
</div>
<Empty v-else :description="coverHistoryLoading ? '正在加载历史封面图…' : '暂无历史封面图'" />
</Modal>
</Page>
<Modal
v-model:open="leaveDialogVisible"
title="有未保存的改动"
:mask-closable="false"
:footer="null"
@cancel="confirmPreviewLeave('cancel')"
>
<p>当前预演页有未保存的修改切换前要怎么处理</p>
<div class="leave-dialog__actions">
<Button block @click="confirmPreviewLeave('save')">保存</Button>
<Button block @click="confirmPreviewLeave('discard')">不保存</Button>
<Button block @click="confirmPreviewLeave('cancel')">取消</Button>
</div>
</Modal>
</div>
</template>
<style scoped>
@@ -500,6 +696,7 @@ onMounted(loadPreviews);
.preview-list__item { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; padding: 12px 14px; border: 1px solid #e8edf6; border-radius: 14px; background: #fff; text-align: left; cursor: pointer; }
.preview-list__item.is-active { border-color: #1f4fd6; box-shadow: 0 10px 24px rgba(31, 79, 214, 0.12); }
.preview-list__item p { margin: 6px 0 0; color: #7d8899; font-size: 12px; }
.preview-list__remark { color: #556070 !important; }
.preview-main { display: grid; gap: 16px; }
.preview-tabs :deep(.ant-tabs-nav),
.preview-node-tabs :deep(.ant-tabs-nav) { margin-bottom: 12px; }
@@ -521,6 +718,8 @@ onMounted(loadPreviews);
.meta-actions,.cover-selector__actions,.cover-history-card__actions { display: flex; flex-wrap: wrap; gap: 8px; }
.node-toolbar { margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid #eef2f8; }
.preview-link-card__qr { display: flex; align-items: center; justify-content: center; min-height: 220px; border: 1px dashed #d7e1f0; border-radius: 16px; background: #fafcff; }
.preview-link-card__qr { position: relative; flex-direction: column; gap: 12px; padding: 12px; }
.preview-link-card__qr-head { display: flex; justify-content: flex-end; width: 100%; }
.preview-link-card__qr img { width: 220px; height: 220px; }
.node-editor { padding: 18px; border: 1px solid #edf1f7; border-radius: 18px; background: linear-gradient(180deg, #fcfdff 0%, #ffffff 100%); }
.node-editor__summary { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 16px; padding: 14px 16px; border: 1px solid #ebf0f7; border-radius: 16px; background: #f8fbff; }
@@ -539,7 +738,7 @@ onMounted(loadPreviews);
.field-editor { margin-top: 18px; }
.field-editor__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.field-strip { display: flex; flex-wrap: wrap; gap: 12px; padding-bottom: 6px; }
.field-pill { min-width: 200px; max-width: 220px; border: 1px solid #e5ebf5; border-radius: 16px; padding: 12px 14px; background: #fff; text-align: left; transition: all 0.2s ease; }
.field-pill { cursor: grab; min-width: 200px; max-width: 220px; border: 1px solid #e5ebf5; border-radius: 16px; padding: 12px 14px; background: #fff; text-align: left; transition: all 0.2s ease; }
.field-pill.active { border-color: #adc4ff; background: #f5f8ff; }
.field-pill:hover { border-color: #cfdaf0; transform: translateY(-1px); }
.field-pill span { color: #1d4ed8; font-size: 12px; }
@@ -550,6 +749,11 @@ onMounted(loadPreviews);
.field-card__head,.field-card__title,.switch-line,.color-picker-line { display: flex; align-items: center; }
.field-card__head { justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.field-card__title,.color-picker-line { gap: 8px; }
.field-toggle-row { display: flex; flex-wrap: wrap; gap: 10px; margin: 14px 0; }
.field-toggle-chip { display: inline-flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #e3eaf5; border-radius: 14px; background: #fff; }
.field-toggle-chip span { color: #475569; font-size: 13px; }
.compact-color-line { min-height: 44px; padding: 4px 10px; border: 1px solid #e3eaf5; border-radius: 14px; background: #fff; }
.field-color-inline { justify-content: space-between; }
.field-add-button { margin-top: 14px; }
.cover-selector__preview,.image-uploader__preview { width: 100%; max-width: 320px; overflow: hidden; border: 1px solid #e7edf7; border-radius: 16px; background: #fff; }
.cover-selector__preview img,.image-uploader__preview img { display: block; width: 100%; height: auto; }
@@ -559,6 +763,17 @@ onMounted(loadPreviews);
.cover-history-card__meta strong,.cover-history-card__meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cover-history-card__meta span { color: #7d8899; font-size: 12px; }
.color-input { width: 52px; height: 36px; border: none; background: transparent; padding: 0; }
.publish-qr { display: flex; justify-content: center; margin-top: 14px; padding-top: 14px; border-top: 1px dashed #e6edf8; }
.publish-qr img { width: 220px; height: 220px; border-radius: 16px; border: 1px solid #e6edf8; background: #fff; padding: 10px; }
.leave-dialog__actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.leave-dialog__actions :deep(.ant-btn) {
width: 100%;
}
@media (max-width: 1200px) { .preview-layout { grid-template-columns: 1fr; } }
@media (max-width: 768px) {
.preview-link-card__content,.cover-history-grid { grid-template-columns: 1fr; }
@@ -568,5 +783,6 @@ onMounted(loadPreviews);
.node-pill { min-width: 100%; max-width: none; }
.field-pill { min-width: 100%; max-width: none; }
.node-lane__title { align-items: flex-start; flex-direction: column; }
.field-toggle-row { flex-direction: column; }
}
</style>
@@ -94,6 +94,7 @@ export const fieldTypeOptions = [
{ label: '小数', value: 'decimal' },
{ label: '日期', value: 'date' },
{ label: '日期时间', value: 'datetime' },
{ label: '坐标', value: 'coordinate' },
{ label: '单选', value: 'select' },
{ label: '多选', value: 'multi_select' },
{ label: '图片', value: 'image' },
@@ -166,6 +167,7 @@ export function createField(
extra: Partial<TraceabilityApi.FieldDefinition> = {},
): TraceabilityApi.FieldDefinition {
return {
sort: 0,
key,
label,
type,
@@ -183,8 +185,84 @@ export function createField(
};
}
export function isCoordinateValue(
value: any,
): value is TraceabilityApi.CoordinateValue {
return !!value && typeof value === 'object' && !Array.isArray(value)
&& ('lng' in value || 'lat' in value || 'address' in value);
}
export function normalizeCoordinateValue(
value: any,
): TraceabilityApi.CoordinateValue | null {
if (value === null || value === undefined || value === '') {
return null;
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) return null;
const [lngText, latText] = text.split(',').map((item) => item.trim());
const lng = Number(lngText);
const lat = Number(latText);
if (Number.isFinite(lng) && Number.isFinite(lat)) {
return { lng, lat, source: 'manual' };
}
return null;
}
if (!isCoordinateValue(value)) {
return null;
}
const lng = value.lng === '' || value.lng === null || value.lng === undefined
? null
: Number(value.lng);
const lat = value.lat === '' || value.lat === null || value.lat === undefined
? null
: Number(value.lat);
if (!Number.isFinite(lng) && !Number.isFinite(lat)) {
return null;
}
return {
lng: Number.isFinite(lng) ? lng : null,
lat: Number.isFinite(lat) ? lat : null,
address: '',
source: value.source || 'manual',
};
}
export function formatCoordinateValue(value: any) {
const normalized = normalizeCoordinateValue(value);
if (!normalized) {
return '未填写';
}
const parts: string[] = [];
if (normalized.lng !== null && normalized.lng !== undefined && normalized.lat !== null && normalized.lat !== undefined) {
parts.push(`${normalized.lng}, ${normalized.lat}`);
}
return parts.join(' | ') || '未填写';
}
export function buildCoordinateMapUrl(value: any) {
const normalized = normalizeCoordinateValue(value);
if (!normalized) {
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 '';
}
export function buildCoordinateEmbedUrl(value: any) {
const normalized = normalizeCoordinateValue(value);
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}`;
}
export function createEmptyField(): TraceabilityApi.FieldDefinition {
return {
sort: 0,
key: createLocalUniqueId('field'),
label: '新字段',
type: 'string',
@@ -239,7 +317,8 @@ export function cloneNodeFromLibrary(
description: preset.description,
locked: true,
consumerVisible: preset.consumerVisible,
fields: preset.fields.map((field) => ({
fields: preset.fields.map((field, fieldIndex) => ({
sort: field.sort ?? fieldIndex,
...field,
options: [...(field.options ?? [])],
fieldStyle: {
@@ -256,6 +335,7 @@ export function cloneTemplateForSave(
return {
name: template.name ?? '',
description: template.description ?? '',
remark: template.remark ?? '',
productName: template.productName ?? '',
industryName: template.industryName ?? '',
coverImage: serializeImageValue(template.coverImage ?? ''),
@@ -267,7 +347,8 @@ export function cloneTemplateForSave(
description: node.description ?? '',
locked: node.locked ?? false,
consumerVisible: node.consumerVisible ?? true,
fields: (node.fields ?? []).map((field) => ({
fields: (node.fields ?? []).map((field, fieldIndex) => ({
sort: field.sort ?? fieldIndex,
key: field.key,
label: field.label,
type: field.type ?? 'string',
@@ -278,6 +359,8 @@ export function cloneTemplateForSave(
defaultValue:
field.type === 'image'
? stripOssTempUrl(field.defaultValue ?? '')
: field.type === 'coordinate'
? normalizeCoordinateValue(field.defaultValue)
: field.defaultValue ?? '',
options: field.options ?? [],
fieldStyle: {
@@ -295,6 +378,7 @@ export function clonePreviewForSave(
return {
name: preview.name ?? '',
description: preview.description ?? '',
remark: preview.remark ?? '',
productName: preview.productName ?? '',
coverImage: serializeImageValue(preview.coverImage ?? ''),
themeColor: preview.themeColor ?? '#1f4fd6',
@@ -309,10 +393,13 @@ export function clonePreviewForSave(
key,
node.fields?.find((field) => field.key === key)?.type === 'image'
? stripOssTempUrl(value)
: node.fields?.find((field) => field.key === key)?.type === 'coordinate'
? normalizeCoordinateValue(value)
: value,
]),
),
fields: (node.fields ?? []).map((field) => ({
fields: (node.fields ?? []).map((field, fieldIndex) => ({
sort: field.sort ?? fieldIndex,
key: field.key,
label: field.label,
type: field.type ?? 'string',
@@ -323,6 +410,8 @@ export function clonePreviewForSave(
defaultValue:
field.type === 'image'
? stripOssTempUrl(field.defaultValue ?? '')
: field.type === 'coordinate'
? normalizeCoordinateValue(field.defaultValue)
: field.defaultValue ?? '',
options: field.options ?? [],
fieldStyle: {
@@ -349,6 +438,9 @@ export function formatFieldValue(value: any) {
return value.join('、');
}
if (typeof value === 'object') {
if (isCoordinateValue(value)) {
return formatCoordinateValue(value);
}
if (isOssStoredValue(value)) {
return value.objectName || '已上传图片';
}
@@ -381,6 +473,9 @@ export function normalizeFieldInput(
if (field.type === 'multi_select') {
return Array.isArray(value) ? value : [];
}
if (field.type === 'coordinate') {
return normalizeCoordinateValue(value);
}
return value;
}
+1 -1
View File
@@ -1,7 +1,7 @@
# push_docker.ps1
# Set version
$env:VERSION = "1.5.3"
$env:VERSION = "1.5.4"
# Docker registry/repository
$registry = "ai.ronsunny.cn:13011/bbit_ai/ce_vue"