完善各种需求
This commit is contained in:
@@ -8,7 +8,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "com.bbitcn"
|
||||
version = "0.0.3"
|
||||
version = "0.0.4"
|
||||
|
||||
application {
|
||||
mainClass = "io.ktor.server.netty.EngineMain"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" # 生产
|
||||
|
||||
@@ -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: '© 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,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"
|
||||
|
||||
Reference in New Issue
Block a user