修复溯源模块大量问题

This commit is contained in:
BBIT-Kai
2026-04-14 10:10:52 +08:00
parent 0a43f5e4b9
commit 1c68762421
26 changed files with 3413 additions and 463 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ plugins {
}
group = "com.bbitcn"
version = "0.0.1"
version = "0.0.3"
application {
mainClass = "io.ktor.server.netty.EngineMain"
+32 -2
View File
@@ -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")
+12
View File
@@ -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<ApiResponse<TraceabilityPublicDetailResponse>>()
return payload.data
}
suspend fun submitFeedback(request: SubmitTraceabilityFeedbackRequest): ApiResponse<TraceabilityFeedbackResponse> {
val response = client.post {
url("$coreBaseUrl/traceability/public/feedback")
+12 -1
View File
@@ -11,6 +11,12 @@ data class ApiResponse<T>(
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<String> = 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<String, String> = emptyMap(),
val completedAt: String = "",
val fields: List<TraceFieldDefinitionResponse> = emptyList(),
)
@@ -49,6 +58,7 @@ data class TraceBatchDetailResponse(
val productName: String,
val summary: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val tags: List<String>,
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,
+24 -6
View File
@@ -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<DisplayEntry> {
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('/'),
)
}
+3 -2
View File
@@ -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" # 生产
+51 -31
View File
@@ -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;
}
}
@@ -8,7 +8,15 @@
</head>
<body>
<div class="page-shell">
<section class="hero<#if page.coverImage?has_content> hero--with-cover</#if>">
<#if page.coverImage?has_content>
<section class="cover-panel">
<div class="cover-card">
<img src="${page.coverImage}" alt="${page.batchName}" />
</div>
</section>
</#if>
<section class="hero">
<div class="hero__content">
<h1>${page.batchName}</h1>
<p>${page.summary}</p>
@@ -21,51 +29,35 @@
<span>产品名称</span>
<strong>${page.productName}</strong>
</div>
<div class="stat-card">
<span>所属模板</span>
<strong>${page.templateName}</strong>
</div>
<div class="stat-card">
<span>累计访问</span>
<strong>${page.scanCount}</strong>
</div>
</div>
</div>
<#if page.coverImage?has_content>
<div class="hero__cover">
<img src="${page.coverImage}" alt="${page.productName}" />
</div>
</#if>
<div class="hero__aside">
<div class="summary-card">
<span>发布时间</span>
<strong>${page.publishedAt}</strong>
</div>
<#if page.tagsText != "暂无标签">
<div class="summary-card">
<span>标签</span>
<strong>${page.tagsText}</strong>
</div>
</#if>
</div>
</section>
<#if feedbackMessage?has_content>
<section class="notice">${feedbackMessage}</section>
</#if>
<section class="panel tabs-panel">
<div class="tabs-nav" role="tablist" aria-label="溯源页面内容切换">
<button class="tab-btn active" data-tab-target="timeline-panel" type="button">溯源链</button>
<button class="tab-btn" data-tab-target="public-panel" type="button">公开资料</button>
<button class="tab-btn" data-tab-target="feedback-panel" type="button">反馈投诉</button>
<button class="tab-btn" data-tab-target="feedback-panel" type="button">反馈投诉</button>
</div>
<div id="timeline-panel" class="tab-panel active">
<div class="panel__head">
<div>
<h2>溯源链</h2>
<p>按业务流程顺序查看本批次的处理过程与留痕信息。</p>
</div>
</div>
<#if page.businessSections?size gt 0>
<div class="timeline">
<#list page.businessSections as section>
@@ -85,10 +77,10 @@
<#list section.entries as entry>
<div class="kv-card">
<span>${entry.label}</span>
<#if entry.type == "image" && entry.value != "未填写">
<#if entry.type == "image" && entry.value?has_content && entry.value != "未填写">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
<#else>
<strong>${entry.value}</strong>
<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>
</div>
</#list>
@@ -103,12 +95,6 @@
</div>
<div id="public-panel" class="tab-panel">
<div class="panel__head">
<div>
<h2>公开资料</h2>
<p>面向消费者展示的企业资料、资质证明及其他公开信息。</p>
</div>
</div>
<#if page.publicSections?size gt 0>
<div class="public-grid">
<#list page.publicSections as section>
@@ -121,10 +107,10 @@
<#list section.entries as entry>
<div class="kv-card">
<span>${entry.label}</span>
<#if entry.type == "image" && entry.value != "未填写">
<#if entry.type == "image" && entry.value?has_content && entry.value != "未填写">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
<#else>
<strong>${entry.value}</strong>
<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>
</div>
</#list>
@@ -140,10 +126,15 @@
<div id="feedback-panel" class="tab-panel">
<div class="panel__head">
<div>
<h2>反馈投诉</h2>
<h2>反馈投诉</h2>
<p>如发现信息异常、商品质量问题,或有建议,可直接提交。</p>
</div>
</div>
<#if feedbackMessage?has_content>
<div class="notice">${feedbackMessage}</div>
</#if>
<form class="feedback-form" method="post" action="/feedback">
<input type="hidden" name="batchCode" value="${page.code}" />
<div class="form-grid">
@@ -155,6 +146,7 @@
<option value="consult">咨询</option>
</select>
</label>
<label class="form-item">
<span>满意度</span>
<select name="rating">
@@ -165,19 +157,26 @@
<option value="1">1 分</option>
</select>
</label>
<label class="form-item">
<span>联系方式</span>
<input name="contact" placeholder="手机号 / 邮箱 / 微信" />
</label>
</div>
<label class="form-item form-item--full">
<span>反馈内容</span>
<textarea name="content" placeholder="请填写你要反馈的问题或建议" required></textarea>
</label>
<button type="submit" class="submit-btn">提交反馈</button>
</form>
</div>
</section>
<footer class="page-footer">
技术支持:四川主干信息技术有限公司 BBITCN Co.,Ltd
</footer>
</div>
<script>
@@ -75,5 +75,5 @@ fun Application.module() {
VideoAnalyticsJetson()
// 业务-图片分析
ImageAnalytics()
Traceability()
Traceability(appConfig)
}
@@ -2,7 +2,10 @@ package ink.snowflake.server.controller
import ink.snowflake.server.model.request.CreateTraceBatchRequest
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
import ink.snowflake.server.model.request.SaveTraceNodeLibraryRequest
import ink.snowflake.server.model.request.SaveTracePreviewPageRequest
import ink.snowflake.server.model.request.SubmitTraceabilityFeedbackRequest
import ink.snowflake.server.model.request.TraceabilityFileAssetDeleteRequest
import ink.snowflake.server.model.request.TraceabilityOssDeleteRequest
import ink.snowflake.server.model.request.TraceabilityOssMoveRequest
import ink.snowflake.server.model.request.TraceabilityOssPresignRequest
@@ -13,6 +16,7 @@ import ink.snowflake.server.model.response.BaseResponse
import ink.snowflake.server.model.response.TraceBatchStepResponse
import ink.snowflake.server.model.response.TraceabilityOssFileResponse
import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
import ink.snowflake.server.utils.AppConfig
import ink.snowflake.server.utils.OSSUtils
import ink.snowflake.server.utils.dao.TraceabilityDao
import io.ktor.http.ContentType
@@ -35,7 +39,58 @@ import kotlinx.serialization.json.JsonObject
import java.util.Locale
import java.util.UUID
fun Application.Traceability() {
private fun parseUuidOrNull(raw: String?): UUID? = runCatching {
raw?.let(UUID::fromString)
}.getOrNull()
private suspend fun handleSaveNodeLibrary(call: io.ktor.server.application.ApplicationCall, id: UUID?) {
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "节点库ID无效", data = null))
return
}
val request = call.receive<SaveTraceNodeLibraryRequest>()
call.respond(BaseResponse(data = TraceabilityDao.saveNodeLibrary(id, request)))
}
private suspend fun handleSaveTemplate(call: io.ktor.server.application.ApplicationCall, id: UUID?) {
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
return
}
val request = call.receive<SaveTraceTemplateRequest>()
call.respond(BaseResponse(data = TraceabilityDao.saveTemplate(id, request)))
}
private suspend fun handleUpdateBatchBase(call: io.ktor.server.application.ApplicationCall, id: UUID?) {
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
return
}
val request = call.receive<UpdateTraceBatchBaseRequest>()
val data = TraceabilityDao.updateBatchBase(id, request)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
return
}
call.respond(BaseResponse(data = data))
}
private suspend fun handleUpdateBatchStep(call: io.ktor.server.application.ApplicationCall, id: UUID?, stepId: UUID?) {
if (id == null || stepId == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "步骤ID无效", data = null))
return
}
val request = call.receive<UpdateTraceBatchStepRequest>()
val data = TraceabilityDao.updateBatchStep(id, stepId, request)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "步骤不存在", data = null))
return
}
call.respond(BaseResponse(data = data))
}
fun Application.Traceability(config: AppConfig) {
TraceabilityDao.init(config)
TraceabilityDao.initSchema()
routing {
@@ -43,6 +98,30 @@ fun Application.Traceability() {
get("/overview") {
call.respond(BaseResponse(data = TraceabilityDao.getOverview()))
}
route("/node-library") {
get {
call.respond(BaseResponse(data = TraceabilityDao.listNodeLibrary()))
}
post {
val request = call.receive<SaveTraceNodeLibraryRequest>()
call.respond(BaseResponse(data = TraceabilityDao.saveNodeLibrary(null, request)))
}
put("/{id}") {
handleSaveNodeLibrary(call, parseUuidOrNull(call.parameters["id"]))
}
post("/{id}") {
handleSaveNodeLibrary(call, parseUuidOrNull(call.parameters["id"]))
}
delete("/{id}") {
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "节点库ID无效", data = null))
return@delete
}
val deleted = TraceabilityDao.deleteNodeLibrary(id)
call.respond(BaseResponse(status = deleted, message = if (deleted) "节点库节点已删除" else "节点库节点不存在", data = deleted))
}
}
route("/templates") {
get {
call.respond(BaseResponse(data = TraceabilityDao.listTemplates()))
@@ -52,7 +131,7 @@ fun Application.Traceability() {
call.respond(BaseResponse(data = TraceabilityDao.saveTemplate(null, request)))
}
get("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
val id = parseUuidOrNull(call.parameters["id"])
val data = id?.let(TraceabilityDao::getTemplate)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "模板不存在", data = null))
@@ -61,16 +140,13 @@ fun Application.Traceability() {
call.respond(BaseResponse(data = data))
}
put("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
return@put
handleSaveTemplate(call, parseUuidOrNull(call.parameters["id"]))
}
val request = call.receive<SaveTraceTemplateRequest>()
call.respond(BaseResponse(data = TraceabilityDao.saveTemplate(id, request)))
post("/{id}") {
handleSaveTemplate(call, parseUuidOrNull(call.parameters["id"]))
}
delete("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
return@delete
@@ -80,16 +156,89 @@ fun Application.Traceability() {
}
}
route("/previews") {
get {
call.respond(BaseResponse(data = TraceabilityDao.listPreviewPages()))
}
post {
val request = call.receive<SaveTracePreviewPageRequest>()
call.respond(BaseResponse(data = TraceabilityDao.savePreviewPage(null, request)))
}
get("/{id}") {
val id = parseUuidOrNull(call.parameters["id"])
val data = id?.let(TraceabilityDao::getPreviewPage)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "预演页不存在", data = null))
return@get
}
call.respond(BaseResponse(data = data))
}
put("/{id}") {
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "预演页ID无效", data = null))
return@put
}
val request = call.receive<SaveTracePreviewPageRequest>()
call.respond(BaseResponse(data = TraceabilityDao.savePreviewPage(id, request)))
}
post("/{id}") {
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "预演页ID无效", data = null))
return@post
}
val request = call.receive<SaveTracePreviewPageRequest>()
call.respond(BaseResponse(data = TraceabilityDao.savePreviewPage(id, request)))
}
delete("/{id}") {
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "预演页ID无效", data = null))
return@delete
}
val deleted = TraceabilityDao.deletePreviewPage(id)
call.respond(BaseResponse(status = deleted, message = if (deleted) "预演页已删除" else "预演页不存在", data = deleted))
}
post("/{id}/sync-template") {
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "预演页ID无效", data = null))
return@post
}
val data = TraceabilityDao.syncPreviewToTemplate(id)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "预演页不存在", data = null))
return@post
}
call.respond(BaseResponse(message = "已同步为新模板", data = data))
}
}
route("/batches") {
get {
call.respond(BaseResponse(data = TraceabilityDao.listBatches()))
}
post {
val request = call.receive<CreateTraceBatchRequest>()
call.respond(BaseResponse(data = TraceabilityDao.createBatch(request)))
val templateId = parseUuidOrNull(request.templateId)
if (templateId == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
return@post
}
if (TraceabilityDao.getTemplate(templateId) == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "模板不存在", data = null))
return@post
}
val data = TraceabilityDao.createBatch(request)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "模板不存在", data = null))
return@post
}
call.respond(BaseResponse(data = data))
}
delete("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
return@delete
@@ -98,7 +247,7 @@ fun Application.Traceability() {
call.respond(BaseResponse(status = deleted, message = if (deleted) "批次已删除" else "批次不存在", data = deleted))
}
get("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
val id = parseUuidOrNull(call.parameters["id"])
val data = id?.let(TraceabilityDao::getBatch)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
@@ -107,36 +256,27 @@ fun Application.Traceability() {
call.respond(BaseResponse(data = data))
}
put("/{id}/base") {
val id = call.parameters["id"]?.let(UUID::fromString)
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
return@put
handleUpdateBatchBase(call, parseUuidOrNull(call.parameters["id"]))
}
val request = call.receive<UpdateTraceBatchBaseRequest>()
val data = TraceabilityDao.updateBatchBase(id, request)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
return@put
}
call.respond(BaseResponse(data = data))
post("/{id}/base") {
handleUpdateBatchBase(call, parseUuidOrNull(call.parameters["id"]))
}
put("/{id}/steps/{stepId}") {
val id = call.parameters["id"]?.let(UUID::fromString)
val stepId = call.parameters["stepId"]?.let(UUID::fromString)
if (id == null || stepId == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "步骤ID无效", data = null))
return@put
handleUpdateBatchStep(
call,
parseUuidOrNull(call.parameters["id"]),
parseUuidOrNull(call.parameters["stepId"]),
)
}
val request = call.receive<UpdateTraceBatchStepRequest>()
val data = TraceabilityDao.updateBatchStep(id, stepId, request)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "步骤不存在", data = null))
return@put
}
call.respond(BaseResponse(data = data))
post("/{id}/steps/{stepId}") {
handleUpdateBatchStep(
call,
parseUuidOrNull(call.parameters["id"]),
parseUuidOrNull(call.parameters["stepId"]),
)
}
post("/{id}/publish") {
val id = call.parameters["id"]?.let(UUID::fromString)
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
return@post
@@ -156,7 +296,12 @@ fun Application.Traceability() {
}
post {
val request = call.receive<SubmitTraceabilityFeedbackRequest>()
call.respond(BaseResponse(message = "反馈已提交", data = TraceabilityDao.submitFeedback(request)))
val data = TraceabilityDao.submitFeedback(request)
if (data == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次不存在或批次参数无效", data = null))
return@post
}
call.respond(BaseResponse(message = "反馈已提交", data = data))
}
}
route("/public") {
@@ -170,9 +315,42 @@ fun Application.Traceability() {
}
call.respond(BaseResponse(data = data))
}
get("/preview/by-code/{code}") {
val code = call.parameters["code"] ?: ""
val data = TraceabilityDao.getPublicDetailByCode(
batchCode = code,
increaseScan = false,
onlyPublished = false,
)
if (data == null) {
call.respond(
HttpStatusCode.NotFound,
BaseResponse(status = false, message = "未找到对应批次", data = null),
)
return@get
}
call.respond(BaseResponse(data = data))
}
get("/preview-page/by-code/{code}") {
val code = call.parameters["code"] ?: ""
val data = TraceabilityDao.getPreviewPublicDetailByCode(code)
if (data == null) {
call.respond(
HttpStatusCode.NotFound,
BaseResponse(status = false, message = "未找到对应预演页", data = null),
)
return@get
}
call.respond(BaseResponse(data = data))
}
post("/feedback") {
val request = call.receive<SubmitTraceabilityFeedbackRequest>()
call.respond(BaseResponse(message = "感谢反馈,我们会尽快处理", data = TraceabilityDao.submitFeedback(request)))
val data = TraceabilityDao.submitFeedback(request)
if (data == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "未找到对应批次,无法提交反馈", data = null))
return@post
}
call.respond(BaseResponse(message = "感谢反馈,我们会尽快处理", data = data))
}
get("/page/{code}") {
val code = call.parameters["code"] ?: ""
@@ -191,6 +369,7 @@ fun Application.Traceability() {
var bucketName = OSSUtils.defaultBucket()
var objectDir = "traceability/images"
var objectName = ""
var assetType = ""
var response: TraceabilityOssFileResponse? = null
multipart.forEachPart { part ->
@@ -200,6 +379,7 @@ fun Application.Traceability() {
"bucketName" -> bucketName = part.value.ifBlank { OSSUtils.defaultBucket() }
"objectDir" -> objectDir = part.value.ifBlank { "traceability/images" }
"objectName" -> objectName = part.value
"assetType" -> assetType = part.value.trim()
}
}
@@ -223,6 +403,16 @@ fun Application.Traceability() {
fileName = fileName,
size = bytes.size.toLong(),
)
if (assetType.isNotBlank()) {
TraceabilityDao.recordFileAsset(
assetType = assetType,
bucketName = bucketName,
objectName = finalObjectName,
fileName = fileName,
contentType = contentType,
size = bytes.size.toLong(),
)
}
}
else -> {}
@@ -240,6 +430,39 @@ fun Application.Traceability() {
call.respond(BaseResponse(message = "图片上传成功", data = response))
}
get("/history") {
val assetType = call.request.queryParameters["assetType"]?.trim().orEmpty()
val limit = call.request.queryParameters["limit"]?.toIntOrNull()?.coerceIn(1, 100) ?: 24
if (assetType.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
BaseResponse(status = false, message = "assetType 不能为空", data = null),
)
return@get
}
call.respond(BaseResponse(data = TraceabilityDao.listFileAssets(assetType, limit)))
}
post("/history/delete") {
val request = call.receive<TraceabilityFileAssetDeleteRequest>()
val assetId = parseUuidOrNull(request.id)
if (assetId == null) {
call.respond(
HttpStatusCode.BadRequest,
BaseResponse(status = false, message = "历史文件ID无效", data = false),
)
return@post
}
val (status, messageText) = TraceabilityDao.deleteFileAsset(assetId)
call.respond(
if (status) {
BaseResponse(message = messageText, data = true)
} else {
BaseResponse(status = false, message = messageText, data = false)
},
)
}
post("/presigned-put") {
val request = call.receive<TraceabilityOssPresignRequest>()
val bucketName = request.bucketName?.ifBlank { OSSUtils.defaultBucket() } ?: OSSUtils.defaultBucket()
@@ -324,7 +547,8 @@ private fun renderTraceabilityPage(detail: TraceabilityPublicDetailResponse): St
val batch = detail.batch
val publicCards = detail.publicSections.joinToString("") { renderSectionCard(it) }
val timelineCards = detail.businessSections.joinToString("") { renderTimelineCard(it) }
val cover = batch.coverImage.takeIf { it.isNotBlank() }
val cover = batch.coverImagePreviewUrl.takeIf { it.isNotBlank() }
?: batch.coverImage.takeIf { it.isNotBlank() }
?: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1400&q=80"
return """
@@ -405,18 +629,18 @@ private fun renderTraceabilityPage(detail: TraceabilityPublicDetailResponse): St
<div class="timeline">$timelineCards</div>
</section>
<section class="section">
<div class="section-head"><div><h2>投诉与建议</h2><p>如果你发现信息异常、质量问题,或有优化建议,可以直接提交。</p></div></div>
<div class="section-head"><div><h2>反馈与投诉</h2><p>如果你发现信息异常、质量问题,或有优化建议,可以直接提交。</p></div></div>
<div class="feedback-grid">
<div class="feedback">
<form id="feedback-form">
<label>反馈类型</label>
<select name="type"><option value="complaint">投诉</option><option value="suggestion">建议</option><option value="consult">咨询</option></select>
<label>满意度</label>
<select name="rating"><option value="5">5 分</option><option value="4">4 分</option><option value="3">3 分</option><option value="2">2 分</option><option value="1">1 分</option></select>
<label>联系方式</label>
<input name="contact" placeholder="手机号、邮箱或微信(选填)" />
<label>反馈内容</label>
<textarea name="content" placeholder="请描述你的问题或建议"></textarea>
<label>满意度</label>
<select name="rating"><option value="5">5 分</option><option value="4">4 分</option><option value="3">3 分</option><option value="2">2 分</option><option value="1">1 分</option></select>
<button type="submit">提交反馈</button>
</form>
</div>
@@ -450,15 +674,21 @@ private fun renderTraceabilityPage(detail: TraceabilityPublicDetailResponse): St
}
private fun renderSectionCard(step: TraceBatchStepResponse): String =
"""<article class="section-card"><h3>${escapeHtml(step.name)}</h3><p class="muted">${escapeHtml(step.description)}</p><div class="kv-grid">${renderValueCards(step.values)}</div></article>"""
"""<article class="section-card"><h3>${escapeHtml(step.name)}</h3><p class="muted">${escapeHtml(step.description)}</p><div class="kv-grid">${renderValueCards(step)}</div></article>"""
private fun renderTimelineCard(step: TraceBatchStepResponse): String {
val time = step.completedAt.ifBlank { "待补充" }
return """<div class="timeline-item"><div class="timeline-rail"><span class="dot"></span><span class="line"></span></div><div class="timeline-card"><div class="timeline-meta"><div><h3 style="margin:0;">${escapeHtml(step.name)}</h3><p class="muted">${escapeHtml(step.description)}</p></div><span class="tag">${escapeHtml(step.status)} · ${escapeHtml(time)}</span></div><div class="kv-grid">${renderValueCards(step.values)}</div></div></div>"""
return """<div class="timeline-item"><div class="timeline-rail"><span class="dot"></span><span class="line"></span></div><div class="timeline-card"><div class="timeline-meta"><div><h3 style="margin:0;">${escapeHtml(step.name)}</h3><p class="muted">${escapeHtml(step.description)}</p></div></div><div class="kv-grid">${renderValueCards(step)}</div></div></div>"""
}
private fun renderValueCards(values: JsonObject): String = values.entries.joinToString("") { (key, value) ->
"""<div class="kv"><span>${escapeHtml(key)}</span><strong>${escapeHtml(formatJsonValue(value))}</strong></div>"""
private fun renderValueCards(step: TraceBatchStepResponse): String = step.values.entries.joinToString("") { (key, value) ->
val field = step.fields.find { it.key == key }
val label = field?.label ?: key
val imageUrl = step.valuePreviewUrls[key].orEmpty()
if ((field?.type ?: "string") == "image" && imageUrl.isNotBlank()) {
"""<div class="kv"><span>${escapeHtml(label)}</span><img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(label)}" style="display:block;width:100%;max-height:220px;margin-top:8px;border:1px solid #dbe3f0;border-radius:14px;object-fit:cover;background:#fff;" /></div>"""
} else {
"""<div class="kv"><span>${escapeHtml(label)}</span><strong>${escapeHtml(formatJsonValue(value))}</strong></div>"""
}
}
private fun formatJsonValue(value: JsonElement): String = when (value) {
@@ -28,6 +28,41 @@ object TraceabilityTemplateNodesTable : UUIDTable("traceability_template_nodes")
val updatedAt = timestamp("updated_at").nullable()
}
object TraceabilityPreviewPagesTable : UUIDTable("traceability_preview_pages") {
val name = varchar("name", 120)
val previewCode = varchar("preview_code", 120).uniqueIndex()
val description = text("description").default("")
val productName = varchar("product_name", 120).default("")
val coverImage = text("cover_image").default("")
val themeColor = varchar("theme_color", 20).default("#1f4fd6")
val tagsJson = text("tags_json").default("[]")
val createdAt = timestamp("created_at").nullable()
val updatedAt = timestamp("updated_at").nullable()
}
object TraceabilityPreviewNodesTable : UUIDTable("traceability_preview_nodes") {
val previewPageId = reference("preview_page_id", TraceabilityPreviewPagesTable)
val sort = integer("sort").default(0)
val category = varchar("category", 32).default("business")
val name = varchar("name", 120)
val description = text("description").default("")
val consumerVisible = bool("consumer_visible").default(true)
val fieldsJson = text("fields_json").default("[]")
val valuesJson = text("values_json").default("{}")
val createdAt = timestamp("created_at").nullable()
val updatedAt = timestamp("updated_at").nullable()
}
object TraceabilityNodeLibraryTable : UUIDTable("traceability_node_library") {
val category = varchar("category", 32).default("business")
val name = varchar("name", 120)
val description = text("description").default("")
val consumerVisible = bool("consumer_visible").default(true)
val fieldsJson = text("fields_json")
val createdAt = timestamp("created_at").nullable()
val updatedAt = timestamp("updated_at").nullable()
}
object TraceabilityBatchesTable : UUIDTable("traceability_batches") {
val templateId = reference("template_id", TraceabilityTemplatesTable)
val batchName = varchar("batch_name", 150)
@@ -55,6 +90,7 @@ object TraceabilityBatchStepsTable : UUIDTable("traceability_batch_steps") {
val consumerVisible = bool("consumer_visible").default(true)
val status = varchar("status", 32).default("pending")
val operatorName = varchar("operator_name", 80).default("")
val fieldsJson = text("fields_json").default("[]")
val valuesJson = text("values_json").default("{}")
val completedAt = timestamp("completed_at").nullable()
val createdAt = timestamp("created_at").nullable()
@@ -70,3 +106,13 @@ object TraceabilityFeedbackTable : UUIDTable("traceability_feedback") {
val rating = integer("rating").default(5)
val createdAt = timestamp("created_at").nullable()
}
object TraceabilityFileAssetsTable : UUIDTable("traceability_file_assets") {
val assetType = varchar("asset_type", 32).default("general")
val bucketName = varchar("bucket_name", 120)
val objectName = text("object_name")
val fileName = varchar("file_name", 255).default("")
val contentType = varchar("content_type", 120).default("")
val size = long("size").default(0)
val createdAt = timestamp("created_at").nullable()
}
@@ -5,6 +5,12 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import java.util.UUID
@Serializable
data class TraceFieldStyleRequest(
val bold: Boolean = false,
val color: String = "",
)
@Serializable
data class TraceFieldDefinitionRequest(
val key: String,
@@ -12,9 +18,11 @@ data class TraceFieldDefinitionRequest(
val type: String = "string",
val required: Boolean = false,
val visible: Boolean = true,
val fixedPreset: Boolean = false,
val placeholder: String = "",
val defaultValue: JsonElement? = null,
val options: List<String> = emptyList(),
val fieldStyle: TraceFieldStyleRequest = TraceFieldStyleRequest(),
)
@Serializable
@@ -28,6 +36,17 @@ data class TraceTemplateNodeRequest(
val fields: List<TraceFieldDefinitionRequest> = emptyList(),
)
@Serializable
data class TracePreviewNodeRequest(
val id: String? = null,
val category: String = "business",
val name: String,
val description: String = "",
val consumerVisible: Boolean = true,
val fields: List<TraceFieldDefinitionRequest> = emptyList(),
val values: JsonObject = JsonObject(emptyMap()),
)
@Serializable
data class SaveTraceTemplateRequest(
val name: String,
@@ -40,6 +59,26 @@ data class SaveTraceTemplateRequest(
val nodes: List<TraceTemplateNodeRequest> = emptyList(),
)
@Serializable
data class SaveTracePreviewPageRequest(
val name: String,
val description: String = "",
val productName: String = "",
val coverImage: String = "",
val themeColor: String = "#1f4fd6",
val tags: List<String> = emptyList(),
val nodes: List<TracePreviewNodeRequest> = emptyList(),
)
@Serializable
data class SaveTraceNodeLibraryRequest(
val category: String = "business",
val name: String,
val description: String = "",
val consumerVisible: Boolean = true,
val fields: List<TraceFieldDefinitionRequest> = emptyList(),
)
@Serializable
data class CreateTraceBatchRequest(
val templateId: String,
@@ -109,4 +148,9 @@ data class TraceabilityOssDeleteRequest(
val objectName: String,
)
@Serializable
data class TraceabilityFileAssetDeleteRequest(
val id: String,
)
fun CreateTraceBatchRequest.templateUuid(): UUID = UUID.fromString(templateId)
@@ -13,6 +13,12 @@ data class TraceabilityOverviewResponse(
val totalScans: Int,
)
@Serializable
data class TraceFieldStyleResponse(
val bold: Boolean = false,
val color: String = "",
)
@Serializable
data class TraceFieldDefinitionResponse(
val key: String,
@@ -20,9 +26,12 @@ data class TraceFieldDefinitionResponse(
val type: String = "string",
val required: Boolean = false,
val visible: Boolean = true,
val fixedPreset: Boolean = false,
val placeholder: String = "",
val defaultValue: JsonElement? = null,
val defaultPreviewUrl: String? = null,
val options: List<String> = emptyList(),
val fieldStyle: TraceFieldStyleResponse = TraceFieldStyleResponse(),
)
@Serializable
@@ -37,6 +46,17 @@ data class TraceTemplateNodeResponse(
val fields: List<TraceFieldDefinitionResponse>,
)
@Serializable
data class TraceNodeLibraryResponse(
val id: String,
val category: String,
val name: String,
val description: String,
val consumerVisible: Boolean,
val fields: List<TraceFieldDefinitionResponse>,
val updatedAt: String,
)
@Serializable
data class TraceTemplateSummaryResponse(
val id: String,
@@ -45,6 +65,7 @@ data class TraceTemplateSummaryResponse(
val productName: String,
val industryName: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val themeColor: String,
val status: String,
val nodeCount: Int,
@@ -60,12 +81,57 @@ data class TraceTemplateDetailResponse(
val productName: String,
val industryName: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val themeColor: String,
val status: String,
val nodes: List<TraceTemplateNodeResponse>,
val updatedAt: String,
)
@Serializable
data class TracePreviewNodeResponse(
val id: String,
val sort: Int,
val category: String,
val name: String,
val description: String,
val consumerVisible: Boolean,
val values: JsonObject,
val valuePreviewUrls: Map<String, String> = emptyMap(),
val fields: List<TraceFieldDefinitionResponse>,
)
@Serializable
data class TracePreviewPageSummaryResponse(
val id: String,
val name: String,
val previewCode: String,
val description: String,
val productName: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val themeColor: String,
val tags: List<String>,
val publicUrl: String,
val updatedAt: String,
)
@Serializable
data class TracePreviewPageDetailResponse(
val id: String,
val name: String,
val previewCode: String,
val description: String,
val productName: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val themeColor: String,
val tags: List<String>,
val publicUrl: String,
val nodes: List<TracePreviewNodeResponse>,
val updatedAt: String,
)
@Serializable
data class TraceBatchStepResponse(
val id: String,
@@ -79,6 +145,7 @@ data class TraceBatchStepResponse(
val status: String,
val operatorName: String,
val values: JsonObject,
val valuePreviewUrls: Map<String, String> = emptyMap(),
val completedAt: String = "",
val fields: List<TraceFieldDefinitionResponse> = emptyList(),
)
@@ -93,6 +160,7 @@ data class TraceBatchSummaryResponse(
val productName: String,
val summary: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val tags: List<String>,
val status: String,
val currentStep: Int,
@@ -111,6 +179,7 @@ data class TraceBatchDetailResponse(
val productName: String,
val summary: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val tags: List<String>,
val status: String,
val currentStep: Int,
@@ -153,3 +222,16 @@ data class TraceabilityOssFileResponse(
val fileName: String? = null,
val size: Long? = null,
)
@Serializable
data class TraceabilityFileAssetResponse(
val id: String,
val assetType: String,
val bucketName: String,
val objectName: String,
val fileName: String,
val contentType: String,
val size: Long,
val previewUrl: String,
val createdAt: String,
)
@@ -26,4 +26,11 @@ class AppConfig(config: ApplicationConfig) {
val ossDefaultBucket: String = config.property("ktor.oss.default-bucket").getString()
val ossFallbackBucket: String = config.property("ktor.oss.fallback-bucket").getString()
val ossFallbackObject: String = config.property("ktor.oss.fallback-object").getString()
val traceabilityPublicPreviewBaseUrl: String =
config.propertyOrNull("ktor.traceability.public-preview-base-url")
?.getString()
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?: "http://127.0.0.1:8081"
}
@@ -3,12 +3,19 @@ package ink.snowflake.server.utils.dao
import ink.snowflake.server.model.database.TraceabilityBatchStepsTable
import ink.snowflake.server.model.database.TraceabilityBatchesTable
import ink.snowflake.server.model.database.TraceabilityFeedbackTable
import ink.snowflake.server.model.database.TraceabilityFileAssetsTable
import ink.snowflake.server.model.database.TraceabilityNodeLibraryTable
import ink.snowflake.server.model.database.TraceabilityPreviewNodesTable
import ink.snowflake.server.model.database.TraceabilityPreviewPagesTable
import ink.snowflake.server.model.database.TraceabilityTemplateNodesTable
import ink.snowflake.server.model.database.TraceabilityTemplatesTable
import ink.snowflake.server.model.request.CreateTraceBatchRequest
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
import ink.snowflake.server.model.request.SaveTraceNodeLibraryRequest
import ink.snowflake.server.model.request.SaveTracePreviewPageRequest
import ink.snowflake.server.model.request.SubmitTraceabilityFeedbackRequest
import ink.snowflake.server.model.request.TraceFieldDefinitionRequest
import ink.snowflake.server.model.request.TracePreviewNodeRequest
import ink.snowflake.server.model.request.UpdateTraceBatchBaseRequest
import ink.snowflake.server.model.request.UpdateTraceBatchStepRequest
import ink.snowflake.server.model.request.templateUuid
@@ -16,19 +23,30 @@ import ink.snowflake.server.model.response.TraceBatchDetailResponse
import ink.snowflake.server.model.response.TraceBatchStepResponse
import ink.snowflake.server.model.response.TraceBatchSummaryResponse
import ink.snowflake.server.model.response.TraceFieldDefinitionResponse
import ink.snowflake.server.model.response.TraceFieldStyleResponse
import ink.snowflake.server.model.response.TraceNodeLibraryResponse
import ink.snowflake.server.model.response.TracePreviewNodeResponse
import ink.snowflake.server.model.response.TracePreviewPageDetailResponse
import ink.snowflake.server.model.response.TracePreviewPageSummaryResponse
import ink.snowflake.server.model.response.TraceTemplateDetailResponse
import ink.snowflake.server.model.response.TraceTemplateNodeResponse
import ink.snowflake.server.model.response.TraceTemplateSummaryResponse
import ink.snowflake.server.model.response.TraceabilityFeedbackResponse
import ink.snowflake.server.model.response.TraceabilityFileAssetResponse
import ink.snowflake.server.model.response.TraceabilityOverviewResponse
import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
import ink.snowflake.server.utils.AppConfig
import ink.snowflake.server.utils.OSSUtils
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and
@@ -47,21 +65,24 @@ object TraceabilityDao {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val publicPreviewBaseUrl =
System.getenv("TRACEABILITY_PUBLIC_PREVIEW_BASE_URL")
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?: "http://127.0.0.1:8081"
private var publicPreviewBaseUrl: String = "http://127.0.0.1:8081"
fun init(config: AppConfig) {
publicPreviewBaseUrl = config.traceabilityPublicPreviewBaseUrl
}
fun initSchema() {
transaction {
SchemaUtils.createMissingTablesAndColumns(
TraceabilityTemplatesTable,
TraceabilityTemplateNodesTable,
TraceabilityPreviewPagesTable,
TraceabilityPreviewNodesTable,
TraceabilityNodeLibraryTable,
TraceabilityBatchesTable,
TraceabilityBatchStepsTable,
TraceabilityFeedbackTable,
TraceabilityFileAssetsTable,
)
}
}
@@ -79,8 +100,374 @@ object TraceabilityDao {
)
}
fun listPreviewPages(): List<TracePreviewPageSummaryResponse> = transaction {
TraceabilityPreviewPagesTable.selectAll()
.orderBy(TraceabilityPreviewPagesTable.updatedAt, SortOrder.DESC)
.map {
val code = it[TraceabilityPreviewPagesTable.previewCode]
TracePreviewPageSummaryResponse(
id = it[TraceabilityPreviewPagesTable.id].value.toString(),
name = it[TraceabilityPreviewPagesTable.name],
previewCode = code,
description = it[TraceabilityPreviewPagesTable.description],
productName = it[TraceabilityPreviewPagesTable.productName],
coverImage = it[TraceabilityPreviewPagesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(it[TraceabilityPreviewPagesTable.coverImage]).orEmpty(),
themeColor = it[TraceabilityPreviewPagesTable.themeColor],
tags = decodeStringList(it[TraceabilityPreviewPagesTable.tagsJson]),
publicUrl = previewPublicUrl(code),
updatedAt = formatTimestamp(it[TraceabilityPreviewPagesTable.updatedAt]),
)
}
}
fun getPreviewPage(previewId: UUID): TracePreviewPageDetailResponse? = transaction {
val row = TraceabilityPreviewPagesTable.selectAll()
.where { TraceabilityPreviewPagesTable.id eq previewId }
.singleOrNull() ?: return@transaction null
val code = row[TraceabilityPreviewPagesTable.previewCode]
TracePreviewPageDetailResponse(
id = row[TraceabilityPreviewPagesTable.id].value.toString(),
name = row[TraceabilityPreviewPagesTable.name],
previewCode = code,
description = row[TraceabilityPreviewPagesTable.description],
productName = row[TraceabilityPreviewPagesTable.productName],
coverImage = row[TraceabilityPreviewPagesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(row[TraceabilityPreviewPagesTable.coverImage]).orEmpty(),
themeColor = row[TraceabilityPreviewPagesTable.themeColor],
tags = decodeStringList(row[TraceabilityPreviewPagesTable.tagsJson]),
publicUrl = previewPublicUrl(code),
nodes = loadPreviewNodes(previewId),
updatedAt = formatTimestamp(row[TraceabilityPreviewPagesTable.updatedAt]),
)
}
fun savePreviewPage(previewId: UUID?, request: SaveTracePreviewPageRequest): TracePreviewPageDetailResponse = transaction {
val now = timestampLiteral(nowInstant())
val currentId = previewId ?: TraceabilityPreviewPagesTable.insertAndGetId {
it[name] = request.name
it[previewCode] = buildPreviewCode()
it[description] = request.description
it[productName] = request.productName
it[coverImage] = request.coverImage
it[themeColor] = request.themeColor
it[tagsJson] = json.encodeToString(request.tags)
it[createdAt] = now
it[updatedAt] = now
}.value
if (previewId != null) {
TraceabilityPreviewPagesTable.update({ TraceabilityPreviewPagesTable.id eq currentId }) {
it[name] = request.name
it[description] = request.description
it[productName] = request.productName
it[coverImage] = request.coverImage
it[themeColor] = request.themeColor
it[tagsJson] = json.encodeToString(request.tags)
it[updatedAt] = now
}
TraceabilityPreviewNodesTable.deleteWhere { TraceabilityPreviewNodesTable.previewPageId eq currentId }
}
request.nodes.forEachIndexed { index, node ->
TraceabilityPreviewNodesTable.insertAndGetId {
it[previewPageId] = currentId
it[sort] = index
it[category] = node.category
it[name] = node.name
it[description] = node.description
it[consumerVisible] = node.consumerVisible
it[fieldsJson] = json.encodeToString(node.fields)
it[valuesJson] = json.encodeToString(node.values)
it[createdAt] = now
it[updatedAt] = now
}
}
getPreviewPage(currentId)!!
}
fun deletePreviewPage(previewId: UUID): Boolean = transaction {
TraceabilityPreviewNodesTable.deleteWhere { TraceabilityPreviewNodesTable.previewPageId eq previewId }
TraceabilityPreviewPagesTable.deleteWhere { TraceabilityPreviewPagesTable.id eq previewId } > 0
}
fun syncPreviewToTemplate(previewId: UUID): TraceTemplateDetailResponse? = transaction {
val detail = getPreviewPage(previewId) ?: return@transaction null
val request = SaveTraceTemplateRequest(
name = detail.name,
description = detail.description,
productName = detail.productName,
coverImage = detail.coverImage,
themeColor = detail.themeColor,
status = "draft",
nodes = detail.nodes.map { node ->
ink.snowflake.server.model.request.TraceTemplateNodeRequest(
category = node.category,
name = node.name,
description = node.description,
locked = false,
consumerVisible = node.consumerVisible,
fields = node.fields.map { field ->
val value = node.values[field.key] ?: field.defaultValue
TraceFieldDefinitionRequest(
key = field.key,
label = field.label,
type = field.type,
required = field.required,
visible = field.visible,
fixedPreset = field.fixedPreset,
placeholder = field.placeholder,
defaultValue = value,
options = field.options,
fieldStyle = ink.snowflake.server.model.request.TraceFieldStyleRequest(
bold = field.fieldStyle.bold,
color = field.fieldStyle.color,
),
)
},
)
},
)
saveTemplate(null, request)
}
fun recordFileAsset(
assetType: String,
bucketName: String,
objectName: String,
fileName: String,
contentType: String,
size: Long,
) = transaction {
val now = timestampLiteral(nowInstant())
TraceabilityFileAssetsTable.insertAndGetId {
it[this.assetType] = assetType.ifBlank { "general" }
it[this.bucketName] = bucketName
it[this.objectName] = objectName
it[this.fileName] = fileName
it[this.contentType] = contentType
it[this.size] = size
it[createdAt] = now
}
}
fun listFileAssets(assetType: String, limit: Int = 24): List<TraceabilityFileAssetResponse> = transaction {
val items = LinkedHashMap<String, TraceabilityFileAssetResponse>()
TraceabilityFileAssetsTable.selectAll()
.where { TraceabilityFileAssetsTable.assetType eq assetType }
.orderBy(TraceabilityFileAssetsTable.createdAt, SortOrder.DESC)
.limit(limit)
.forEach {
val bucketName = it[TraceabilityFileAssetsTable.bucketName]
val objectName = it[TraceabilityFileAssetsTable.objectName]
items["$bucketName::$objectName"] = TraceabilityFileAssetResponse(
id = it[TraceabilityFileAssetsTable.id].value.toString(),
assetType = it[TraceabilityFileAssetsTable.assetType],
bucketName = bucketName,
objectName = objectName,
fileName = it[TraceabilityFileAssetsTable.fileName],
contentType = it[TraceabilityFileAssetsTable.contentType],
size = it[TraceabilityFileAssetsTable.size],
previewUrl = OSSUtils.getTempUrl(bucketName, objectName),
createdAt = formatTimestamp(it[TraceabilityFileAssetsTable.createdAt]),
)
}
if (assetType == "cover" && items.size < limit) {
val legacySources = buildList {
addAll(
TraceabilityTemplatesTable.selectAll()
.where { TraceabilityTemplatesTable.coverImage neq "" }
.map { it[TraceabilityTemplatesTable.coverImage] to formatTimestamp(it[TraceabilityTemplatesTable.updatedAt]) },
)
addAll(
TraceabilityBatchesTable.selectAll()
.where { TraceabilityBatchesTable.coverImage neq "" }
.map { it[TraceabilityBatchesTable.coverImage] to formatTimestamp(it[TraceabilityBatchesTable.updatedAt]) },
)
}
legacySources.forEachIndexed { index, (raw, createdAt) ->
if (items.size >= limit) return@forEachIndexed
val previewUrl = resolveStoredImagePreviewUrl(raw) ?: return@forEachIndexed
val (bucketName, objectName) = extractStoredImageReference(raw)
val identity = if (objectName.isNotBlank()) "$bucketName::$objectName" else "legacy::$previewUrl"
if (items.containsKey(identity)) return@forEachIndexed
items[identity] = TraceabilityFileAssetResponse(
id = "legacy-cover-$index",
assetType = assetType,
bucketName = bucketName,
objectName = objectName.ifBlank { previewUrl },
fileName = objectName.substringAfterLast('/').ifBlank { "历史封面图" },
contentType = "image/*",
size = 0,
previewUrl = previewUrl,
createdAt = createdAt,
)
}
}
items.values.take(limit)
}
fun deleteFileAsset(assetId: UUID): Pair<Boolean, String> = transaction {
val asset = TraceabilityFileAssetsTable.selectAll()
.where { TraceabilityFileAssetsTable.id eq assetId }
.singleOrNull()
?: return@transaction false to "历史文件不存在"
val bucketName = asset[TraceabilityFileAssetsTable.bucketName]
val objectName = asset[TraceabilityFileAssetsTable.objectName]
val usedByTemplate = TraceabilityTemplatesTable.selectAll().any {
val (bucket, obj) = extractStoredImageReference(it[TraceabilityTemplatesTable.coverImage])
bucket == bucketName && obj == objectName
}
if (usedByTemplate) {
return@transaction false to "该封面图仍被模板使用,无法删除"
}
val usedByBatch = TraceabilityBatchesTable.selectAll().any {
val (bucket, obj) = extractStoredImageReference(it[TraceabilityBatchesTable.coverImage])
bucket == bucketName && obj == objectName
}
if (usedByBatch) {
return@transaction false to "该封面图仍被批次使用,无法删除"
}
runCatching {
OSSUtils.deleteFile(bucketName, objectName)
}
TraceabilityFileAssetsTable.deleteWhere { TraceabilityFileAssetsTable.id eq assetId }
true to "历史封面图已删除"
}
private fun nowInstant() = Clock.System.now()
fun listNodeLibrary(): List<TraceNodeLibraryResponse> = transaction {
ensureDefaultNodeLibrarySeeded()
TraceabilityNodeLibraryTable.selectAll()
.orderBy(TraceabilityNodeLibraryTable.updatedAt, SortOrder.DESC)
.map {
TraceNodeLibraryResponse(
id = it[TraceabilityNodeLibraryTable.id].value.toString(),
category = it[TraceabilityNodeLibraryTable.category],
name = it[TraceabilityNodeLibraryTable.name],
description = it[TraceabilityNodeLibraryTable.description],
consumerVisible = it[TraceabilityNodeLibraryTable.consumerVisible],
fields = decodeFields(it[TraceabilityNodeLibraryTable.fieldsJson]),
updatedAt = formatTimestamp(it[TraceabilityNodeLibraryTable.updatedAt]),
)
}
}
private fun ensureDefaultNodeLibrarySeeded() {
if (TraceabilityNodeLibraryTable.selectAll().limit(1).any()) {
return
}
val now = timestampLiteral(nowInstant())
defaultNodeLibraryPresets().forEach { request ->
TraceabilityNodeLibraryTable.insertAndGetId {
it[category] = request.category
it[name] = request.name
it[description] = request.description
it[consumerVisible] = request.consumerVisible
it[fieldsJson] = json.encodeToString(request.fields)
it[createdAt] = now
it[updatedAt] = now
}
}
}
private fun defaultNodeLibraryPresets(): List<SaveTraceNodeLibraryRequest> {
return listOf(
SaveTraceNodeLibraryRequest(
category = "business",
name = "生产加工节点",
description = "记录原料、工艺、加工批次等业务过程信息。",
consumerVisible = true,
fields = listOf(
TraceFieldDefinitionRequest(key = "process_name", label = "工艺名称"),
TraceFieldDefinitionRequest(key = "operator", label = "负责人"),
TraceFieldDefinitionRequest(key = "production_date", label = "生产日期", type = "date"),
TraceFieldDefinitionRequest(key = "remark", label = "备注"),
),
),
SaveTraceNodeLibraryRequest(
category = "business",
name = "质检检验节点",
description = "记录质检结果、检验员和检验时间。",
consumerVisible = true,
fields = listOf(
TraceFieldDefinitionRequest(key = "inspector", label = "检验员"),
TraceFieldDefinitionRequest(key = "inspection_date", label = "检验日期", type = "date"),
TraceFieldDefinitionRequest(
key = "inspection_result",
label = "检验结果",
type = "select",
options = listOf("合格", "不合格", "复检中"),
),
TraceFieldDefinitionRequest(key = "inspection_note", label = "检验说明"),
),
),
SaveTraceNodeLibraryRequest(
category = "public",
name = "企业信息节点",
description = "面向消费者展示企业名称、产地和联系方式等信息。",
consumerVisible = true,
fields = listOf(
TraceFieldDefinitionRequest(key = "company_name", label = "企业名称"),
TraceFieldDefinitionRequest(key = "origin", label = "产地"),
TraceFieldDefinitionRequest(key = "contact_phone", label = "联系电话"),
TraceFieldDefinitionRequest(key = "company_intro", label = "企业简介"),
),
),
SaveTraceNodeLibraryRequest(
category = "public",
name = "资质证书节点",
description = "展示认证证书、证书编号和有效期。",
consumerVisible = true,
fields = listOf(
TraceFieldDefinitionRequest(key = "certificate_name", label = "证书名称"),
TraceFieldDefinitionRequest(key = "certificate_no", label = "证书编号"),
TraceFieldDefinitionRequest(key = "valid_until", label = "有效期", type = "date"),
TraceFieldDefinitionRequest(key = "certificate_image", label = "证书图片", type = "image"),
),
),
)
}
fun saveNodeLibrary(nodeId: UUID?, request: SaveTraceNodeLibraryRequest): TraceNodeLibraryResponse = transaction {
val now = timestampLiteral(nowInstant())
val currentId = nodeId ?: TraceabilityNodeLibraryTable.insertAndGetId {
it[category] = request.category
it[name] = request.name
it[description] = request.description
it[consumerVisible] = request.consumerVisible
it[fieldsJson] = json.encodeToString(request.fields)
it[createdAt] = now
it[updatedAt] = now
}.value
if (nodeId != null) {
TraceabilityNodeLibraryTable.update({ TraceabilityNodeLibraryTable.id eq currentId }) {
it[category] = request.category
it[name] = request.name
it[description] = request.description
it[consumerVisible] = request.consumerVisible
it[fieldsJson] = json.encodeToString(request.fields)
it[updatedAt] = now
}
}
listNodeLibrary().first { it.id == currentId.toString() }
}
fun deleteNodeLibrary(nodeId: UUID): Boolean = transaction {
TraceabilityNodeLibraryTable.deleteWhere { TraceabilityNodeLibraryTable.id eq nodeId } > 0
}
fun listTemplates(): List<TraceTemplateSummaryResponse> = transaction {
val batchCountByTemplate = TraceabilityBatchesTable.selectAll()
.groupBy { it[TraceabilityBatchesTable.templateId].value }
@@ -99,6 +486,7 @@ object TraceabilityDao {
productName = it[TraceabilityTemplatesTable.productName],
industryName = it[TraceabilityTemplatesTable.industryName],
coverImage = it[TraceabilityTemplatesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(it[TraceabilityTemplatesTable.coverImage]).orEmpty(),
themeColor = it[TraceabilityTemplatesTable.themeColor],
status = it[TraceabilityTemplatesTable.status],
nodeCount = nodeCountByTemplate[it[TraceabilityTemplatesTable.id].value] ?: 0,
@@ -120,6 +508,7 @@ object TraceabilityDao {
productName = templateRow[TraceabilityTemplatesTable.productName],
industryName = templateRow[TraceabilityTemplatesTable.industryName],
coverImage = templateRow[TraceabilityTemplatesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(templateRow[TraceabilityTemplatesTable.coverImage]).orEmpty(),
themeColor = templateRow[TraceabilityTemplatesTable.themeColor],
status = templateRow[TraceabilityTemplatesTable.status],
nodes = loadTemplateNodes(templateId),
@@ -154,11 +543,17 @@ object TraceabilityDao {
}
val existingNodeIds = TraceabilityTemplateNodesTable.selectAll()
.where { TraceabilityTemplateNodesTable.templateId eq currentId }
.map { it[TraceabilityTemplateNodesTable.id].value }
.associate {
it[TraceabilityTemplateNodesTable.id].value to it[TraceabilityTemplateNodesTable.fieldsJson]
}
if (existingNodeIds.isNotEmpty()) {
existingNodeIds.forEach { nodeId ->
TraceabilityBatchStepsTable.deleteWhere {
existingNodeIds.forEach { (nodeId, fieldsJson) ->
TraceabilityBatchStepsTable.update({
TraceabilityBatchStepsTable.templateNodeId eq nodeId
}) {
it[TraceabilityBatchStepsTable.fieldsJson] = fieldsJson
it[templateNodeId] = null
it[updatedAt] = now
}
}
}
@@ -221,6 +616,7 @@ object TraceabilityDao {
productName = it[TraceabilityBatchesTable.productName],
summary = it[TraceabilityBatchesTable.summary],
coverImage = it[TraceabilityBatchesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(it[TraceabilityBatchesTable.coverImage]).orEmpty(),
tags = decodeStringList(it[TraceabilityBatchesTable.tagsJson]),
status = it[TraceabilityBatchesTable.status],
currentStep = it[TraceabilityBatchesTable.currentStep],
@@ -249,6 +645,7 @@ object TraceabilityDao {
productName = batchRow[TraceabilityBatchesTable.productName],
summary = batchRow[TraceabilityBatchesTable.summary],
coverImage = batchRow[TraceabilityBatchesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(batchRow[TraceabilityBatchesTable.coverImage]).orEmpty(),
tags = decodeStringList(batchRow[TraceabilityBatchesTable.tagsJson]),
status = batchRow[TraceabilityBatchesTable.status],
currentStep = batchRow[TraceabilityBatchesTable.currentStep],
@@ -260,8 +657,8 @@ object TraceabilityDao {
)
}
fun createBatch(request: CreateTraceBatchRequest): TraceBatchDetailResponse = transaction {
val template = getTemplate(request.templateUuid()) ?: error("template not found")
fun createBatch(request: CreateTraceBatchRequest): TraceBatchDetailResponse? = transaction {
val template = getTemplate(request.templateUuid()) ?: return@transaction null
val now = timestampLiteral(nowInstant())
val batchId = TraceabilityBatchesTable.insertAndGetId {
it[this.templateId] = request.templateUuid()
@@ -269,7 +666,7 @@ object TraceabilityDao {
it[batchCode] = request.batchCode
it[productName] = request.productName
it[summary] = request.summary
it[coverImage] = request.coverImage
it[coverImage] = request.coverImage.ifBlank { template.coverImage }
it[tagsJson] = json.encodeToString(request.tags)
it[status] = "draft"
it[currentStep] = 0
@@ -287,6 +684,7 @@ object TraceabilityDao {
it[description] = node.description
it[locked] = node.locked
it[consumerVisible] = node.consumerVisible
it[fieldsJson] = json.encodeToString(node.fields)
it[status] = "pending"
it[operatorName] = ""
it[valuesJson] = buildDefaultValues(node.fields)
@@ -295,7 +693,7 @@ object TraceabilityDao {
}
}
getBatch(batchId)!!
getBatch(batchId)
}
fun updateBatchBase(batchId: UUID, request: UpdateTraceBatchBaseRequest): TraceBatchDetailResponse? = transaction {
@@ -357,11 +755,19 @@ object TraceabilityDao {
getBatch(batchId)
}
fun getPublicDetailByCode(batchCode: String, increaseScan: Boolean = false): TraceabilityPublicDetailResponse? = transaction {
fun getPublicDetailByCode(
batchCode: String,
increaseScan: Boolean = false,
onlyPublished: Boolean = true,
): TraceabilityPublicDetailResponse? = transaction {
val batchRow = TraceabilityBatchesTable.selectAll()
.where { TraceabilityBatchesTable.batchCode eq batchCode }
.singleOrNull() ?: return@transaction null
if (onlyPublished && batchRow[TraceabilityBatchesTable.status] != "published") {
return@transaction null
}
if (increaseScan) {
TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchRow[TraceabilityBatchesTable.id].value }) {
it[scanCount] = batchRow[TraceabilityBatchesTable.scanCount] + 1
@@ -377,6 +783,52 @@ object TraceabilityDao {
)
}
fun getPreviewPublicDetailByCode(previewCode: String): TraceabilityPublicDetailResponse? = transaction {
val pageRow = TraceabilityPreviewPagesTable.selectAll()
.where { TraceabilityPreviewPagesTable.previewCode eq previewCode }
.singleOrNull() ?: return@transaction null
val pageId = pageRow[TraceabilityPreviewPagesTable.id].value
val nodes = loadPreviewNodes(pageId)
val batchLike = TraceBatchDetailResponse(
id = pageId.toString(),
templateId = "",
templateName = "预演页",
batchName = pageRow[TraceabilityPreviewPagesTable.name],
batchCode = pageRow[TraceabilityPreviewPagesTable.previewCode],
productName = pageRow[TraceabilityPreviewPagesTable.productName],
summary = pageRow[TraceabilityPreviewPagesTable.description],
coverImage = pageRow[TraceabilityPreviewPagesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(pageRow[TraceabilityPreviewPagesTable.coverImage]).orEmpty(),
tags = decodeStringList(pageRow[TraceabilityPreviewPagesTable.tagsJson]),
status = "preview",
currentStep = 0,
scanCount = 0,
publicUrl = previewPublicUrl(pageRow[TraceabilityPreviewPagesTable.previewCode]),
steps = nodes.map {
TraceBatchStepResponse(
id = it.id,
sort = it.sort,
category = it.category,
name = it.name,
description = it.description,
consumerVisible = it.consumerVisible,
status = "preview",
operatorName = "",
values = it.values,
valuePreviewUrls = it.valuePreviewUrls,
fields = it.fields,
)
},
updatedAt = formatTimestamp(pageRow[TraceabilityPreviewPagesTable.updatedAt]),
publishedAt = formatTimestamp(pageRow[TraceabilityPreviewPagesTable.updatedAt]),
)
TraceabilityPublicDetailResponse(
batch = batchLike,
publicSections = batchLike.steps.filter { it.category == "public" && it.consumerVisible },
businessSections = batchLike.steps.filter { it.category != "public" && it.consumerVisible },
)
}
fun listFeedback(): List<TraceabilityFeedbackResponse> = transaction {
val batchMap = TraceabilityBatchesTable.selectAll()
.associateBy { it[TraceabilityBatchesTable.id].value }
@@ -400,14 +852,14 @@ object TraceabilityDao {
}
}
fun submitFeedback(request: SubmitTraceabilityFeedbackRequest): TraceabilityFeedbackResponse = transaction {
fun submitFeedback(request: SubmitTraceabilityFeedbackRequest): TraceabilityFeedbackResponse? = transaction {
val batchId = when {
!request.batchId.isNullOrBlank() -> UUID.fromString(request.batchId)
!request.batchId.isNullOrBlank() -> runCatching { UUID.fromString(request.batchId) }.getOrNull()
!request.batchCode.isNullOrBlank() -> TraceabilityBatchesTable.selectAll()
.where { TraceabilityBatchesTable.batchCode eq request.batchCode }
.single()[TraceabilityBatchesTable.id].value
else -> error("batch not found")
}
.singleOrNull()?.get(TraceabilityBatchesTable.id)?.value
else -> null
} ?: return@transaction null
val now = timestampLiteral(nowInstant())
val feedbackId = TraceabilityFeedbackTable.insertAndGetId {
@@ -420,7 +872,7 @@ object TraceabilityDao {
it[createdAt] = now
}.value
listFeedback().first { it.id == feedbackId.toString() }
listFeedback().firstOrNull { it.id == feedbackId.toString() }
}
private fun loadTemplateNodes(templateId: UUID): List<TraceTemplateNodeResponse> {
@@ -441,12 +893,34 @@ object TraceabilityDao {
}
}
private fun loadPreviewNodes(previewId: UUID): List<TracePreviewNodeResponse> {
return TraceabilityPreviewNodesTable.selectAll()
.where { TraceabilityPreviewNodesTable.previewPageId eq previewId }
.orderBy(TraceabilityPreviewNodesTable.sort, SortOrder.ASC)
.map { row ->
val fields = decodeFields(row[TraceabilityPreviewNodesTable.fieldsJson])
val values = decodeValues(row[TraceabilityPreviewNodesTable.valuesJson])
TracePreviewNodeResponse(
id = row[TraceabilityPreviewNodesTable.id].value.toString(),
sort = row[TraceabilityPreviewNodesTable.sort],
category = row[TraceabilityPreviewNodesTable.category],
name = row[TraceabilityPreviewNodesTable.name],
description = row[TraceabilityPreviewNodesTable.description],
consumerVisible = row[TraceabilityPreviewNodesTable.consumerVisible],
values = values,
valuePreviewUrls = buildValuePreviewUrls(fields, values),
fields = fields,
)
}
}
private fun loadBatchSteps(batchId: UUID): List<TraceBatchStepResponse> {
return TraceabilityBatchStepsTable.selectAll()
.where { TraceabilityBatchStepsTable.batchId eq batchId }
.orderBy(TraceabilityBatchStepsTable.sort, SortOrder.ASC)
.map { row ->
val fields = row[TraceabilityBatchStepsTable.templateNodeId]?.value?.let { nodeId ->
val snapshotFields = decodeFields(row[TraceabilityBatchStepsTable.fieldsJson])
val fields = snapshotFields.takeIf { it.isNotEmpty() } ?: row[TraceabilityBatchStepsTable.templateNodeId]?.value?.let { nodeId ->
TraceabilityTemplateNodesTable.selectAll()
.where { TraceabilityTemplateNodesTable.id eq nodeId }
.singleOrNull()
@@ -465,6 +939,10 @@ object TraceabilityDao {
status = row[TraceabilityBatchStepsTable.status],
operatorName = row[TraceabilityBatchStepsTable.operatorName],
values = decodeValues(row[TraceabilityBatchStepsTable.valuesJson]),
valuePreviewUrls = buildValuePreviewUrls(
fields,
decodeValues(row[TraceabilityBatchStepsTable.valuesJson]),
),
completedAt = formatTimestamp(row[TraceabilityBatchStepsTable.completedAt]),
fields = fields,
)
@@ -479,9 +957,15 @@ object TraceabilityDao {
type = it.type,
required = it.required,
visible = it.visible,
fixedPreset = it.fixedPreset,
placeholder = it.placeholder,
defaultValue = it.defaultValue,
defaultPreviewUrl = if (it.type == "image") resolveImagePreviewUrl(it.defaultValue) else null,
options = it.options,
fieldStyle = TraceFieldStyleResponse(
bold = it.fieldStyle.bold,
color = it.fieldStyle.color,
),
)
}
}
@@ -498,6 +982,81 @@ object TraceabilityDao {
emptyList()
}
private fun buildValuePreviewUrls(
fields: List<TraceFieldDefinitionResponse>,
values: JsonObject,
): Map<String, String> {
return fields.filter { it.type == "image" }
.mapNotNull { field ->
resolveImagePreviewUrl(values[field.key])?.let { field.key to it }
}
.toMap()
}
private fun resolveStoredImagePreviewUrl(raw: String?): String? {
val text = raw?.trim().orEmpty()
if (text.isBlank()) {
return null
}
return runCatching {
if (text.startsWith("{")) {
resolveImagePreviewUrl(json.parseToJsonElement(text))
} else {
resolveImagePreviewUrl(JsonPrimitive(text))
}
}.getOrNull()
}
private fun extractStoredImageReference(raw: String?): Pair<String, String> {
val text = raw?.trim().orEmpty()
if (text.isBlank()) {
return "" to ""
}
return runCatching {
if (text.startsWith("{")) {
val element = json.parseToJsonElement(text)
if (element is JsonObject) {
val bucketName = element["bucketName"]?.jsonPrimitive?.content?.trim().orEmpty()
val objectName = element["objectName"]?.jsonPrimitive?.content?.trim().orEmpty()
bucketName to objectName
} else {
"" to text
}
} else if (text.startsWith("http://") || text.startsWith("https://")) {
"" to text
} else {
OSSUtils.defaultBucket() to text
}
}.getOrDefault("" to text)
}
private fun resolveImagePreviewUrl(value: JsonElement?): String? = runCatching {
when (value) {
null, JsonNull -> null
is JsonPrimitive -> {
val raw = value.content.trim()
when {
raw.isBlank() -> null
raw.startsWith("http://") || raw.startsWith("https://") -> raw
else -> OSSUtils.getTempUrl(OSSUtils.defaultBucket(), raw)
}
}
is JsonObject -> {
val bucketName = value["bucketName"]?.jsonPrimitive?.content?.trim().orEmpty()
.ifBlank { OSSUtils.defaultBucket() }
val objectName = value["objectName"]?.jsonPrimitive?.content?.trim().orEmpty()
if (objectName.isBlank()) {
null
} else {
OSSUtils.getTempUrl(bucketName, objectName)
}
}
else -> null
}
}.getOrNull()
private fun buildDefaultValues(fields: List<TraceFieldDefinitionResponse>): String {
val values = buildJsonObject {
fields.forEach { field ->
@@ -511,6 +1070,15 @@ object TraceabilityDao {
value?.toString()?.replace('T', ' ')?.replace("Z", "") ?: ""
private fun publicUrl(code: String): String {
return "$publicPreviewBaseUrl/p/$code"
return "$publicPreviewBaseUrl/f10/$code"
}
private fun previewPublicUrl(code: String): String {
return "$publicPreviewBaseUrl/preview/$code"
}
private fun buildPreviewCode(): String {
return "PV-${Clock.System.now().epochSeconds.toString().takeLast(8)}-${UUID.randomUUID().toString().take(6)}"
.uppercase()
}
}
+4
View File
@@ -40,3 +40,7 @@ ktor:
fallback-bucket: "system"
fallback-object: "favicon.ico"
traceability:
# public-preview-base-url: "http://127.0.0.1:8081" # 开发测试用
public-preview-base-url: "https://ats.f10.bbitcn.com" # 生产环境用
+2
View File
@@ -42,6 +42,7 @@
"@vben/types": "workspace:*",
"@vben/utils": "workspace:*",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"ant-design-vue": "catalog:",
"axios": "catalog:",
"dayjs": "catalog:",
@@ -51,6 +52,7 @@
"markdown-it": "^14.1.0",
"markdown-it-table": "^4.1.1",
"pinia": "catalog:",
"qrcode": "catalog:",
"video.js": "^8.23.4",
"vue": "catalog:",
"vue-router": "catalog:"
@@ -1,6 +1,17 @@
import { requestClient } from '#/api/request';
export namespace TraceabilityApi {
export interface OssStoredValue {
bucketName: string;
objectName: string;
tempUrl?: string;
}
export interface FieldStyle {
bold?: boolean;
color?: string;
}
export interface Overview {
templateCount: number;
batchCount: number;
@@ -15,9 +26,12 @@ export namespace TraceabilityApi {
type: string;
required: boolean;
visible: boolean;
fixedPreset?: boolean;
placeholder?: string;
defaultValue?: any;
defaultPreviewUrl?: string;
options?: string[];
fieldStyle?: FieldStyle;
}
export interface TemplateNode {
@@ -31,6 +45,28 @@ export namespace TraceabilityApi {
fields: FieldDefinition[];
}
export interface PreviewNode {
id?: string;
sort?: number;
category: 'business' | 'public' | string;
name: string;
description: string;
consumerVisible: boolean;
values: Record<string, any>;
valuePreviewUrls?: Record<string, string>;
fields: FieldDefinition[];
}
export interface NodeLibraryItem {
id: string;
category: 'business' | 'public' | string;
name: string;
description: string;
consumerVisible: boolean;
fields: FieldDefinition[];
updatedAt: string;
}
export interface TemplateSummary {
id: string;
name: string;
@@ -38,6 +74,7 @@ export namespace TraceabilityApi {
productName: string;
industryName: string;
coverImage: string;
coverImagePreviewUrl?: string;
themeColor: string;
status: string;
nodeCount: number;
@@ -49,6 +86,24 @@ export namespace TraceabilityApi {
nodes: TemplateNode[];
}
export interface PreviewPageSummary {
id: string;
name: string;
previewCode: string;
description: string;
productName: string;
coverImage: string;
coverImagePreviewUrl?: string;
themeColor: string;
tags: string[];
publicUrl: string;
updatedAt: string;
}
export interface PreviewPageDetail extends PreviewPageSummary {
nodes: PreviewNode[];
}
export interface BatchStep {
id: string;
templateNodeId?: string;
@@ -61,6 +116,7 @@ export namespace TraceabilityApi {
status: string;
operatorName: string;
values: Record<string, any>;
valuePreviewUrls?: Record<string, string>;
completedAt: string;
fields: FieldDefinition[];
}
@@ -74,6 +130,7 @@ export namespace TraceabilityApi {
productName: string;
summary: string;
coverImage: string;
coverImagePreviewUrl?: string;
tags: string[];
status: string;
currentStep: number;
@@ -116,6 +173,18 @@ export namespace TraceabilityApi {
fileName?: string;
size?: number;
}
export interface FileAssetItem {
id: string;
assetType: string;
bucketName: string;
objectName: string;
fileName: string;
contentType: string;
size: number;
previewUrl: string;
createdAt: string;
}
}
export function getTraceabilityOverview() {
@@ -128,6 +197,68 @@ export function getTraceabilityTemplates() {
);
}
export function getTraceabilityPreviews() {
return requestClient.get<TraceabilityApi.PreviewPageSummary[]>(
'/traceability/previews',
);
}
export function getTraceabilityPreview(id: string) {
return requestClient.get<TraceabilityApi.PreviewPageDetail>(
`/traceability/previews/${id}`,
);
}
export function createTraceabilityPreview(
data: Omit<TraceabilityApi.PreviewPageDetail, 'id' | 'previewCode' | 'publicUrl' | 'updatedAt'>,
) {
return requestClient.post('/traceability/previews', data);
}
export function updateTraceabilityPreview(
id: string,
data: Omit<TraceabilityApi.PreviewPageDetail, 'id' | 'previewCode' | 'publicUrl' | 'updatedAt'>,
) {
return requestClient.post(`/traceability/previews/${id}`, data);
}
export function deleteTraceabilityPreview(id: string) {
return requestClient.delete(`/traceability/previews/${id}`);
}
export function syncTraceabilityPreviewToTemplate(id: string) {
return requestClient.post(`/traceability/previews/${id}/sync-template`);
}
export function getTraceabilityNodeLibrary() {
return requestClient.get<TraceabilityApi.NodeLibraryItem[]>(
'/traceability/node-library',
);
}
export function createTraceabilityNodeLibrary(
data: Omit<TraceabilityApi.NodeLibraryItem, 'id' | 'updatedAt'>,
) {
return requestClient.post<TraceabilityApi.NodeLibraryItem>(
'/traceability/node-library',
data,
);
}
export function updateTraceabilityNodeLibrary(
id: string,
data: Omit<TraceabilityApi.NodeLibraryItem, 'id' | 'updatedAt'>,
) {
return requestClient.post<TraceabilityApi.NodeLibraryItem>(
`/traceability/node-library/${id}`,
data,
);
}
export function deleteTraceabilityNodeLibrary(id: string) {
return requestClient.delete(`/traceability/node-library/${id}`);
}
export function getTraceabilityTemplate(id: string) {
return requestClient.get<TraceabilityApi.TemplateDetail>(
`/traceability/templates/${id}`,
@@ -144,7 +275,7 @@ export function updateTraceabilityTemplate(
id: string,
data: Omit<TraceabilityApi.TemplateDetail, 'batchCount' | 'id' | 'nodeCount' | 'updatedAt'>,
) {
return requestClient.put(`/traceability/templates/${id}`, data);
return requestClient.post(`/traceability/templates/${id}`, data);
}
export function deleteTraceabilityTemplate(id: string) {
@@ -180,7 +311,7 @@ export function deleteTraceabilityBatch(id: string) {
}
export function updateTraceabilityBatchBase(id: string, data: any) {
return requestClient.put(`/traceability/batches/${id}/base`, data);
return requestClient.post(`/traceability/batches/${id}/base`, data);
}
export function updateTraceabilityBatchStep(
@@ -193,7 +324,7 @@ export function updateTraceabilityBatchStep(
completedAt?: string;
},
) {
return requestClient.put(`/traceability/batches/${batchId}/steps/${stepId}`, data);
return requestClient.post(`/traceability/batches/${batchId}/steps/${stepId}`, data);
}
export function publishTraceabilityBatch(id: string) {
@@ -206,6 +337,12 @@ export function getTraceabilityPublicDetail(code: string) {
);
}
export function getTraceabilityPreviewDetail(code: string) {
return requestClient.get<TraceabilityApi.PublicDetail>(
`/traceability/public/preview/by-code/${code}`,
);
}
export function getTraceabilityFeedbackList() {
return requestClient.get<TraceabilityApi.FeedbackItem[]>(
'/traceability/feedback',
@@ -236,6 +373,17 @@ export function uploadTraceabilityImage(data: FormData) {
);
}
export function getTraceabilityFileAssets(assetType: string, limit = 24) {
return requestClient.get<TraceabilityApi.FileAssetItem[]>(
'/traceability/files/history',
{ params: { assetType, limit } },
);
}
export function deleteTraceabilityFileAsset(id: string) {
return requestClient.post<boolean>('/traceability/files/history/delete', { id });
}
export function getTraceabilityUploadToken(data: {
bucketName?: string;
objectName: string;
File diff suppressed because it is too large Load Diff
@@ -1,15 +1,25 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import { Page } from '@vben/common-ui';
import { Button, Card, Col, Empty, Input, message, Row, Tag } from 'ant-design-vue';
import { getTraceabilityBatches, getTraceabilityPublicDetail } from '#/api';
import type { TraceabilityApi } from '#/api';
import { formatFieldValue } from './shared';
import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import {
Button,
Card,
Col,
Empty,
Input,
message,
Row,
Tag,
} from 'ant-design-vue';
import { getTraceabilityBatches, getTraceabilityPreviewDetail } from '#/api';
import { formatFieldValue, getFieldDisplayStyle, getImagePreviewSrc } from './shared';
const loading = ref(false);
const batches = ref<TraceabilityApi.BatchSummary[]>([]);
@@ -38,7 +48,7 @@ async function search() {
}
loading.value = true;
try {
detail.value = await getTraceabilityPublicDetail(queryCode.value.trim());
detail.value = await getTraceabilityPreviewDetail(queryCode.value.trim());
} finally {
loading.value = false;
}
@@ -52,10 +62,7 @@ function getStatusLabel(status: string) {
return status || '进行中';
}
function getFieldLabel(
fields: TraceabilityApi.FieldDefinition[],
key: string,
) {
function getFieldLabel(fields: TraceabilityApi.FieldDefinition[], key: string) {
return fields.find((field) => field.key === key)?.label || key;
}
@@ -64,6 +71,7 @@ function getDisplayEntries(step: TraceabilityApi.BatchStep) {
key,
label: getFieldLabel(step.fields, key),
type: step.fields.find((field) => field.key === key)?.type || 'string',
field: step.fields.find((field) => field.key === key),
value,
}));
}
@@ -145,7 +153,12 @@ onMounted(loadBatches);
<span>消费者访问地址</span>
<strong>{{ publicLink }}</strong>
</div>
<p>{{ detail.batch.summary || '该批次已完成发布,可直接用于消费者扫码访问。' }}</p>
<p>
{{
detail.batch.summary ||
'该批次已完成发布,可直接用于消费者扫码访问。'
}}
</p>
</div>
<div class="access-meta">
<div class="access-card">
@@ -154,7 +167,9 @@ onMounted(loadBatches);
</div>
<div class="access-card">
<span>产品名称</span>
<strong>{{ detail.batch.productName || '未设置产品名称' }}</strong>
<strong>{{
detail.batch.productName || '未设置产品名称'
}}</strong>
</div>
<div class="access-card">
<span>所属模板</span>
@@ -162,7 +177,9 @@ onMounted(loadBatches);
</div>
<div class="access-card">
<span>标签</span>
<strong>{{ detail.batch.tags.join('、') || '暂无标签' }}</strong>
<strong>{{
detail.batch.tags.join('、') || '暂无标签'
}}</strong>
</div>
</div>
</div>
@@ -193,11 +210,13 @@ onMounted(loadBatches);
<span>{{ entry.label }}</span>
<img
v-if="entry.type === 'image' && entry.value"
:src="String(entry.value)"
:src="getImagePreviewSrc(entry.value, item.valuePreviewUrls?.[entry.key])"
:alt="entry.label"
class="consumer-image"
/>
<strong v-else>{{ formatFieldValue(entry.value) }}</strong>
<strong v-else :style="getFieldDisplayStyle(entry.field)">
{{ formatFieldValue(entry.value) }}
</strong>
</div>
</div>
</div>
@@ -237,11 +256,16 @@ onMounted(loadBatches);
<span>{{ entry.label }}</span>
<img
v-if="entry.type === 'image' && entry.value"
:src="String(entry.value)"
:src="getImagePreviewSrc(entry.value, item.valuePreviewUrls?.[entry.key])"
:alt="entry.label"
class="consumer-image"
/>
<strong v-else>{{ formatFieldValue(entry.value) }}</strong>
<strong
v-else
:style="getFieldDisplayStyle(entry.field)"
>
{{ formatFieldValue(entry.value) }}
</strong>
</div>
</div>
</div>
@@ -31,7 +31,14 @@ import {
} from '#/api';
import type { TraceabilityApi } from '#/api';
import { formatFieldValue, getFieldTypeLabel, normalizeFieldInput } from './shared';
import {
buildOssStoredValue,
formatFieldValue,
getFieldTypeLabel,
getImagePreviewSrc,
normalizeFieldInput,
stripOssTempUrl,
} from './shared';
const loading = ref(false);
const selectedBatchId = ref('');
@@ -176,6 +183,21 @@ function updateFieldValue(field: TraceabilityApi.FieldDefinition, value: any) {
currentStep.value.values[field.key] = normalizeFieldInput(field, value);
}
function buildPersistedStepValues(step: TraceabilityApi.BatchStep) {
return Object.fromEntries(
step.fields.map((field) => [
field.key,
field.type === 'image'
? stripOssTempUrl(step.values[field.key])
: normalizeFieldInput(field, step.values[field.key]),
]),
);
}
function isFieldValueLocked(field: TraceabilityApi.FieldDefinition) {
return !isCurrentEditableStep.value || !!isPublished.value || !!field.fixedPreset;
}
function sanitizeIntegerInput(value: string) {
const cleaned = value.replaceAll(/[^\d-]/g, '');
const hasLeadingMinus = cleaned.startsWith('-');
@@ -230,7 +252,7 @@ async function handleImageUpload(field: TraceabilityApi.FieldDefinition, event:
`traceability/${selectedBatchId.value}/${currentStep.value.id}/${field.key}`,
);
const result = await uploadTraceabilityImage(formData);
updateFieldValue(field, result.tempUrl || result.objectName);
updateFieldValue(field, buildOssStoredValue(result));
message.success('图片上传成功');
} catch {
message.error('图片上传失败');
@@ -241,9 +263,10 @@ async function handleImageUpload(field: TraceabilityApi.FieldDefinition, event:
}
function removeBatch(id: string) {
const target = batches.value.find((item) => item.id === id);
Modal.confirm({
title: '删除批次',
content: '删除后该批次的填报记录和发布信息都会一起清除,是否继续?',
content: `确认删除批次“${target?.batchName || '未命名批次'}”吗?删除后该批次的填报记录和发布信息都会一起清除`,
async onOk() {
await deleteTraceabilityBatch(id);
message.success('批次已删除');
@@ -271,7 +294,7 @@ async function saveStep() {
completedAt: new Date().toISOString(),
operatorName: currentStep.value.operatorName,
status: 'completed',
values: currentStep.value.values,
values: buildPersistedStepValues(currentStep.value),
},
);
applyBatch(detail);
@@ -439,13 +462,19 @@ onMounted(async () => {
>
<div class="field-entry">
<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>
</div>
<div class="field-head-tags">
<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'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
:value="currentStep.values[field.key]"
style="width: 100%"
@@ -453,7 +482,7 @@ onMounted(async () => {
/>
<Select
v-else-if="field.type === 'multi_select'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
:value="currentStep.values[field.key]"
mode="multiple"
@@ -462,7 +491,7 @@ onMounted(async () => {
/>
<Input
v-else-if="field.type === 'integer'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:placeholder="field.placeholder || '请输入整数'"
:value="String(currentStep.values[field.key] ?? '')"
style="width: 100%"
@@ -473,7 +502,7 @@ onMounted(async () => {
/>
<Input
v-else-if="field.type === 'decimal'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:placeholder="field.placeholder || '请输入小数'"
:value="String(currentStep.values[field.key] ?? '')"
style="width: 100%"
@@ -484,7 +513,7 @@ onMounted(async () => {
/>
<DatePicker
v-else-if="field.type === 'date'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:value="currentStep.values[field.key]"
style="width: 100%"
value-format="YYYY-MM-DD"
@@ -492,7 +521,7 @@ onMounted(async () => {
/>
<DatePicker
v-else-if="field.type === 'datetime'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:value="currentStep.values[field.key]"
format="YYYY-MM-DD HH:mm:ss"
show-time
@@ -502,14 +531,14 @@ onMounted(async () => {
/>
<Input
v-else-if="field.type === 'link'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:placeholder="field.placeholder || '请输入链接地址'"
:value="currentStep.values[field.key]"
@update:value="(value) => updateFieldValue(field, value)"
/>
<Input
v-else-if="field.type === 'string'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:placeholder="field.placeholder || '请输入内容'"
:value="currentStep.values[field.key]"
@update:value="(value) => updateFieldValue(field, value)"
@@ -525,7 +554,12 @@ onMounted(async () => {
class="image-preview-wrap"
>
<img
:src="String(currentStep.values[field.key])"
:src="
getImagePreviewSrc(
currentStep.values[field.key],
currentStep.valuePreviewUrls?.[field.key],
)
"
alt="节点图片"
class="image-preview"
/>
@@ -536,11 +570,11 @@ onMounted(async () => {
accept="image/*"
class="upload-input"
type="file"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
@change="(event) => handleImageUpload(field, event)"
/>
<Button
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:loading="uploadingFieldKey === getFieldUploadKey(field)"
size="small"
type="primary"
@@ -550,7 +584,7 @@ onMounted(async () => {
</Button>
<Button
v-if="currentStep.values[field.key]"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
size="small"
@click="clearImageValue(field)"
>
@@ -568,11 +602,14 @@ onMounted(async () => {
<Input.TextArea
v-else
:auto-size="{ minRows: 3, maxRows: 5 }"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:disabled="isFieldValueLocked(field)"
:placeholder="field.placeholder || '请输入内容'"
:value="currentStep.values[field.key]"
@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]) }}
</div>
@@ -650,11 +687,11 @@ onMounted(async () => {
}
.batch-panel-card {
height: 100%;
height: auto;
}
.batch-panel-card :deep(.ant-card-body) {
height: calc(100% - 57px);
height: auto;
}
.batch-list {
@@ -716,12 +753,28 @@ onMounted(async () => {
.step-strip {
margin-bottom: 20px;
overflow: auto;
overflow: visible;
}
.step-strip :deep(.ant-steps) {
display: flex;
flex-wrap: wrap;
row-gap: 12px;
}
.step-strip :deep(.ant-steps-item) {
flex: 1 1 220px;
min-width: 220px;
}
.step-strip :deep(.ant-steps-item-container) {
padding-right: 12px;
}
.step-editor {
border-top: 1px solid #f0f2f5;
padding-top: 20px;
background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
}
.step-strip--published,
@@ -752,7 +805,7 @@ onMounted(async () => {
}
.dynamic-fields {
margin-top: 8px;
margin-top: 12px;
}
.publish-panel {
@@ -794,20 +847,48 @@ onMounted(async () => {
.field-entry {
display: grid;
gap: 8px;
gap: 12px;
min-height: 100%;
border: 1px solid #edf1f7;
border-radius: 18px;
background: linear-gradient(180deg, #ffffff, #fafcff);
padding: 14px;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.8) inset;
}
.field-entry__head {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
gap: 12px;
}
.field-head-tags {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.field-entry__title {
display: grid;
gap: 4px;
}
.field-entry__title small {
color: #8b96a8;
font-size: 12px;
line-height: 1.5;
}
.field-entry__body {
min-height: 120px;
display: grid;
gap: 10px;
border: 1px solid #edf1f7;
border-radius: 14px;
padding: 14px;
background: #fff;
}
.field-entry__body strong {
@@ -832,6 +913,16 @@ onMounted(async () => {
margin-bottom: 8px;
}
@media (max-width: 640px) {
.step-strip :deep(.ant-steps-item) {
min-width: 100%;
}
.field-entry__head {
flex-direction: column;
}
}
.upload-trigger {
display: inline-flex;
margin-top: 4px;
@@ -858,6 +949,17 @@ onMounted(async () => {
.field-preview {
color: #7d8899;
font-size: 12px;
border-top: 1px dashed #e4e9f2;
padding-top: 10px;
}
.field-fixed-tip {
margin-top: 8px;
color: #b45309;
font-size: 12px;
padding: 8px 10px;
border-radius: 10px;
background: #fff8eb;
}
.placeholder-uploader p {
@@ -0,0 +1,572 @@
<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 {
createTraceabilityPreview,
deleteTraceabilityFileAsset,
deleteTraceabilityPreview,
getTraceabilityFileAssets,
getTraceabilityPreview,
getTraceabilityPreviews,
syncTraceabilityPreviewToTemplate,
updateTraceabilityPreview,
uploadTraceabilityImage,
} from '#/api';
import type { TraceabilityApi } from '#/api';
import { buildOssStoredValue, clonePreviewForSave, createEmptyField, createEmptyPreviewNode, getFieldTypeLabel, getImagePreviewSrc, normalizeFieldInput } from './shared';
const previews = ref<TraceabilityApi.PreviewPageSummary[]>([]);
const selectedPreviewId = ref('');
const loading = ref(false);
const saving = ref(false);
const syncing = ref(false);
const uploadingFieldKey = ref('');
const coverHistoryVisible = ref(false);
const coverHistoryLoading = ref(false);
const deletingCoverAssetId = ref('');
const coverHistoryItems = ref<TraceabilityApi.FileAssetItem[]>([]);
const activeEditorTab = ref<'base' | 'nodes'>('base');
const activeNodeTab = ref<'business' | 'public'>('business');
const selectedNodeId = ref('');
const selectedFieldKey = ref('');
const draggingNodeId = ref('');
const editor = reactive<Partial<TraceabilityApi.PreviewPageDetail> & { coverImage: any; coverImagePreviewUrl?: string }>({
id: '', name: '', previewCode: '', description: '', 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' },
];
function nodesByCategory(category: 'business' | 'public') {
return (editor.nodes ?? []).filter((item) => item.category === category);
}
const currentNodeList = computed(() => nodesByCategory(activeNodeTab.value));
const currentNode = computed(() => currentNodeList.value.find((item) => item.id === selectedNodeId.value) ?? currentNodeList.value[0] ?? null);
const currentField = computed(() => currentNode.value?.fields.find((field) => field.key === selectedFieldKey.value) ?? currentNode.value?.fields[0] ?? null);
function syncSelectedFieldFromNode(node: TraceabilityApi.PreviewNode | null) {
selectedFieldKey.value = node?.fields[0]?.key ?? '';
}
function resetEditor() {
Object.assign(editor, {
id: '', name: '', previewCode: '', description: '', productName: '', coverImage: '', coverImagePreviewUrl: '',
themeColor: '#1f4fd6', tags: [], publicUrl: '', updatedAt: '', nodes: [createEmptyPreviewNode('public'), createEmptyPreviewNode('business')],
});
}
function applyPreview(detail: TraceabilityApi.PreviewPageDetail) {
Object.assign(editor, structuredClone(detail));
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);
}
async function loadPreviews() {
loading.value = true;
try {
previews.value = await getTraceabilityPreviews();
if (!selectedPreviewId.value && previews.value[0]) await selectPreview(previews.value[0].id);
else if (!selectedPreviewId.value) resetEditor();
} finally { loading.value = false; }
}
async function selectPreview(id: string) {
selectedPreviewId.value = id;
applyPreview(await getTraceabilityPreview(id));
}
async function createPreview() {
saving.value = true;
try {
const created = await createTraceabilityPreview({
name: '新建预演页', description: '', productName: '', coverImage: '', themeColor: '#1f4fd6', tags: [],
nodes: [createEmptyPreviewNode('public'), createEmptyPreviewNode('business')],
});
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;
try {
applyPreview(await updateTraceabilityPreview(editor.id, clonePreviewForSave(editor as TraceabilityApi.PreviewPageDetail)));
await loadPreviews();
message.success('预演页已保存');
} finally { saving.value = false; }
}
async function removePreview(id: string) {
const target = previews.value.find((item) => item.id === id);
Modal.confirm({
title: '删除预演页',
content: `确认删除预演页“${target?.name || '未命名预演页'}”吗?`,
async onOk() {
await deleteTraceabilityPreview(id);
if (selectedPreviewId.value === id) selectedPreviewId.value = '';
await loadPreviews();
message.success('预演页已删除');
},
});
}
async function syncToTemplate() {
if (!editor.id) return;
syncing.value = true;
try {
await savePreview();
await syncTraceabilityPreviewToTemplate(editor.id);
message.success('已同步为新模板');
} finally { syncing.value = false; }
}
function addNode(category: 'business' | 'public') {
editor.nodes ??= [];
const node = createEmptyPreviewNode(category);
editor.nodes.push(node);
activeEditorTab.value = 'nodes';
activeNodeTab.value = category;
selectedNodeId.value = node.id ?? '';
syncSelectedFieldFromNode(node);
}
function removeNode(target: TraceabilityApi.PreviewNode) {
editor.nodes = (editor.nodes ?? []).filter((node) => node !== target);
if (selectedNodeId.value === target.id) {
const nextNode = nodesByCategory(activeNodeTab.value)[0] ?? null;
selectedNodeId.value = nextNode?.id ?? '';
syncSelectedFieldFromNode(nextNode);
}
}
function confirmRemovePreviewNode(target: TraceabilityApi.PreviewNode) {
Modal.confirm({
title: '删除节点',
content: `确认删除节点“${target.name || '未命名节点'}”吗?`,
async onOk() {
removeNode(target);
},
});
}
function movePreviewNode(
category: 'business' | 'public',
draggedId: string,
targetId: string,
) {
const nodes = editor.nodes ?? [];
if (!draggedId || !targetId || draggedId === targetId) return;
const categoryNodes = nodes.filter((node) => node.category === category);
const fromIndex = categoryNodes.findIndex((node) => node.id === draggedId);
const toIndex = categoryNodes.findIndex((node) => node.id === targetId);
if (fromIndex === -1 || toIndex === -1) return;
const movedNodes = [...categoryNodes];
const [movedNode] = movedNodes.splice(fromIndex, 1);
movedNodes.splice(toIndex, 0, movedNode);
const reordered = nodes.slice();
let cursor = 0;
editor.nodes = reordered.map((node) =>
node.category === category ? movedNodes[cursor++] : node,
);
selectedNodeId.value = draggedId;
}
function handleNodeDragStart(nodeId?: string) {
draggingNodeId.value = nodeId ?? '';
}
function handleNodeDrop(targetNodeId?: string) {
movePreviewNode(
activeNodeTab.value,
draggingNodeId.value,
targetNodeId ?? '',
);
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 confirmRemovePreviewField(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) {
Modal.confirm({
title: '删除字段',
content: `确认删除字段“${field.label || field.key || '未命名字段'}”吗?`,
async onOk() {
removeField(node, field);
},
});
}
function updateFieldValue(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition, value: any) { node.values[field.key] = normalizeFieldInput(field, value); }
function updateFieldKey(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition, nextKey: string) {
const previousKey = field.key;
const finalKey = nextKey.trim() || previousKey;
if (finalKey === previousKey) return;
node.values[finalKey] = node.values[previousKey];
delete node.values[previousKey];
if (node.valuePreviewUrls?.[previousKey]) {
node.valuePreviewUrls[finalKey] = node.valuePreviewUrls[previousKey];
delete node.valuePreviewUrls[previousKey];
}
field.key = finalKey;
if (selectedFieldKey.value === previousKey) {
selectedFieldKey.value = finalKey;
}
}
function getFieldUploadKey(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) { return `${node.id ?? 'node'}:${field.key}`; }
function getFieldInputId(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) { return `traceability-preview-upload-${getFieldUploadKey(node, field)}`; }
function triggerImageSelect(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) { document.getElementById(getFieldInputId(node, field))?.click(); }
function clearImageValue(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition) { updateFieldValue(node, field, ''); }
function sanitizeIntegerInput(value: string) { const c = value.replaceAll(/[^\d-]/g, ''); const m = c.startsWith('-'); const u = m ? c.slice(1).replaceAll('-', '') : c.replaceAll('-', ''); return m ? `-${u}` : u; }
function sanitizeDecimalInput(value: string) { const c = value.replaceAll(/[^\d.-]/g, ''); const i = c.indexOf('.'); const d = i === -1 ? c : `${c.slice(0, i + 1)}${c.slice(i + 1).replaceAll('.', '')}`; const m = d.indexOf('-'); return m <= 0 ? d : `-${d.replaceAll('-', '')}`; }
async function handleImageUpload(node: TraceabilityApi.PreviewNode, field: TraceabilityApi.FieldDefinition, event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return;
uploadingFieldKey.value = getFieldUploadKey(node, field);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('objectDir', `traceability/preview/${editor.id || 'draft'}/${node.id}/${field.key}`);
const result = await uploadTraceabilityImage(formData);
updateFieldValue(node, field, buildOssStoredValue(result));
message.success('图片上传成功');
} finally { uploadingFieldKey.value = ''; (event.target as HTMLInputElement).value = ''; }
}
async function loadCoverHistory() { coverHistoryLoading.value = true; try { coverHistoryItems.value = await getTraceabilityFileAssets('cover', 30); } finally { coverHistoryLoading.value = false; } }
async function openCoverHistoryModal() { coverHistoryVisible.value = true; await loadCoverHistory(); }
function triggerTemplateCoverSelect() { document.getElementById('preview-cover-upload')?.click(); }
function clearTemplateCoverImage() { editor.coverImage = ''; editor.coverImagePreviewUrl = ''; }
function selectHistoryCover(item: TraceabilityApi.FileAssetItem) { editor.coverImage = item.bucketName ? { bucketName: item.bucketName, objectName: item.objectName } : String(item.objectName); editor.coverImagePreviewUrl = item.previewUrl; coverHistoryVisible.value = false; }
async function deleteHistoryCover(item: TraceabilityApi.FileAssetItem) {
if (item.id.startsWith('legacy-')) return message.warning('历史回溯封面图暂不支持直接删除');
Modal.confirm({
title: '删除历史封面图',
content: `确认删除历史封面图“${item.fileName || item.objectName}”吗?`,
async onOk() {
deletingCoverAssetId.value = item.id;
try { await deleteTraceabilityFileAsset(item.id); coverHistoryItems.value = coverHistoryItems.value.filter((asset) => asset.id !== item.id); message.success('历史封面图已删除'); }
finally { deletingCoverAssetId.value = ''; }
},
});
}
async function handleTemplateCoverUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return;
uploadingFieldKey.value = 'preview-cover';
try {
const formData = new FormData();
formData.append('file', file);
formData.append('assetType', 'cover');
formData.append('objectDir', 'traceability/covers/preview');
const result = await uploadTraceabilityImage(formData);
editor.coverImage = buildOssStoredValue(result);
editor.coverImagePreviewUrl = result.tempUrl || '';
await loadCoverHistory();
message.success('封面图上传成功');
} finally { uploadingFieldKey.value = ''; (event.target as HTMLInputElement).value = ''; }
}
onMounted(loadPreviews);
</script>
<template>
<Page auto-content-height>
<div class="trace-preview-page">
<div class="preview-layout">
<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>
</button>
</div>
<Empty v-else description="暂无预演页" />
</Card>
<div class="preview-main">
<Tabs v-model:active-key="activeEditorTab" class="panel-card preview-tabs">
<template #rightExtra>
<Button type="primary" :loading="saving" @click="savePreview">保存预演页</Button>
</template>
<Tabs.TabPane key="base" tab="基础信息">
<Card class="panel-card compact-card" :bordered="false">
<div class="section-heading">
<div>
<strong>基础信息</strong>
<p>维护预演页名称封面图主题色和标签</p>
</div>
</div>
<Row :gutter="[16, 16]">
<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 :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">
<label class="field-label">封面图</label>
<div class="cover-selector">
<div v-if="getImagePreviewSrc(editor.coverImage, editor.coverImagePreviewUrl)" class="cover-selector__preview"><img :src="getImagePreviewSrc(editor.coverImage, editor.coverImagePreviewUrl)" alt="预演页封面图"></div>
<div class="cover-selector__actions">
<input id="preview-cover-upload" hidden type="file" accept="image/*" @change="handleTemplateCoverUpload">
<Button :loading="uploadingFieldKey === 'preview-cover'" @click="triggerTemplateCoverSelect">选择本地文件</Button>
<Button @click="openCoverHistoryModal">选择历史文件</Button>
<Button v-if="getImagePreviewSrc(editor.coverImage, editor.coverImagePreviewUrl)" danger @click="clearTemplateCoverImage">清空</Button>
</div>
</div>
</Col>
</Row>
</Card>
<Card class="panel-card compact-card" :bordered="false">
<div class="section-heading">
<div>
<strong>预览与同步</strong>
<p>保存后可生成固定预演链接与二维码确认后再同步为模板</p>
</div>
</div>
<div class="preview-link-card__content">
<div class="preview-link-card__meta">
<div class="meta-line"><span>预演地址</span><strong>{{ editor.publicUrl || '保存后生成' }}</strong></div>
<div class="meta-line"><span>预演编码</span><strong>{{ editor.previewCode || '保存后生成' }}</strong></div>
<div class="meta-actions">
<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>
</Card>
</Tabs.TabPane>
<Tabs.TabPane key="nodes" tab="节点编排">
<Tabs v-model:active-key="activeNodeTab" class="preview-node-tabs">
<Tabs.TabPane key="business" tab="业务流程节点" />
<Tabs.TabPane key="public" tab="公开资料节点" />
</Tabs>
<Card class="panel-card compact-card" :bordered="false">
<div class="section-heading section-heading--tight">
<div>
<strong>节点编排</strong>
<p>顶部切换节点右侧保存当前预演页下面只编辑当前节点内容</p>
</div>
</div>
<div class="node-toolbar">
<div class="node-lane">
<div class="node-lane__title">
<span>{{ activeNodeTab === 'business' ? '业务流程节点' : '公开资料节点' }}</span>
<Button @click="addNode(activeNodeTab)">新增{{ activeNodeTab === 'business' ? '业务流程' : '公开资料' }}节点</Button>
</div>
<div class="node-strip">
<button
v-for="node in currentNodeList"
:key="node.id"
class="node-pill"
:class="{ active: node.id === currentNode?.id }"
draggable="true"
type="button"
@dragstart="handleNodeDragStart(node.id)"
@dragover.prevent
@drop.prevent="handleNodeDrop(node.id)"
@dragend="clearNodeDragState"
@click="selectedNodeId = node.id || ''; syncSelectedFieldFromNode(node)"
>
<span>{{ activeNodeTab === 'business' ? '业务流程' : '公开资料' }}</span>
<strong>{{ node.name || '未命名节点' }}</strong>
<small>{{ node.fields.length }} 个字段</small>
<Button
class="node-pill__remove"
danger
size="small"
type="text"
@click.stop="confirmRemovePreviewNode(node)"
>
删除节点
</Button>
</button>
<div v-if="!currentNodeList.length" class="lane-empty">
{{ activeNodeTab === 'business' ? '还没有业务流程节点' : '还没有公开资料节点' }}
</div>
</div>
</div>
</div>
<template v-if="currentNode">
<div class="node-editor">
<div class="node-editor__summary">
<div>
<strong>{{ currentNode.name || (activeNodeTab === 'business' ? '未命名业务节点' : '未命名公开资料节点') }}</strong>
<p>{{ activeNodeTab === 'business' ? '业务流程节点' : '公开资料节点' }} · {{ currentNode.fields.length }} 个字段</p>
</div>
<Tag color="blue">{{ currentNode.consumerVisible ? '消费者可见' : '仅内部可见' }}</Tag>
</div>
<Row :gutter="[12, 12]">
<Col :md="12" :xs="24"><label class="field-label">节点名称</label><Input v-model:value="currentNode.name" /></Col>
<Col :md="12" :xs="24"><label class="field-label">消费者可见</label><div class="switch-line"><Switch v-model:checked="currentNode.consumerVisible" /></div></Col>
<Col :span="24"><label class="field-label">节点说明</label><Input.TextArea v-model:value="currentNode.description" :auto-size="{ minRows: 2, maxRows: 4 }" /></Col>
</Row>
<div class="field-editor">
<div class="field-editor__header">
<strong>字段设计</strong>
<Button @click="addField(currentNode)">新增字段</Button>
</div>
<div class="field-strip">
<button
v-for="field in currentNode.fields"
:key="field.key"
class="field-pill"
:class="{ active: field.key === currentField?.key }"
type="button"
@click="selectedFieldKey = field.key"
>
<span>{{ getFieldTypeLabel(field.type) }}</span>
<strong>{{ field.label || '新字段' }}</strong>
<small>{{ field.key }}</small>
<Button class="field-pill__remove" danger size="small" type="text" @click.stop="confirmRemovePreviewField(currentNode, field)">删除字段</Button>
</button>
<div v-if="!currentNode.fields.length" class="lane-empty">还没有字段请先新增字段</div>
</div>
</div>
<div v-if="currentField" class="field-card">
<div class="field-card__head">
<div class="field-card__title">
<strong>{{ currentField.label || '新字段' }}</strong>
<Tag>{{ getFieldTypeLabel(currentField.type) }}</Tag>
</div>
<Button danger size="small" @click="confirmRemovePreviewField(currentNode, currentField)">删除字段</Button>
</div>
<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="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>
<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>
<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>
</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)" />
</Col>
</Row>
</div>
</div>
</template>
<Empty v-else :description="`暂无${activeNodeTab === 'business' ? '业务流程' : '公开资料'}节点`" />
</Card>
</Tabs.TabPane>
</Tabs>
</div>
</div>
</div>
<Modal v-model:open="coverHistoryVisible" title="选择历史封面图" :footer="null">
<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__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>
</div>
</div>
</div>
<Empty v-else :description="coverHistoryLoading ? '正在加载历史封面图…' : '暂无历史封面图'" />
</Modal>
</Page>
</template>
<style scoped>
.trace-preview-page { padding: 4px; }
.preview-layout { display: grid; grid-template-columns: 320px minmax(0, 1fr); gap: 16px; }
.panel-card { border-radius: 18px; }
.compact-card :deep(.ant-card-body) { padding: 16px; }
.preview-sidebar { align-self: start; position: sticky; top: 12px; }
.preview-list { display: grid; gap: 10px; margin-top: 12px; }
.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-main { display: grid; gap: 16px; }
.preview-tabs :deep(.ant-tabs-nav),
.preview-node-tabs :deep(.ant-tabs-nav) { margin-bottom: 12px; }
.preview-tabs :deep(.ant-tabs-content-holder),
.preview-node-tabs :deep(.ant-tabs-content-holder) { background: transparent; }
.preview-tabs :deep(.ant-tabs-tabpane) { display: grid; gap: 16px; }
.preview-tabs :deep(.ant-tabs-nav),
.preview-node-tabs :deep(.ant-tabs-nav) { padding: 0 4px; }
.preview-tabs :deep(.ant-tabs-tab),
.preview-node-tabs :deep(.ant-tabs-tab) { padding: 10px 14px; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-heading strong { color: #111827; font-size: 16px; }
.section-heading p { margin: 6px 0 0; color: #7d8899; font-size: 12px; line-height: 1.6; }
.section-heading--tight { margin-bottom: 12px; }
.preview-link-card__content { display: grid; grid-template-columns: minmax(0, 1fr) 240px; gap: 20px; align-items: center; }
.preview-link-card__meta,.meta-line,.cover-selector,.image-uploader,.cover-history-card,.cover-history-card__meta { display: grid; gap: 12px; }
.meta-line span,.field-label { color: #7d8899; font-size: 12px; }
.meta-line strong { word-break: break-word; }
.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 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; }
.node-editor__summary strong { color: #111827; font-size: 16px; }
.node-editor__summary p { margin: 6px 0 0; color: #7d8899; font-size: 12px; }
.node-strip { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; }
.node-pill { position: relative; min-width: 210px; max-width: 240px; border: 1px solid #edf1f7; background: #fff; border-radius: 16px; padding: 14px; text-align: left; transition: all 0.2s ease; cursor: grab; }
.node-pill:hover { border-color: #cfdaf0; transform: translateY(-1px); }
.node-pill.active { border-color: #adc4ff; background: #f5f8ff; }
.node-pill span { color: #1d4ed8; font-size: 12px; }
.node-pill strong,.node-pill small { display: block; }
.node-pill small { margin-top: 6px; color: #8b96a8; }
.node-pill__remove { margin-top: 10px; padding-left: 0; font-size: 12px; }
.node-lane__title { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: #1f2937; font-size: 14px; font-weight: 700; }
.lane-empty { min-width: 200px; border: 1px dashed #dbe3f0; border-radius: 14px; padding: 18px; color: #8b96a8; background: #fafcff; }
.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.active { border-color: #adc4ff; background: #f5f8ff; }
.field-pill:hover { border-color: #cfdaf0; transform: translateY(-1px); }
.field-pill span { color: #1d4ed8; font-size: 12px; }
.field-pill strong,.field-pill small { display: block; }
.field-pill small { margin-top: 6px; color: #8b96a8; }
.field-pill__remove { margin-top: 10px; padding-left: 0; font-size: 12px; }
.field-card { padding: 14px; border: 1px solid #e8edf6; border-radius: 16px; background: #fafcff; }
.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-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; }
.cover-history-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.cover-history-card { padding: 10px; border: 1px solid #e7edf7; border-radius: 16px; background: #fff; }
.cover-history-card img { display: block; width: 100%; height: 140px; object-fit: cover; border-radius: 12px; }
.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; }
@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; }
.preview-sidebar { position: static; }
.node-toolbar { align-items: flex-start; }
.node-editor__summary { flex-direction: column; }
.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; }
}
</style>
@@ -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<TraceabilityApi.PreviewPageDetail>,
) {
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 {
+9 -3
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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"