diff --git a/f10/build.gradle.kts b/f10/build.gradle.kts index 325fc0d..952c2c9 100644 --- a/f10/build.gradle.kts +++ b/f10/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } group = "com.bbitcn" -version = "0.0.1" +version = "0.0.3" application { mainClass = "io.ktor.server.netty.EngineMain" diff --git a/f10/src/main/kotlin/Routing.kt b/f10/src/main/kotlin/Routing.kt index 6468841..2df781b 100644 --- a/f10/src/main/kotlin/Routing.kt +++ b/f10/src/main/kotlin/Routing.kt @@ -38,7 +38,7 @@ fun Application.configureRouting() { call.respond(mapOf("status" to "ok")) } - get("/p/{code}") { + get("/f10/{code}") { val code = call.parameters["code"]?.trim().orEmpty() if (code.isBlank()) { call.respondText("批次编码不能为空", status = HttpStatusCode.BadRequest) @@ -75,6 +75,36 @@ fun Application.configureRouting() { ) } + get("/preview/{code}") { + val code = call.parameters["code"]?.trim().orEmpty() + if (code.isBlank()) { + call.respondText("预演页编码不能为空", status = HttpStatusCode.BadRequest) + return@get + } + + val page = call.traceabilityService().loadPreviewPage(code) + if (page == null) { + call.respond( + HttpStatusCode.NotFound, + FreeMarkerContent( + "error.ftl", + mapOf("message" to "未找到对应的预演页,请确认链接是否正确。"), + ), + ) + return@get + } + + call.respond( + FreeMarkerContent( + "traceability.ftl", + mapOf( + "page" to page, + "feedbackMessage" to "", + ), + ), + ) + } + post("/feedback") { val params = call.receiveParameters() val code = params["batchCode"]?.trim().orEmpty() @@ -92,7 +122,7 @@ fun Application.configureRouting() { rating = params["rating"]?.toIntOrNull() ?: 5, ) val result = if (response.status) "success" else "failed" - call.respondRedirect("/p/$code?result=$result") + call.respondRedirect("/f10/$code?result=$result") } staticResources("/static", "static") diff --git a/f10/src/main/kotlin/TraceabilityClient.kt b/f10/src/main/kotlin/TraceabilityClient.kt index ef4f21d..34e68b3 100644 --- a/f10/src/main/kotlin/TraceabilityClient.kt +++ b/f10/src/main/kotlin/TraceabilityClient.kt @@ -52,6 +52,18 @@ class TraceabilityClient( return payload.data } + suspend fun fetchPreviewDetail(code: String): TraceabilityPublicDetailResponse? { + val response = client.get { + url("$coreBaseUrl/traceability/public/preview-page/by-code/$code") + accept(ContentType.Application.Json) + } + if (!response.status.isSuccess()) { + return null + } + val payload = response.body>() + return payload.data + } + suspend fun submitFeedback(request: SubmitTraceabilityFeedbackRequest): ApiResponse { val response = client.post { url("$coreBaseUrl/traceability/public/feedback") diff --git a/f10/src/main/kotlin/TraceabilityModels.kt b/f10/src/main/kotlin/TraceabilityModels.kt index aa5b7ad..94120d2 100644 --- a/f10/src/main/kotlin/TraceabilityModels.kt +++ b/f10/src/main/kotlin/TraceabilityModels.kt @@ -11,6 +11,12 @@ data class ApiResponse( val data: T? = null, ) +@Serializable +data class TraceFieldStyleResponse( + val bold: Boolean = false, + val color: String = "", +) + @Serializable data class TraceFieldDefinitionResponse( val key: String, @@ -20,7 +26,9 @@ data class TraceFieldDefinitionResponse( val visible: Boolean = true, val placeholder: String = "", val defaultValue: JsonElement? = null, + val defaultPreviewUrl: String? = null, val options: List = emptyList(), + val fieldStyle: TraceFieldStyleResponse = TraceFieldStyleResponse(), ) @Serializable @@ -35,6 +43,7 @@ data class TraceBatchStepResponse( val status: String, val operatorName: String, val values: JsonObject, + val valuePreviewUrls: Map = emptyMap(), val completedAt: String = "", val fields: List = emptyList(), ) @@ -49,6 +58,7 @@ data class TraceBatchDetailResponse( val productName: String, val summary: String, val coverImage: String, + val coverImagePreviewUrl: String = "", val tags: List, val status: String, val currentStep: Int, @@ -96,6 +106,8 @@ data class DisplayEntry( val label: String, val value: String, val type: String = "string", + val bold: Boolean = false, + val color: String = "", ) data class PublicSectionView( @@ -116,7 +128,6 @@ data class TimelineSectionView( data class PageViewModel( val code: String, - val pageUrl: String, val batchName: String, val productName: String, val templateName: String, diff --git a/f10/src/main/kotlin/TraceabilityService.kt b/f10/src/main/kotlin/TraceabilityService.kt index 2832b46..d748896 100644 --- a/f10/src/main/kotlin/TraceabilityService.kt +++ b/f10/src/main/kotlin/TraceabilityService.kt @@ -1,4 +1,4 @@ -package com.bbitcn +package com.bbitcn import io.ktor.server.config.ApplicationConfig import kotlinx.serialization.json.JsonArray @@ -7,7 +7,6 @@ import kotlinx.serialization.json.JsonObject data class TraceabilityPublicConfig( val coreBaseUrl: String, - val publicBaseUrl: String, ) class TraceabilityService( @@ -20,12 +19,11 @@ class TraceabilityService( return PageViewModel( code = batch.batchCode, - pageUrl = "${config.publicBaseUrl.trimEnd('/')}/p/${batch.batchCode}", batchName = batch.batchName, productName = batch.productName.ifBlank { batch.templateName }, templateName = batch.templateName, summary = batch.summary.ifBlank { "该批次已完成关键环节留痕,可查看公开资料与业务流程。" }, - coverImage = batch.coverImage, + coverImage = batch.coverImagePreviewUrl.ifBlank { batch.coverImage }, scanCount = batch.scanCount, publishedAt = formatDateOnly(batch.publishedAt), tagsText = batch.tags.joinToString("、").ifBlank { "暂无标签" }, @@ -34,6 +32,24 @@ class TraceabilityService( ) } + 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 }, + 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), + ) + } + suspend fun submitFeedback( code: String, type: String, @@ -80,10 +96,13 @@ class TraceabilityService( private fun toDisplayEntries(step: TraceBatchStepResponse): List { return step.values.entries.map { (key, value) -> val field = step.fields.find { it.key == key } + val imageUrl = step.valuePreviewUrls[key].orEmpty() DisplayEntry( label = field?.label ?: key, - value = formatJsonValue(value), + 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(), ) } } @@ -106,6 +125,5 @@ class TraceabilityService( fun ApplicationConfig.toTraceabilityPublicConfig(): TraceabilityPublicConfig { return TraceabilityPublicConfig( coreBaseUrl = property("traceability.core-base-url").getString().trimEnd('/'), - publicBaseUrl = property("traceability.public-base-url").getString().trimEnd('/'), ) } diff --git a/f10/src/main/resources/application.yaml b/f10/src/main/resources/application.yaml index bc132ee..1400159 100644 --- a/f10/src/main/resources/application.yaml +++ b/f10/src/main/resources/application.yaml @@ -6,5 +6,6 @@ ktor: port: 8081 traceability: - core-base-url: "http://127.0.0.1:8089" - public-base-url: "http://127.0.0.1:8081" + # 访问主服务的地址 +# 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 8955ce0..b258b57 100644 --- a/f10/src/main/resources/static/traceability.css +++ b/f10/src/main/resources/static/traceability.css @@ -17,9 +17,9 @@ a { } .page-shell { - max-width: 1240px; + max-width: 1440px; margin: 0 auto; - padding: 28px 16px 48px; + padding: 36px 40px 56px; } .hero, @@ -30,6 +30,25 @@ a { box-shadow: 0 16px 48px rgba(16, 24, 40, 0.08); } +.cover-panel { + margin-bottom: 18px; +} + +.cover-card { + width: 100%; + overflow: hidden; + border: 1px solid rgba(228, 234, 245, 0.9); + border-radius: 28px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 16px 48px rgba(16, 24, 40, 0.08); +} + +.cover-card img { + display: block; + width: 100%; + height: auto; +} + .hero { display: grid; grid-template-columns: 1fr; @@ -37,11 +56,6 @@ a { padding: 26px; } -.hero--with-cover { - grid-template-columns: minmax(0, 1.2fr) 320px; - align-items: stretch; -} - .hero h1, .panel h2, .info-card h3, @@ -69,26 +83,11 @@ a { .hero__stats { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 18px; } -.hero__cover { - overflow: hidden; - border: 1px solid #e8eef7; - border-radius: 22px; - background: #fff; - min-height: 240px; -} - -.hero__cover img { - display: block; - width: 100%; - height: 100%; - object-fit: cover; -} - .stat-card, .summary-card, .kv-card, @@ -134,6 +133,7 @@ a { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; + align-content: start; } .panel { @@ -146,9 +146,9 @@ a { } .tabs-nav { - display: inline-flex; - flex-wrap: wrap; - gap: 10px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; padding: 8px; border: 1px solid #e8eef7; border-radius: 999px; @@ -157,7 +157,10 @@ a { } .tab-btn { - min-width: 112px; + display: flex; + align-items: center; + justify-content: center; + min-width: 0; min-height: 42px; padding: 0 18px; border: none; @@ -168,6 +171,8 @@ a { font-weight: 600; cursor: pointer; transition: all 0.2s ease; + width: 100%; + white-space: nowrap; } .tab-btn.active { @@ -348,6 +353,14 @@ a { margin-top: 64px; } +.page-footer { + padding: 24px 4px 8px; + text-align: center; + color: #7d8899; + font-size: 13px; + line-height: 1.8; +} + @media (max-width: 992px) { .hero, .form-grid, @@ -363,14 +376,21 @@ a { .timeline-item__head { flex-direction: column; } +} + +@media (max-width: 768px) { + .page-shell { + padding: 24px 18px 44px; + } .tabs-nav { - display: grid; - grid-template-columns: 1fr; - border-radius: 20px; + gap: 4px; + padding: 6px; } .tab-btn { - width: 100%; + min-height: 40px; + padding: 0 10px; + font-size: 13px; } } diff --git a/f10/src/main/resources/templates/traceability.ftl b/f10/src/main/resources/templates/traceability.ftl index 8cd1a4b..8f926ef 100644 --- a/f10/src/main/resources/templates/traceability.ftl +++ b/f10/src/main/resources/templates/traceability.ftl @@ -8,7 +8,15 @@
-
+ <#if page.coverImage?has_content> +
+
+ ${page.batchName} +
+
+ + +

${page.batchName}

${page.summary}

@@ -21,51 +29,35 @@ 产品名称 ${page.productName}
-
- 所属模板 - ${page.templateName} -
累计访问 ${page.scanCount}
- <#if page.coverImage?has_content> -
- ${page.productName} -
- +
发布时间 ${page.publishedAt}
-
- 标签 - ${page.tagsText} -
+ <#if page.tagsText != "暂无标签"> +
+ 标签 + ${page.tagsText} +
+
- <#if feedbackMessage?has_content> -
${feedbackMessage}
- -
- +
-
-
-

溯源链

-

按业务流程顺序查看本批次的处理过程与留痕信息。

-
-
<#if page.businessSections?size gt 0>
<#list page.businessSections as section> @@ -85,10 +77,10 @@ <#list section.entries as entry>
${entry.label} - <#if entry.type == "image" && entry.value != "未填写"> + <#if entry.type == "image" && entry.value?has_content && entry.value != "未填写"> ${entry.label} <#else> - ${entry.value} + style="<#if entry.bold>font-weight:700;<#if entry.color?has_content>color:${entry.color};">${entry.value}
@@ -103,12 +95,6 @@
-
-
-

公开资料

-

面向消费者展示的企业资料、资质证明及其他公开信息。

-
-
<#if page.publicSections?size gt 0>
<#list page.publicSections as section> @@ -121,10 +107,10 @@ <#list section.entries as entry>
${entry.label} - <#if entry.type == "image" && entry.value != "未填写"> + <#if entry.type == "image" && entry.value?has_content && entry.value != "未填写"> ${entry.label} <#else> - ${entry.value} + style="<#if entry.bold>font-weight:700;<#if entry.color?has_content>color:${entry.color};">${entry.value}
@@ -140,10 +126,15 @@
-

反馈与投诉

+

反馈投诉

如发现信息异常、商品质量问题,或有建议,可直接提交。

+ + <#if feedbackMessage?has_content> +
${feedbackMessage}
+ +
+ +
+ 技术支持:四川主干信息技术有限公司 BBITCN Co.,Ltd +
+ + + + diff --git a/vue2/apps/web-antd/src/views/traceability/shared.ts b/vue2/apps/web-antd/src/views/traceability/shared.ts index 2a34f3d..90f9269 100644 --- a/vue2/apps/web-antd/src/views/traceability/shared.ts +++ b/vue2/apps/web-antd/src/views/traceability/shared.ts @@ -1,5 +1,84 @@ import type { TraceabilityApi } from '#/api'; +let localIdSeed = 0; + +export function createLocalUniqueId(prefix: string) { + localIdSeed += 1; + return `${prefix}_${Date.now()}_${localIdSeed}`; +} + +export function isOssStoredValue( + value: any, +): value is TraceabilityApi.OssStoredValue { + return !!value + && typeof value === 'object' + && typeof value.bucketName === 'string' + && typeof value.objectName === 'string'; +} + +export function parseStoredImageValue(value: any): any { + if (isOssStoredValue(value)) { + return value; + } + if (typeof value === 'string') { + const text = value.trim(); + if (text.startsWith('{')) { + try { + const parsed = JSON.parse(text); + return isOssStoredValue(parsed) ? parsed : value; + } catch { + return value; + } + } + } + return value; +} + +export function buildOssStoredValue(result: { + bucketName: string; + objectName: string; + tempUrl?: string; +}): TraceabilityApi.OssStoredValue { + return { + bucketName: result.bucketName, + objectName: result.objectName, + tempUrl: result.tempUrl, + }; +} + +export function stripOssTempUrl(value: any) { + const parsed = parseStoredImageValue(value); + if (!isOssStoredValue(parsed)) { + return value; + } + return { + bucketName: parsed.bucketName, + objectName: parsed.objectName, + }; +} + +export function serializeImageValue(value: any) { + const normalized = stripOssTempUrl(value); + if (isOssStoredValue(normalized)) { + return JSON.stringify(normalized); + } + return typeof normalized === 'string' ? normalized : ''; +} + +export function getImagePreviewSrc(value: any, previewUrl?: string) { + if (previewUrl) { + return previewUrl; + } + const parsed = parseStoredImageValue(value); + if (isOssStoredValue(parsed)) { + return parsed.tempUrl || ''; + } + if (typeof parsed === 'string') { + return parsed; + } + return ''; +} + export interface TraceabilityNodeLibraryItem { id: string; category: 'business' | 'public'; @@ -92,23 +171,33 @@ export function createField( type, required: false, visible: true, + fixedPreset: false, placeholder: '', defaultValue: '', options: [], + fieldStyle: { + bold: false, + color: '', + }, ...extra, }; } export function createEmptyField(): TraceabilityApi.FieldDefinition { return { - key: `field_${Date.now()}`, + key: createLocalUniqueId('field'), label: '新字段', type: 'string', required: false, visible: true, + fixedPreset: false, placeholder: '', defaultValue: '', options: [], + fieldStyle: { + bold: false, + color: '', + }, }; } @@ -116,6 +205,7 @@ export function createEmptyNode( category: TraceabilityApi.TemplateNode['category'] = 'business', ): TraceabilityApi.TemplateNode { return { + id: createLocalUniqueId('node'), category, name: category === 'public' ? '公开资料节点' : '业务流程节点', description: '', @@ -124,10 +214,26 @@ export function createEmptyNode( }; } +export function createEmptyPreviewNode( + category: TraceabilityApi.PreviewNode['category'] = 'business', +): TraceabilityApi.PreviewNode { + const field = createEmptyField(); + return { + id: createLocalUniqueId('preview-node'), + category, + name: category === 'public' ? '公开资料节点' : '业务流程节点', + description: '', + consumerVisible: true, + fields: [field], + values: { [field.key]: '' }, + }; +} + export function cloneNodeFromLibrary( preset: TraceabilityNodeLibraryItem, ): TraceabilityApi.TemplateNode { return { + id: createLocalUniqueId('node'), category: preset.category, name: preset.name, description: preset.description, @@ -136,6 +242,10 @@ export function cloneNodeFromLibrary( fields: preset.fields.map((field) => ({ ...field, options: [...(field.options ?? [])], + fieldStyle: { + bold: field.fieldStyle?.bold ?? false, + color: field.fieldStyle?.color ?? '', + }, })), }; } @@ -148,7 +258,7 @@ export function cloneTemplateForSave( description: template.description ?? '', productName: template.productName ?? '', industryName: template.industryName ?? '', - coverImage: template.coverImage ?? '', + coverImage: serializeImageValue(template.coverImage ?? ''), themeColor: template.themeColor ?? '#1f4fd6', status: template.status ?? 'draft', nodes: (template.nodes ?? []).map((node) => ({ @@ -163,14 +273,74 @@ export function cloneTemplateForSave( type: field.type ?? 'string', required: field.required ?? false, visible: field.visible ?? true, + fixedPreset: field.fixedPreset ?? false, placeholder: field.placeholder ?? '', - defaultValue: field.defaultValue ?? '', + defaultValue: + field.type === 'image' + ? stripOssTempUrl(field.defaultValue ?? '') + : field.defaultValue ?? '', options: field.options ?? [], + fieldStyle: { + bold: field.fieldStyle?.bold ?? false, + color: field.fieldStyle?.color ?? '', + }, })), })), }; } +export function clonePreviewForSave( + preview: Partial, +) { + return { + name: preview.name ?? '', + description: preview.description ?? '', + productName: preview.productName ?? '', + coverImage: serializeImageValue(preview.coverImage ?? ''), + themeColor: preview.themeColor ?? '#1f4fd6', + tags: preview.tags ?? [], + nodes: (preview.nodes ?? []).map((node) => ({ + category: node.category ?? 'business', + name: node.name ?? '', + description: node.description ?? '', + consumerVisible: node.consumerVisible ?? true, + values: Object.fromEntries( + Object.entries(node.values ?? {}).map(([key, value]) => [ + key, + node.fields?.find((field) => field.key === key)?.type === 'image' + ? stripOssTempUrl(value) + : value, + ]), + ), + fields: (node.fields ?? []).map((field) => ({ + key: field.key, + label: field.label, + type: field.type ?? 'string', + required: field.required ?? false, + visible: field.visible ?? true, + fixedPreset: field.fixedPreset ?? false, + placeholder: field.placeholder ?? '', + defaultValue: + field.type === 'image' + ? stripOssTempUrl(field.defaultValue ?? '') + : field.defaultValue ?? '', + options: field.options ?? [], + fieldStyle: { + bold: field.fieldStyle?.bold ?? false, + color: field.fieldStyle?.color ?? '', + }, + })), + })), + }; +} + +export function getFieldDisplayStyle(field?: TraceabilityApi.FieldDefinition) { + return { + color: field?.fieldStyle?.color || undefined, + fontWeight: field?.fieldStyle?.bold ? '700' : undefined, + }; +} + export function formatFieldValue(value: any) { if (value === null || value === undefined || value === '') { return '未填写'; @@ -179,6 +349,9 @@ export function formatFieldValue(value: any) { return value.join('、'); } if (typeof value === 'object') { + if (isOssStoredValue(value)) { + return value.objectName || '已上传图片'; + } try { return JSON.stringify(value, null, 2); } catch { diff --git a/vue2/pnpm-lock.yaml b/vue2/pnpm-lock.yaml index f1c5717..6a2fbad 100644 --- a/vue2/pnpm-lock.yaml +++ b/vue2/pnpm-lock.yaml @@ -145,10 +145,10 @@ catalogs: specifier: ^2.4.6 version: 2.4.6 '@vueuse/core': - specifier: ^13.4.0 + specifier: 13.9.0 version: 13.9.0 '@vueuse/integrations': - specifier: ^14.0.0 + specifier: 14.0.0 version: 14.0.0 '@vueuse/motion': specifier: ^3.0.3 @@ -361,7 +361,7 @@ catalogs: specifier: ^0.3.12 version: 0.3.15 qrcode: - specifier: ^1.5.4 + specifier: 1.5.4 version: 1.5.4 qs: specifier: ^6.14.0 @@ -687,6 +687,9 @@ importers: '@vueuse/core': specifier: 'catalog:' version: 13.9.0(vue@3.5.24(typescript@5.9.3)) + '@vueuse/integrations': + specifier: 'catalog:' + version: 14.0.0(async-validator@4.2.5)(axios@1.13.2)(change-case@5.4.4)(focus-trap@7.6.6)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(vue@3.5.24(typescript@5.9.3)) ant-design-vue: specifier: 'catalog:' version: 4.2.6(vue@3.5.24(typescript@5.9.3)) @@ -714,6 +717,9 @@ importers: pinia: specifier: ^3.0.3 version: 3.0.4(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)) + qrcode: + specifier: 'catalog:' + version: 1.5.4 video.js: specifier: ^8.23.4 version: 8.23.4 diff --git a/vue2/pnpm-workspace.yaml b/vue2/pnpm-workspace.yaml index 2e6cff7..82df039 100644 --- a/vue2/pnpm-workspace.yaml +++ b/vue2/pnpm-workspace.yaml @@ -63,8 +63,8 @@ catalog: '@vue/reactivity': ^3.5.17 '@vue/shared': ^3.5.24 '@vue/test-utils': ^2.4.6 - '@vueuse/core': ^13.4.0 - '@vueuse/integrations': ^14.0.0 + '@vueuse/core': 13.9.0 + '@vueuse/integrations': 14.0.0 '@vueuse/motion': ^3.0.3 ant-design-vue: ^4.2.6 archiver: ^7.0.1 @@ -143,7 +143,7 @@ catalog: prettier: ^3.6.2 prettier-plugin-tailwindcss: ^0.7.1 publint: ^0.3.12 - qrcode: ^1.5.4 + qrcode: 1.5.4 qs: ^6.14.0 reka-ui: ^2.6.0 resolve.exports: ^2.0.3 diff --git a/vue2/scripts/push_docker.ps1 b/vue2/scripts/push_docker.ps1 index 35ba2ee..bb0cfcc 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.2" +$env:VERSION = "1.5.3" # Docker registry/repository $registry = "ai.ronsunny.cn:13011/bbit_ai/ce_vue"