From 44181bcf5adef20bcdb3d4dff04e51ebb0c6f96a Mon Sep 17 00:00:00 2001 From: BBIT-Kai <2911862937@qq.com> Date: Tue, 14 Apr 2026 16:52:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=90=84=E7=A7=8D=E9=9C=80?= =?UTF-8?q?=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- f10/build.gradle.kts | 2 +- f10/src/main/kotlin/Application.kt | 1 - f10/src/main/kotlin/TraceabilityModels.kt | 4 + f10/src/main/kotlin/TraceabilityService.kt | 99 ++-- f10/src/main/resources/application.yaml | 4 +- .../main/resources/static/traceability.css | 201 ++++++-- .../main/resources/templates/traceability.ftl | 111 ++++- .../model/database/TraceabilityTables.kt | 2 + .../model/request/TraceabilityRequest.kt | 3 + .../model/response/TraceabilityResponse.kt | 5 + .../server/utils/dao/TraceabilityDao.kt | 10 + .../web-antd/src/api/traceability/index.ts | 10 + .../web-antd/src/views/traceability/admin.vue | 362 +++++++++++---- .../components/CoordinateFieldEditor.vue | 368 +++++++++++++++ .../src/views/traceability/consumer.vue | 113 ++++- .../src/views/traceability/operator.vue | 438 +++++++++++++++--- .../src/views/traceability/preview.vue | 294 ++++++++++-- .../web-antd/src/views/traceability/shared.ts | 101 +++- vue2/scripts/push_docker.ps1 | 2 +- 19 files changed, 1848 insertions(+), 282 deletions(-) create mode 100644 vue2/apps/web-antd/src/views/traceability/components/CoordinateFieldEditor.vue diff --git a/f10/build.gradle.kts b/f10/build.gradle.kts index 952c2c9..2280d2c 100644 --- a/f10/build.gradle.kts +++ b/f10/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } group = "com.bbitcn" -version = "0.0.3" +version = "0.0.4" application { mainClass = "io.ktor.server.netty.EngineMain" diff --git a/f10/src/main/kotlin/Application.kt b/f10/src/main/kotlin/Application.kt index ce74558..e777931 100644 --- a/f10/src/main/kotlin/Application.kt +++ b/f10/src/main/kotlin/Application.kt @@ -14,7 +14,6 @@ fun Application.module() { monitor.subscribe(ApplicationStopped) { traceabilityClient.close() } - attributes.put(TraceabilityAttributes.ServiceKey, traceabilityService) configureHTTP() diff --git a/f10/src/main/kotlin/TraceabilityModels.kt b/f10/src/main/kotlin/TraceabilityModels.kt index 94120d2..1668c02 100644 --- a/f10/src/main/kotlin/TraceabilityModels.kt +++ b/f10/src/main/kotlin/TraceabilityModels.kt @@ -59,6 +59,7 @@ data class TraceBatchDetailResponse( val summary: String, val coverImage: String, val coverImagePreviewUrl: String = "", + val themeColor: String = "#1f4fd6", val tags: List, 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, diff --git a/f10/src/main/kotlin/TraceabilityService.kt b/f10/src/main/kotlin/TraceabilityService.kt index d748896..fcd32f0 100644 --- a/f10/src/main/kotlin/TraceabilityService.kt +++ b/f10/src/main/kotlin/TraceabilityService.kt @@ -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 { diff --git a/f10/src/main/resources/application.yaml b/f10/src/main/resources/application.yaml index 1400159..16ca0de 100644 --- a/f10/src/main/resources/application.yaml +++ b/f10/src/main/resources/application.yaml @@ -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" # 生产 diff --git a/f10/src/main/resources/static/traceability.css b/f10/src/main/resources/static/traceability.css index b258b57..cb5d8ef 100644 --- a/f10/src/main/resources/static/traceability.css +++ b/f10/src/main/resources/static/traceability.css @@ -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; + } } diff --git a/f10/src/main/resources/templates/traceability.ftl b/f10/src/main/resources/templates/traceability.ftl index 8f926ef..808365f 100644 --- a/f10/src/main/resources/templates/traceability.ftl +++ b/f10/src/main/resources/templates/traceability.ftl @@ -1,4 +1,4 @@ - + @@ -6,7 +6,7 @@ ${page.batchName} - 溯源信息 - +
<#if page.coverImage?has_content>
@@ -37,10 +37,6 @@
-
- 发布时间 - ${page.publishedAt} -
<#if page.tagsText != "暂无标签">
标签 @@ -78,7 +74,14 @@
${entry.label} <#if entry.type == "image" && entry.value?has_content && entry.value != "未填写"> - ${entry.label} + + <#elseif entry.type == "coordinate" && entry.value?has_content && entry.value != "未填写"> + <#if entry.mapEmbedUrl?has_content> + + + style="<#if entry.bold>font-weight:700;<#if entry.color?has_content>color:${entry.color};">${entry.value} <#else> style="<#if entry.bold>font-weight:700;<#if entry.color?has_content>color:${entry.color};">${entry.value} @@ -108,7 +111,14 @@
${entry.label} <#if entry.type == "image" && entry.value?has_content && entry.value != "未填写"> - ${entry.label} + + <#elseif entry.type == "coordinate" && entry.value?has_content && entry.value != "未填写"> + <#if entry.mapEmbedUrl?has_content> + + + style="<#if entry.bold>font-weight:700;<#if entry.color?has_content>color:${entry.color};">${entry.value} <#else> style="<#if entry.bold>font-weight:700;<#if entry.color?has_content>color:${entry.color};">${entry.value} @@ -180,8 +190,64 @@
+ diff --git a/ktor/src/main/kotlin/ink/snowflake/server/model/database/TraceabilityTables.kt b/ktor/src/main/kotlin/ink/snowflake/server/model/database/TraceabilityTables.kt index 188e81f..fb3473e 100644 --- a/ktor/src/main/kotlin/ink/snowflake/server/model/database/TraceabilityTables.kt +++ b/ktor/src/main/kotlin/ink/snowflake/server/model/database/TraceabilityTables.kt @@ -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") diff --git a/ktor/src/main/kotlin/ink/snowflake/server/model/request/TraceabilityRequest.kt b/ktor/src/main/kotlin/ink/snowflake/server/model/request/TraceabilityRequest.kt index 0d5203b..6164e25 100644 --- a/ktor/src/main/kotlin/ink/snowflake/server/model/request/TraceabilityRequest.kt +++ b/ktor/src/main/kotlin/ink/snowflake/server/model/request/TraceabilityRequest.kt @@ -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", diff --git a/ktor/src/main/kotlin/ink/snowflake/server/model/response/TraceabilityResponse.kt b/ktor/src/main/kotlin/ink/snowflake/server/model/response/TraceabilityResponse.kt index 8a0f1a0..ffe55a0 100644 --- a/ktor/src/main/kotlin/ink/snowflake/server/model/response/TraceabilityResponse.kt +++ b/ktor/src/main/kotlin/ink/snowflake/server/model/response/TraceabilityResponse.kt @@ -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 = "", diff --git a/ktor/src/main/kotlin/ink/snowflake/server/utils/dao/TraceabilityDao.kt b/ktor/src/main/kotlin/ink/snowflake/server/utils/dao/TraceabilityDao.kt index 69e59c5..24e7a0a 100644 --- a/ktor/src/main/kotlin/ink/snowflake/server/utils/dao/TraceabilityDao.kt +++ b/ktor/src/main/kotlin/ink/snowflake/server/utils/dao/TraceabilityDao.kt @@ -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 { return json.decodeFromString>(raw).map { TraceFieldDefinitionResponse( + sort = it.sort, key = it.key, label = it.label, type = it.type, diff --git a/vue2/apps/web-antd/src/api/traceability/index.ts b/vue2/apps/web-antd/src/api/traceability/index.ts index 03459f0..15ee023 100644 --- a/vue2/apps/web-antd/src/api/traceability/index.ts +++ b/vue2/apps/web-antd/src/api/traceability/index.ts @@ -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; diff --git a/vue2/apps/web-antd/src/views/traceability/admin.vue b/vue2/apps/web-antd/src/views/traceability/admin.vue index 01af638..d9701fd 100644 --- a/vue2/apps/web-antd/src/views/traceability/admin.vue +++ b/vue2/apps/web-antd/src/views/traceability/admin.vue @@ -1,7 +1,8 @@ - - - - - -
+
@@ -1011,7 +1133,7 @@ onMounted(async () => { 删除节点 -
还没有公共资料节点
+
还没有公共资料节点
@@ -1048,7 +1170,7 @@ onMounted(async () => { 删除节点 -
还没有业务流程节点
+
还没有业务流程节点
@@ -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" > {{ getFieldTypeLabel(field.type) }} @@ -1124,7 +1251,7 @@ onMounted(async () => { 删除字段 -
还没有字段,请先新增字段
+
还没有字段,请先新增字段
@@ -1305,6 +1432,12 @@ onMounted(async () => { value-format="YYYY-MM-DD HH:mm:ss" @update:value="(value) => updatePresetValue(currentTemplateField, value)" /> + { :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" > {{ getFieldTypeLabel(field.type) }} @@ -1469,7 +1607,7 @@ onMounted(async () => { 删除字段 -
还没有字段,请先新增字段
+
还没有字段,请先新增字段
@@ -1614,6 +1752,11 @@ onMounted(async () => { value-format="YYYY-MM-DD HH:mm:ss" @update:value="(value) => updatePresetValue(currentLibraryField, value)" /> + { v-model="createTemplateForm.themeColor" class="color-input" type="color" - > + /> {{ createTemplateForm.themeColor }}
@@ -1724,7 +1867,7 @@ onMounted(async () => { 模板封面图 + />
{ type="file" accept="image/*" @change="handleTemplateCoverUpload" - > + /> + + +
+ +
- diff --git a/vue2/apps/web-antd/src/views/traceability/consumer.vue b/vue2/apps/web-antd/src/views/traceability/consumer.vue index 9068116..c744146 100644 --- a/vue2/apps/web-antd/src/views/traceability/consumer.vue +++ b/vue2/apps/web-antd/src/views/traceability/consumer.vue @@ -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([]); @@ -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); +
溯源二维码
@@ -210,10 +243,38 @@ onMounted(loadBatches); {{ entry.label }} +
+ + + {{ formatFieldValue(entry.value) }} + + 打开地图 +
{{ formatFieldValue(entry.value) }} @@ -256,10 +317,38 @@ onMounted(loadBatches); {{ entry.label }} +
+ + + {{ formatFieldValue(entry.value) }} + + 打开地图 +
+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(); + 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 () => { diff --git a/vue2/apps/web-antd/src/views/traceability/preview.vue b/vue2/apps/web-antd/src/views/traceability/preview.vue index 0449403..5267ef0 100644 --- a/vue2/apps/web-antd/src/views/traceability/preview.vue +++ b/vue2/apps/web-antd/src/views/traceability/preview.vue @@ -1,8 +1,7 @@ diff --git a/vue2/apps/web-antd/src/views/traceability/shared.ts b/vue2/apps/web-antd/src/views/traceability/shared.ts index 90f9269..06e0265 100644 --- a/vue2/apps/web-antd/src/views/traceability/shared.ts +++ b/vue2/apps/web-antd/src/views/traceability/shared.ts @@ -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 { 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; } diff --git a/vue2/scripts/push_docker.ps1 b/vue2/scripts/push_docker.ps1 index bb0cfcc..2ee2f79 100644 --- a/vue2/scripts/push_docker.ps1 +++ b/vue2/scripts/push_docker.ps1 @@ -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"