修复溯源模块大量问题
This commit is contained in:
@@ -8,7 +8,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "com.bbitcn"
|
group = "com.bbitcn"
|
||||||
version = "0.0.1"
|
version = "0.0.3"
|
||||||
|
|
||||||
application {
|
application {
|
||||||
mainClass = "io.ktor.server.netty.EngineMain"
|
mainClass = "io.ktor.server.netty.EngineMain"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ fun Application.configureRouting() {
|
|||||||
call.respond(mapOf("status" to "ok"))
|
call.respond(mapOf("status" to "ok"))
|
||||||
}
|
}
|
||||||
|
|
||||||
get("/p/{code}") {
|
get("/f10/{code}") {
|
||||||
val code = call.parameters["code"]?.trim().orEmpty()
|
val code = call.parameters["code"]?.trim().orEmpty()
|
||||||
if (code.isBlank()) {
|
if (code.isBlank()) {
|
||||||
call.respondText("批次编码不能为空", status = HttpStatusCode.BadRequest)
|
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") {
|
post("/feedback") {
|
||||||
val params = call.receiveParameters()
|
val params = call.receiveParameters()
|
||||||
val code = params["batchCode"]?.trim().orEmpty()
|
val code = params["batchCode"]?.trim().orEmpty()
|
||||||
@@ -92,7 +122,7 @@ fun Application.configureRouting() {
|
|||||||
rating = params["rating"]?.toIntOrNull() ?: 5,
|
rating = params["rating"]?.toIntOrNull() ?: 5,
|
||||||
)
|
)
|
||||||
val result = if (response.status) "success" else "failed"
|
val result = if (response.status) "success" else "failed"
|
||||||
call.respondRedirect("/p/$code?result=$result")
|
call.respondRedirect("/f10/$code?result=$result")
|
||||||
}
|
}
|
||||||
|
|
||||||
staticResources("/static", "static")
|
staticResources("/static", "static")
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ class TraceabilityClient(
|
|||||||
return payload.data
|
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> {
|
suspend fun submitFeedback(request: SubmitTraceabilityFeedbackRequest): ApiResponse<TraceabilityFeedbackResponse> {
|
||||||
val response = client.post {
|
val response = client.post {
|
||||||
url("$coreBaseUrl/traceability/public/feedback")
|
url("$coreBaseUrl/traceability/public/feedback")
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ data class ApiResponse<T>(
|
|||||||
val data: T? = null,
|
val data: T? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TraceFieldStyleResponse(
|
||||||
|
val bold: Boolean = false,
|
||||||
|
val color: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TraceFieldDefinitionResponse(
|
data class TraceFieldDefinitionResponse(
|
||||||
val key: String,
|
val key: String,
|
||||||
@@ -20,7 +26,9 @@ data class TraceFieldDefinitionResponse(
|
|||||||
val visible: Boolean = true,
|
val visible: Boolean = true,
|
||||||
val placeholder: String = "",
|
val placeholder: String = "",
|
||||||
val defaultValue: JsonElement? = null,
|
val defaultValue: JsonElement? = null,
|
||||||
|
val defaultPreviewUrl: String? = null,
|
||||||
val options: List<String> = emptyList(),
|
val options: List<String> = emptyList(),
|
||||||
|
val fieldStyle: TraceFieldStyleResponse = TraceFieldStyleResponse(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -35,6 +43,7 @@ data class TraceBatchStepResponse(
|
|||||||
val status: String,
|
val status: String,
|
||||||
val operatorName: String,
|
val operatorName: String,
|
||||||
val values: JsonObject,
|
val values: JsonObject,
|
||||||
|
val valuePreviewUrls: Map<String, String> = emptyMap(),
|
||||||
val completedAt: String = "",
|
val completedAt: String = "",
|
||||||
val fields: List<TraceFieldDefinitionResponse> = emptyList(),
|
val fields: List<TraceFieldDefinitionResponse> = emptyList(),
|
||||||
)
|
)
|
||||||
@@ -49,6 +58,7 @@ data class TraceBatchDetailResponse(
|
|||||||
val productName: String,
|
val productName: String,
|
||||||
val summary: String,
|
val summary: String,
|
||||||
val coverImage: String,
|
val coverImage: String,
|
||||||
|
val coverImagePreviewUrl: String = "",
|
||||||
val tags: List<String>,
|
val tags: List<String>,
|
||||||
val status: String,
|
val status: String,
|
||||||
val currentStep: Int,
|
val currentStep: Int,
|
||||||
@@ -96,6 +106,8 @@ data class DisplayEntry(
|
|||||||
val label: String,
|
val label: String,
|
||||||
val value: String,
|
val value: String,
|
||||||
val type: String = "string",
|
val type: String = "string",
|
||||||
|
val bold: Boolean = false,
|
||||||
|
val color: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PublicSectionView(
|
data class PublicSectionView(
|
||||||
@@ -116,7 +128,6 @@ data class TimelineSectionView(
|
|||||||
|
|
||||||
data class PageViewModel(
|
data class PageViewModel(
|
||||||
val code: String,
|
val code: String,
|
||||||
val pageUrl: String,
|
|
||||||
val batchName: String,
|
val batchName: String,
|
||||||
val productName: String,
|
val productName: String,
|
||||||
val templateName: String,
|
val templateName: String,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.bbitcn
|
package com.bbitcn
|
||||||
|
|
||||||
import io.ktor.server.config.ApplicationConfig
|
import io.ktor.server.config.ApplicationConfig
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
@@ -7,7 +7,6 @@ import kotlinx.serialization.json.JsonObject
|
|||||||
|
|
||||||
data class TraceabilityPublicConfig(
|
data class TraceabilityPublicConfig(
|
||||||
val coreBaseUrl: String,
|
val coreBaseUrl: String,
|
||||||
val publicBaseUrl: String,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class TraceabilityService(
|
class TraceabilityService(
|
||||||
@@ -20,12 +19,11 @@ class TraceabilityService(
|
|||||||
|
|
||||||
return PageViewModel(
|
return PageViewModel(
|
||||||
code = batch.batchCode,
|
code = batch.batchCode,
|
||||||
pageUrl = "${config.publicBaseUrl.trimEnd('/')}/p/${batch.batchCode}",
|
|
||||||
batchName = batch.batchName,
|
batchName = batch.batchName,
|
||||||
productName = batch.productName.ifBlank { batch.templateName },
|
productName = batch.productName.ifBlank { batch.templateName },
|
||||||
templateName = batch.templateName,
|
templateName = batch.templateName,
|
||||||
summary = batch.summary.ifBlank { "该批次已完成关键环节留痕,可查看公开资料与业务流程。" },
|
summary = batch.summary.ifBlank { "该批次已完成关键环节留痕,可查看公开资料与业务流程。" },
|
||||||
coverImage = batch.coverImage,
|
coverImage = batch.coverImagePreviewUrl.ifBlank { batch.coverImage },
|
||||||
scanCount = batch.scanCount,
|
scanCount = batch.scanCount,
|
||||||
publishedAt = formatDateOnly(batch.publishedAt),
|
publishedAt = formatDateOnly(batch.publishedAt),
|
||||||
tagsText = batch.tags.joinToString("、").ifBlank { "暂无标签" },
|
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(
|
suspend fun submitFeedback(
|
||||||
code: String,
|
code: String,
|
||||||
type: String,
|
type: String,
|
||||||
@@ -80,10 +96,13 @@ class TraceabilityService(
|
|||||||
private fun toDisplayEntries(step: TraceBatchStepResponse): List<DisplayEntry> {
|
private fun toDisplayEntries(step: TraceBatchStepResponse): List<DisplayEntry> {
|
||||||
return step.values.entries.map { (key, value) ->
|
return step.values.entries.map { (key, value) ->
|
||||||
val field = step.fields.find { it.key == key }
|
val field = step.fields.find { it.key == key }
|
||||||
|
val imageUrl = step.valuePreviewUrls[key].orEmpty()
|
||||||
DisplayEntry(
|
DisplayEntry(
|
||||||
label = field?.label ?: key,
|
label = field?.label ?: key,
|
||||||
value = formatJsonValue(value),
|
value = if ((field?.type ?: "string") == "image" && imageUrl.isNotBlank()) imageUrl else formatJsonValue(value),
|
||||||
type = field?.type ?: "string",
|
type = field?.type ?: "string",
|
||||||
|
bold = field?.fieldStyle?.bold ?: false,
|
||||||
|
color = field?.fieldStyle?.color.orEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +125,5 @@ class TraceabilityService(
|
|||||||
fun ApplicationConfig.toTraceabilityPublicConfig(): TraceabilityPublicConfig {
|
fun ApplicationConfig.toTraceabilityPublicConfig(): TraceabilityPublicConfig {
|
||||||
return TraceabilityPublicConfig(
|
return TraceabilityPublicConfig(
|
||||||
coreBaseUrl = property("traceability.core-base-url").getString().trimEnd('/'),
|
coreBaseUrl = property("traceability.core-base-url").getString().trimEnd('/'),
|
||||||
publicBaseUrl = property("traceability.public-base-url").getString().trimEnd('/'),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ ktor:
|
|||||||
port: 8081
|
port: 8081
|
||||||
|
|
||||||
traceability:
|
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" # 生产
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-shell {
|
.page-shell {
|
||||||
max-width: 1240px;
|
max-width: 1440px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 28px 16px 48px;
|
padding: 36px 40px 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero,
|
.hero,
|
||||||
@@ -30,6 +30,25 @@ a {
|
|||||||
box-shadow: 0 16px 48px rgba(16, 24, 40, 0.08);
|
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 {
|
.hero {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -37,11 +56,6 @@ a {
|
|||||||
padding: 26px;
|
padding: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero--with-cover {
|
|
||||||
grid-template-columns: minmax(0, 1.2fr) 320px;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero h1,
|
.hero h1,
|
||||||
.panel h2,
|
.panel h2,
|
||||||
.info-card h3,
|
.info-card h3,
|
||||||
@@ -69,26 +83,11 @@ a {
|
|||||||
|
|
||||||
.hero__stats {
|
.hero__stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 18px;
|
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,
|
.stat-card,
|
||||||
.summary-card,
|
.summary-card,
|
||||||
.kv-card,
|
.kv-card,
|
||||||
@@ -134,6 +133,7 @@ a {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
@@ -146,9 +146,9 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tabs-nav {
|
.tabs-nav {
|
||||||
display: inline-flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid #e8eef7;
|
border: 1px solid #e8eef7;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -157,7 +157,10 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
min-width: 112px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
padding: 0 18px;
|
padding: 0 18px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -168,6 +171,8 @@ a {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn.active {
|
.tab-btn.active {
|
||||||
@@ -348,6 +353,14 @@ a {
|
|||||||
margin-top: 64px;
|
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) {
|
@media (max-width: 992px) {
|
||||||
.hero,
|
.hero,
|
||||||
.form-grid,
|
.form-grid,
|
||||||
@@ -363,14 +376,21 @@ a {
|
|||||||
.timeline-item__head {
|
.timeline-item__head {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-shell {
|
||||||
|
padding: 24px 18px 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.tabs-nav {
|
.tabs-nav {
|
||||||
display: grid;
|
gap: 4px;
|
||||||
grid-template-columns: 1fr;
|
padding: 6px;
|
||||||
border-radius: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
width: 100%;
|
min-height: 40px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,15 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page-shell">
|
<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">
|
<div class="hero__content">
|
||||||
<h1>${page.batchName}</h1>
|
<h1>${page.batchName}</h1>
|
||||||
<p>${page.summary}</p>
|
<p>${page.summary}</p>
|
||||||
@@ -21,51 +29,35 @@
|
|||||||
<span>产品名称</span>
|
<span>产品名称</span>
|
||||||
<strong>${page.productName}</strong>
|
<strong>${page.productName}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
|
||||||
<span>所属模板</span>
|
|
||||||
<strong>${page.templateName}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>累计访问</span>
|
<span>累计访问</span>
|
||||||
<strong>${page.scanCount}</strong>
|
<strong>${page.scanCount}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="hero__aside">
|
||||||
<div class="summary-card">
|
<div class="summary-card">
|
||||||
<span>发布时间</span>
|
<span>发布时间</span>
|
||||||
<strong>${page.publishedAt}</strong>
|
<strong>${page.publishedAt}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card">
|
<#if page.tagsText != "暂无标签">
|
||||||
<span>标签</span>
|
<div class="summary-card">
|
||||||
<strong>${page.tagsText}</strong>
|
<span>标签</span>
|
||||||
</div>
|
<strong>${page.tagsText}</strong>
|
||||||
|
</div>
|
||||||
|
</#if>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<#if feedbackMessage?has_content>
|
|
||||||
<section class="notice">${feedbackMessage}</section>
|
|
||||||
</#if>
|
|
||||||
|
|
||||||
<section class="panel tabs-panel">
|
<section class="panel tabs-panel">
|
||||||
<div class="tabs-nav" role="tablist" aria-label="溯源页面内容切换">
|
<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 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="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>
|
||||||
|
|
||||||
<div id="timeline-panel" class="tab-panel active">
|
<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>
|
<#if page.businessSections?size gt 0>
|
||||||
<div class="timeline">
|
<div class="timeline">
|
||||||
<#list page.businessSections as section>
|
<#list page.businessSections as section>
|
||||||
@@ -85,10 +77,10 @@
|
|||||||
<#list section.entries as entry>
|
<#list section.entries as entry>
|
||||||
<div class="kv-card">
|
<div class="kv-card">
|
||||||
<span>${entry.label}</span>
|
<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}" />
|
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
|
||||||
<#else>
|
<#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>
|
</#if>
|
||||||
</div>
|
</div>
|
||||||
</#list>
|
</#list>
|
||||||
@@ -103,12 +95,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="public-panel" class="tab-panel">
|
<div id="public-panel" class="tab-panel">
|
||||||
<div class="panel__head">
|
|
||||||
<div>
|
|
||||||
<h2>公开资料</h2>
|
|
||||||
<p>面向消费者展示的企业资料、资质证明及其他公开信息。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<#if page.publicSections?size gt 0>
|
<#if page.publicSections?size gt 0>
|
||||||
<div class="public-grid">
|
<div class="public-grid">
|
||||||
<#list page.publicSections as section>
|
<#list page.publicSections as section>
|
||||||
@@ -121,10 +107,10 @@
|
|||||||
<#list section.entries as entry>
|
<#list section.entries as entry>
|
||||||
<div class="kv-card">
|
<div class="kv-card">
|
||||||
<span>${entry.label}</span>
|
<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}" />
|
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
|
||||||
<#else>
|
<#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>
|
</#if>
|
||||||
</div>
|
</div>
|
||||||
</#list>
|
</#list>
|
||||||
@@ -140,10 +126,15 @@
|
|||||||
<div id="feedback-panel" class="tab-panel">
|
<div id="feedback-panel" class="tab-panel">
|
||||||
<div class="panel__head">
|
<div class="panel__head">
|
||||||
<div>
|
<div>
|
||||||
<h2>反馈与投诉</h2>
|
<h2>反馈投诉</h2>
|
||||||
<p>如发现信息异常、商品质量问题,或有建议,可直接提交。</p>
|
<p>如发现信息异常、商品质量问题,或有建议,可直接提交。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<#if feedbackMessage?has_content>
|
||||||
|
<div class="notice">${feedbackMessage}</div>
|
||||||
|
</#if>
|
||||||
|
|
||||||
<form class="feedback-form" method="post" action="/feedback">
|
<form class="feedback-form" method="post" action="/feedback">
|
||||||
<input type="hidden" name="batchCode" value="${page.code}" />
|
<input type="hidden" name="batchCode" value="${page.code}" />
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
@@ -155,6 +146,7 @@
|
|||||||
<option value="consult">咨询</option>
|
<option value="consult">咨询</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-item">
|
<label class="form-item">
|
||||||
<span>满意度</span>
|
<span>满意度</span>
|
||||||
<select name="rating">
|
<select name="rating">
|
||||||
@@ -165,19 +157,26 @@
|
|||||||
<option value="1">1 分</option>
|
<option value="1">1 分</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-item">
|
<label class="form-item">
|
||||||
<span>联系方式</span>
|
<span>联系方式</span>
|
||||||
<input name="contact" placeholder="手机号 / 邮箱 / 微信" />
|
<input name="contact" placeholder="手机号 / 邮箱 / 微信" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="form-item form-item--full">
|
<label class="form-item form-item--full">
|
||||||
<span>反馈内容</span>
|
<span>反馈内容</span>
|
||||||
<textarea name="content" placeholder="请填写你要反馈的问题或建议" required></textarea>
|
<textarea name="content" placeholder="请填写你要反馈的问题或建议" required></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit" class="submit-btn">提交反馈</button>
|
<button type="submit" class="submit-btn">提交反馈</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<footer class="page-footer">
|
||||||
|
技术支持:四川主干信息技术有限公司 BBITCN Co.,Ltd
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -75,5 +75,5 @@ fun Application.module() {
|
|||||||
VideoAnalyticsJetson()
|
VideoAnalyticsJetson()
|
||||||
// 业务-图片分析
|
// 业务-图片分析
|
||||||
ImageAnalytics()
|
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.CreateTraceBatchRequest
|
||||||
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
|
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.SubmitTraceabilityFeedbackRequest
|
||||||
|
import ink.snowflake.server.model.request.TraceabilityFileAssetDeleteRequest
|
||||||
import ink.snowflake.server.model.request.TraceabilityOssDeleteRequest
|
import ink.snowflake.server.model.request.TraceabilityOssDeleteRequest
|
||||||
import ink.snowflake.server.model.request.TraceabilityOssMoveRequest
|
import ink.snowflake.server.model.request.TraceabilityOssMoveRequest
|
||||||
import ink.snowflake.server.model.request.TraceabilityOssPresignRequest
|
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.TraceBatchStepResponse
|
||||||
import ink.snowflake.server.model.response.TraceabilityOssFileResponse
|
import ink.snowflake.server.model.response.TraceabilityOssFileResponse
|
||||||
import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
|
import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
|
||||||
|
import ink.snowflake.server.utils.AppConfig
|
||||||
import ink.snowflake.server.utils.OSSUtils
|
import ink.snowflake.server.utils.OSSUtils
|
||||||
import ink.snowflake.server.utils.dao.TraceabilityDao
|
import ink.snowflake.server.utils.dao.TraceabilityDao
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
@@ -35,7 +39,58 @@ import kotlinx.serialization.json.JsonObject
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
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()
|
TraceabilityDao.initSchema()
|
||||||
|
|
||||||
routing {
|
routing {
|
||||||
@@ -43,6 +98,30 @@ fun Application.Traceability() {
|
|||||||
get("/overview") {
|
get("/overview") {
|
||||||
call.respond(BaseResponse(data = TraceabilityDao.getOverview()))
|
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") {
|
route("/templates") {
|
||||||
get {
|
get {
|
||||||
call.respond(BaseResponse(data = TraceabilityDao.listTemplates()))
|
call.respond(BaseResponse(data = TraceabilityDao.listTemplates()))
|
||||||
@@ -52,7 +131,7 @@ fun Application.Traceability() {
|
|||||||
call.respond(BaseResponse(data = TraceabilityDao.saveTemplate(null, request)))
|
call.respond(BaseResponse(data = TraceabilityDao.saveTemplate(null, request)))
|
||||||
}
|
}
|
||||||
get("/{id}") {
|
get("/{id}") {
|
||||||
val id = call.parameters["id"]?.let(UUID::fromString)
|
val id = parseUuidOrNull(call.parameters["id"])
|
||||||
val data = id?.let(TraceabilityDao::getTemplate)
|
val data = id?.let(TraceabilityDao::getTemplate)
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "模板不存在", data = null))
|
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "模板不存在", data = null))
|
||||||
@@ -61,16 +140,13 @@ fun Application.Traceability() {
|
|||||||
call.respond(BaseResponse(data = data))
|
call.respond(BaseResponse(data = data))
|
||||||
}
|
}
|
||||||
put("/{id}") {
|
put("/{id}") {
|
||||||
val id = call.parameters["id"]?.let(UUID::fromString)
|
handleSaveTemplate(call, parseUuidOrNull(call.parameters["id"]))
|
||||||
if (id == null) {
|
}
|
||||||
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
|
post("/{id}") {
|
||||||
return@put
|
handleSaveTemplate(call, parseUuidOrNull(call.parameters["id"]))
|
||||||
}
|
|
||||||
val request = call.receive<SaveTraceTemplateRequest>()
|
|
||||||
call.respond(BaseResponse(data = TraceabilityDao.saveTemplate(id, request)))
|
|
||||||
}
|
}
|
||||||
delete("/{id}") {
|
delete("/{id}") {
|
||||||
val id = call.parameters["id"]?.let(UUID::fromString)
|
val id = parseUuidOrNull(call.parameters["id"])
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
|
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
|
||||||
return@delete
|
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") {
|
route("/batches") {
|
||||||
get {
|
get {
|
||||||
call.respond(BaseResponse(data = TraceabilityDao.listBatches()))
|
call.respond(BaseResponse(data = TraceabilityDao.listBatches()))
|
||||||
}
|
}
|
||||||
post {
|
post {
|
||||||
val request = call.receive<CreateTraceBatchRequest>()
|
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}") {
|
delete("/{id}") {
|
||||||
val id = call.parameters["id"]?.let(UUID::fromString)
|
val id = parseUuidOrNull(call.parameters["id"])
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
|
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
|
||||||
return@delete
|
return@delete
|
||||||
@@ -98,7 +247,7 @@ fun Application.Traceability() {
|
|||||||
call.respond(BaseResponse(status = deleted, message = if (deleted) "批次已删除" else "批次不存在", data = deleted))
|
call.respond(BaseResponse(status = deleted, message = if (deleted) "批次已删除" else "批次不存在", data = deleted))
|
||||||
}
|
}
|
||||||
get("/{id}") {
|
get("/{id}") {
|
||||||
val id = call.parameters["id"]?.let(UUID::fromString)
|
val id = parseUuidOrNull(call.parameters["id"])
|
||||||
val data = id?.let(TraceabilityDao::getBatch)
|
val data = id?.let(TraceabilityDao::getBatch)
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
|
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
|
||||||
@@ -107,36 +256,27 @@ fun Application.Traceability() {
|
|||||||
call.respond(BaseResponse(data = data))
|
call.respond(BaseResponse(data = data))
|
||||||
}
|
}
|
||||||
put("/{id}/base") {
|
put("/{id}/base") {
|
||||||
val id = call.parameters["id"]?.let(UUID::fromString)
|
handleUpdateBatchBase(call, parseUuidOrNull(call.parameters["id"]))
|
||||||
if (id == null) {
|
}
|
||||||
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
|
post("/{id}/base") {
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
put("/{id}/steps/{stepId}") {
|
put("/{id}/steps/{stepId}") {
|
||||||
val id = call.parameters["id"]?.let(UUID::fromString)
|
handleUpdateBatchStep(
|
||||||
val stepId = call.parameters["stepId"]?.let(UUID::fromString)
|
call,
|
||||||
if (id == null || stepId == null) {
|
parseUuidOrNull(call.parameters["id"]),
|
||||||
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "步骤ID无效", data = null))
|
parseUuidOrNull(call.parameters["stepId"]),
|
||||||
return@put
|
)
|
||||||
}
|
}
|
||||||
val request = call.receive<UpdateTraceBatchStepRequest>()
|
post("/{id}/steps/{stepId}") {
|
||||||
val data = TraceabilityDao.updateBatchStep(id, stepId, request)
|
handleUpdateBatchStep(
|
||||||
if (data == null) {
|
call,
|
||||||
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "步骤不存在", data = null))
|
parseUuidOrNull(call.parameters["id"]),
|
||||||
return@put
|
parseUuidOrNull(call.parameters["stepId"]),
|
||||||
}
|
)
|
||||||
call.respond(BaseResponse(data = data))
|
|
||||||
}
|
}
|
||||||
post("/{id}/publish") {
|
post("/{id}/publish") {
|
||||||
val id = call.parameters["id"]?.let(UUID::fromString)
|
val id = parseUuidOrNull(call.parameters["id"])
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
|
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
|
||||||
return@post
|
return@post
|
||||||
@@ -156,7 +296,12 @@ fun Application.Traceability() {
|
|||||||
}
|
}
|
||||||
post {
|
post {
|
||||||
val request = call.receive<SubmitTraceabilityFeedbackRequest>()
|
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") {
|
route("/public") {
|
||||||
@@ -170,9 +315,42 @@ fun Application.Traceability() {
|
|||||||
}
|
}
|
||||||
call.respond(BaseResponse(data = data))
|
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") {
|
post("/feedback") {
|
||||||
val request = call.receive<SubmitTraceabilityFeedbackRequest>()
|
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}") {
|
get("/page/{code}") {
|
||||||
val code = call.parameters["code"] ?: ""
|
val code = call.parameters["code"] ?: ""
|
||||||
@@ -191,6 +369,7 @@ fun Application.Traceability() {
|
|||||||
var bucketName = OSSUtils.defaultBucket()
|
var bucketName = OSSUtils.defaultBucket()
|
||||||
var objectDir = "traceability/images"
|
var objectDir = "traceability/images"
|
||||||
var objectName = ""
|
var objectName = ""
|
||||||
|
var assetType = ""
|
||||||
var response: TraceabilityOssFileResponse? = null
|
var response: TraceabilityOssFileResponse? = null
|
||||||
|
|
||||||
multipart.forEachPart { part ->
|
multipart.forEachPart { part ->
|
||||||
@@ -200,6 +379,7 @@ fun Application.Traceability() {
|
|||||||
"bucketName" -> bucketName = part.value.ifBlank { OSSUtils.defaultBucket() }
|
"bucketName" -> bucketName = part.value.ifBlank { OSSUtils.defaultBucket() }
|
||||||
"objectDir" -> objectDir = part.value.ifBlank { "traceability/images" }
|
"objectDir" -> objectDir = part.value.ifBlank { "traceability/images" }
|
||||||
"objectName" -> objectName = part.value
|
"objectName" -> objectName = part.value
|
||||||
|
"assetType" -> assetType = part.value.trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +403,16 @@ fun Application.Traceability() {
|
|||||||
fileName = fileName,
|
fileName = fileName,
|
||||||
size = bytes.size.toLong(),
|
size = bytes.size.toLong(),
|
||||||
)
|
)
|
||||||
|
if (assetType.isNotBlank()) {
|
||||||
|
TraceabilityDao.recordFileAsset(
|
||||||
|
assetType = assetType,
|
||||||
|
bucketName = bucketName,
|
||||||
|
objectName = finalObjectName,
|
||||||
|
fileName = fileName,
|
||||||
|
contentType = contentType,
|
||||||
|
size = bytes.size.toLong(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
@@ -240,6 +430,39 @@ fun Application.Traceability() {
|
|||||||
call.respond(BaseResponse(message = "图片上传成功", data = response))
|
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") {
|
post("/presigned-put") {
|
||||||
val request = call.receive<TraceabilityOssPresignRequest>()
|
val request = call.receive<TraceabilityOssPresignRequest>()
|
||||||
val bucketName = request.bucketName?.ifBlank { OSSUtils.defaultBucket() } ?: OSSUtils.defaultBucket()
|
val bucketName = request.bucketName?.ifBlank { OSSUtils.defaultBucket() } ?: OSSUtils.defaultBucket()
|
||||||
@@ -324,7 +547,8 @@ private fun renderTraceabilityPage(detail: TraceabilityPublicDetailResponse): St
|
|||||||
val batch = detail.batch
|
val batch = detail.batch
|
||||||
val publicCards = detail.publicSections.joinToString("") { renderSectionCard(it) }
|
val publicCards = detail.publicSections.joinToString("") { renderSectionCard(it) }
|
||||||
val timelineCards = detail.businessSections.joinToString("") { renderTimelineCard(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"
|
?: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1400&q=80"
|
||||||
|
|
||||||
return """
|
return """
|
||||||
@@ -405,18 +629,18 @@ private fun renderTraceabilityPage(detail: TraceabilityPublicDetailResponse): St
|
|||||||
<div class="timeline">$timelineCards</div>
|
<div class="timeline">$timelineCards</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="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-grid">
|
||||||
<div class="feedback">
|
<div class="feedback">
|
||||||
<form id="feedback-form">
|
<form id="feedback-form">
|
||||||
<label>反馈类型</label>
|
<label>反馈类型</label>
|
||||||
<select name="type"><option value="complaint">投诉</option><option value="suggestion">建议</option><option value="consult">咨询</option></select>
|
<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>
|
<label>联系方式</label>
|
||||||
<input name="contact" placeholder="手机号、邮箱或微信(选填)" />
|
<input name="contact" placeholder="手机号、邮箱或微信(选填)" />
|
||||||
<label>反馈内容</label>
|
<label>反馈内容</label>
|
||||||
<textarea name="content" placeholder="请描述你的问题或建议"></textarea>
|
<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>
|
<button type="submit">提交反馈</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -450,15 +674,21 @@ private fun renderTraceabilityPage(detail: TraceabilityPublicDetailResponse): St
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun renderSectionCard(step: TraceBatchStepResponse): String =
|
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 {
|
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></div><div class="kv-grid">${renderValueCards(step)}</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><span class="tag">${escapeHtml(step.status)} · ${escapeHtml(time)}</span></div><div class="kv-grid">${renderValueCards(step.values)}</div></div></div>"""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun renderValueCards(values: JsonObject): String = values.entries.joinToString("") { (key, value) ->
|
private fun renderValueCards(step: TraceBatchStepResponse): String = step.values.entries.joinToString("") { (key, value) ->
|
||||||
"""<div class="kv"><span>${escapeHtml(key)}</span><strong>${escapeHtml(formatJsonValue(value))}</strong></div>"""
|
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) {
|
private fun formatJsonValue(value: JsonElement): String = when (value) {
|
||||||
|
|||||||
@@ -28,6 +28,41 @@ object TraceabilityTemplateNodesTable : UUIDTable("traceability_template_nodes")
|
|||||||
val updatedAt = timestamp("updated_at").nullable()
|
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") {
|
object TraceabilityBatchesTable : UUIDTable("traceability_batches") {
|
||||||
val templateId = reference("template_id", TraceabilityTemplatesTable)
|
val templateId = reference("template_id", TraceabilityTemplatesTable)
|
||||||
val batchName = varchar("batch_name", 150)
|
val batchName = varchar("batch_name", 150)
|
||||||
@@ -55,6 +90,7 @@ object TraceabilityBatchStepsTable : UUIDTable("traceability_batch_steps") {
|
|||||||
val consumerVisible = bool("consumer_visible").default(true)
|
val consumerVisible = bool("consumer_visible").default(true)
|
||||||
val status = varchar("status", 32).default("pending")
|
val status = varchar("status", 32).default("pending")
|
||||||
val operatorName = varchar("operator_name", 80).default("")
|
val operatorName = varchar("operator_name", 80).default("")
|
||||||
|
val fieldsJson = text("fields_json").default("[]")
|
||||||
val valuesJson = text("values_json").default("{}")
|
val valuesJson = text("values_json").default("{}")
|
||||||
val completedAt = timestamp("completed_at").nullable()
|
val completedAt = timestamp("completed_at").nullable()
|
||||||
val createdAt = timestamp("created_at").nullable()
|
val createdAt = timestamp("created_at").nullable()
|
||||||
@@ -70,3 +106,13 @@ object TraceabilityFeedbackTable : UUIDTable("traceability_feedback") {
|
|||||||
val rating = integer("rating").default(5)
|
val rating = integer("rating").default(5)
|
||||||
val createdAt = timestamp("created_at").nullable()
|
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 kotlinx.serialization.json.JsonObject
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TraceFieldStyleRequest(
|
||||||
|
val bold: Boolean = false,
|
||||||
|
val color: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TraceFieldDefinitionRequest(
|
data class TraceFieldDefinitionRequest(
|
||||||
val key: String,
|
val key: String,
|
||||||
@@ -12,9 +18,11 @@ data class TraceFieldDefinitionRequest(
|
|||||||
val type: String = "string",
|
val type: String = "string",
|
||||||
val required: Boolean = false,
|
val required: Boolean = false,
|
||||||
val visible: Boolean = true,
|
val visible: Boolean = true,
|
||||||
|
val fixedPreset: Boolean = false,
|
||||||
val placeholder: String = "",
|
val placeholder: String = "",
|
||||||
val defaultValue: JsonElement? = null,
|
val defaultValue: JsonElement? = null,
|
||||||
val options: List<String> = emptyList(),
|
val options: List<String> = emptyList(),
|
||||||
|
val fieldStyle: TraceFieldStyleRequest = TraceFieldStyleRequest(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -28,6 +36,17 @@ data class TraceTemplateNodeRequest(
|
|||||||
val fields: List<TraceFieldDefinitionRequest> = emptyList(),
|
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
|
@Serializable
|
||||||
data class SaveTraceTemplateRequest(
|
data class SaveTraceTemplateRequest(
|
||||||
val name: String,
|
val name: String,
|
||||||
@@ -40,6 +59,26 @@ data class SaveTraceTemplateRequest(
|
|||||||
val nodes: List<TraceTemplateNodeRequest> = emptyList(),
|
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
|
@Serializable
|
||||||
data class CreateTraceBatchRequest(
|
data class CreateTraceBatchRequest(
|
||||||
val templateId: String,
|
val templateId: String,
|
||||||
@@ -109,4 +148,9 @@ data class TraceabilityOssDeleteRequest(
|
|||||||
val objectName: String,
|
val objectName: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TraceabilityFileAssetDeleteRequest(
|
||||||
|
val id: String,
|
||||||
|
)
|
||||||
|
|
||||||
fun CreateTraceBatchRequest.templateUuid(): UUID = UUID.fromString(templateId)
|
fun CreateTraceBatchRequest.templateUuid(): UUID = UUID.fromString(templateId)
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ data class TraceabilityOverviewResponse(
|
|||||||
val totalScans: Int,
|
val totalScans: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TraceFieldStyleResponse(
|
||||||
|
val bold: Boolean = false,
|
||||||
|
val color: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TraceFieldDefinitionResponse(
|
data class TraceFieldDefinitionResponse(
|
||||||
val key: String,
|
val key: String,
|
||||||
@@ -20,9 +26,12 @@ data class TraceFieldDefinitionResponse(
|
|||||||
val type: String = "string",
|
val type: String = "string",
|
||||||
val required: Boolean = false,
|
val required: Boolean = false,
|
||||||
val visible: Boolean = true,
|
val visible: Boolean = true,
|
||||||
|
val fixedPreset: Boolean = false,
|
||||||
val placeholder: String = "",
|
val placeholder: String = "",
|
||||||
val defaultValue: JsonElement? = null,
|
val defaultValue: JsonElement? = null,
|
||||||
|
val defaultPreviewUrl: String? = null,
|
||||||
val options: List<String> = emptyList(),
|
val options: List<String> = emptyList(),
|
||||||
|
val fieldStyle: TraceFieldStyleResponse = TraceFieldStyleResponse(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -37,6 +46,17 @@ data class TraceTemplateNodeResponse(
|
|||||||
val fields: List<TraceFieldDefinitionResponse>,
|
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
|
@Serializable
|
||||||
data class TraceTemplateSummaryResponse(
|
data class TraceTemplateSummaryResponse(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -45,6 +65,7 @@ data class TraceTemplateSummaryResponse(
|
|||||||
val productName: String,
|
val productName: String,
|
||||||
val industryName: String,
|
val industryName: String,
|
||||||
val coverImage: String,
|
val coverImage: String,
|
||||||
|
val coverImagePreviewUrl: String = "",
|
||||||
val themeColor: String,
|
val themeColor: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
val nodeCount: Int,
|
val nodeCount: Int,
|
||||||
@@ -60,12 +81,57 @@ data class TraceTemplateDetailResponse(
|
|||||||
val productName: String,
|
val productName: String,
|
||||||
val industryName: String,
|
val industryName: String,
|
||||||
val coverImage: String,
|
val coverImage: String,
|
||||||
|
val coverImagePreviewUrl: String = "",
|
||||||
val themeColor: String,
|
val themeColor: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
val nodes: List<TraceTemplateNodeResponse>,
|
val nodes: List<TraceTemplateNodeResponse>,
|
||||||
val updatedAt: String,
|
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
|
@Serializable
|
||||||
data class TraceBatchStepResponse(
|
data class TraceBatchStepResponse(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -79,6 +145,7 @@ data class TraceBatchStepResponse(
|
|||||||
val status: String,
|
val status: String,
|
||||||
val operatorName: String,
|
val operatorName: String,
|
||||||
val values: JsonObject,
|
val values: JsonObject,
|
||||||
|
val valuePreviewUrls: Map<String, String> = emptyMap(),
|
||||||
val completedAt: String = "",
|
val completedAt: String = "",
|
||||||
val fields: List<TraceFieldDefinitionResponse> = emptyList(),
|
val fields: List<TraceFieldDefinitionResponse> = emptyList(),
|
||||||
)
|
)
|
||||||
@@ -93,6 +160,7 @@ data class TraceBatchSummaryResponse(
|
|||||||
val productName: String,
|
val productName: String,
|
||||||
val summary: String,
|
val summary: String,
|
||||||
val coverImage: String,
|
val coverImage: String,
|
||||||
|
val coverImagePreviewUrl: String = "",
|
||||||
val tags: List<String>,
|
val tags: List<String>,
|
||||||
val status: String,
|
val status: String,
|
||||||
val currentStep: Int,
|
val currentStep: Int,
|
||||||
@@ -111,6 +179,7 @@ data class TraceBatchDetailResponse(
|
|||||||
val productName: String,
|
val productName: String,
|
||||||
val summary: String,
|
val summary: String,
|
||||||
val coverImage: String,
|
val coverImage: String,
|
||||||
|
val coverImagePreviewUrl: String = "",
|
||||||
val tags: List<String>,
|
val tags: List<String>,
|
||||||
val status: String,
|
val status: String,
|
||||||
val currentStep: Int,
|
val currentStep: Int,
|
||||||
@@ -153,3 +222,16 @@ data class TraceabilityOssFileResponse(
|
|||||||
val fileName: String? = null,
|
val fileName: String? = null,
|
||||||
val size: Long? = 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 ossDefaultBucket: String = config.property("ktor.oss.default-bucket").getString()
|
||||||
val ossFallbackBucket: String = config.property("ktor.oss.fallback-bucket").getString()
|
val ossFallbackBucket: String = config.property("ktor.oss.fallback-bucket").getString()
|
||||||
val ossFallbackObject: String = config.property("ktor.oss.fallback-object").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.TraceabilityBatchStepsTable
|
||||||
import ink.snowflake.server.model.database.TraceabilityBatchesTable
|
import ink.snowflake.server.model.database.TraceabilityBatchesTable
|
||||||
import ink.snowflake.server.model.database.TraceabilityFeedbackTable
|
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.TraceabilityTemplateNodesTable
|
||||||
import ink.snowflake.server.model.database.TraceabilityTemplatesTable
|
import ink.snowflake.server.model.database.TraceabilityTemplatesTable
|
||||||
import ink.snowflake.server.model.request.CreateTraceBatchRequest
|
import ink.snowflake.server.model.request.CreateTraceBatchRequest
|
||||||
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
|
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.SubmitTraceabilityFeedbackRequest
|
||||||
import ink.snowflake.server.model.request.TraceFieldDefinitionRequest
|
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.UpdateTraceBatchBaseRequest
|
||||||
import ink.snowflake.server.model.request.UpdateTraceBatchStepRequest
|
import ink.snowflake.server.model.request.UpdateTraceBatchStepRequest
|
||||||
import ink.snowflake.server.model.request.templateUuid
|
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.TraceBatchStepResponse
|
||||||
import ink.snowflake.server.model.response.TraceBatchSummaryResponse
|
import ink.snowflake.server.model.response.TraceBatchSummaryResponse
|
||||||
import ink.snowflake.server.model.response.TraceFieldDefinitionResponse
|
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.TraceTemplateDetailResponse
|
||||||
import ink.snowflake.server.model.response.TraceTemplateNodeResponse
|
import ink.snowflake.server.model.response.TraceTemplateNodeResponse
|
||||||
import ink.snowflake.server.model.response.TraceTemplateSummaryResponse
|
import ink.snowflake.server.model.response.TraceTemplateSummaryResponse
|
||||||
import ink.snowflake.server.model.response.TraceabilityFeedbackResponse
|
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.TraceabilityOverviewResponse
|
||||||
import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
|
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.Clock
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonNull
|
import kotlinx.serialization.json.JsonNull
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import org.jetbrains.exposed.v1.core.SortOrder
|
import org.jetbrains.exposed.v1.core.SortOrder
|
||||||
import org.jetbrains.exposed.v1.core.and
|
import org.jetbrains.exposed.v1.core.and
|
||||||
@@ -47,21 +65,24 @@ object TraceabilityDao {
|
|||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
}
|
}
|
||||||
private val publicPreviewBaseUrl =
|
private var publicPreviewBaseUrl: String = "http://127.0.0.1:8081"
|
||||||
System.getenv("TRACEABILITY_PUBLIC_PREVIEW_BASE_URL")
|
|
||||||
?.trim()
|
fun init(config: AppConfig) {
|
||||||
?.trimEnd('/')
|
publicPreviewBaseUrl = config.traceabilityPublicPreviewBaseUrl
|
||||||
?.takeIf { it.isNotBlank() }
|
}
|
||||||
?: "http://127.0.0.1:8081"
|
|
||||||
|
|
||||||
fun initSchema() {
|
fun initSchema() {
|
||||||
transaction {
|
transaction {
|
||||||
SchemaUtils.createMissingTablesAndColumns(
|
SchemaUtils.createMissingTablesAndColumns(
|
||||||
TraceabilityTemplatesTable,
|
TraceabilityTemplatesTable,
|
||||||
TraceabilityTemplateNodesTable,
|
TraceabilityTemplateNodesTable,
|
||||||
|
TraceabilityPreviewPagesTable,
|
||||||
|
TraceabilityPreviewNodesTable,
|
||||||
|
TraceabilityNodeLibraryTable,
|
||||||
TraceabilityBatchesTable,
|
TraceabilityBatchesTable,
|
||||||
TraceabilityBatchStepsTable,
|
TraceabilityBatchStepsTable,
|
||||||
TraceabilityFeedbackTable,
|
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()
|
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 {
|
fun listTemplates(): List<TraceTemplateSummaryResponse> = transaction {
|
||||||
val batchCountByTemplate = TraceabilityBatchesTable.selectAll()
|
val batchCountByTemplate = TraceabilityBatchesTable.selectAll()
|
||||||
.groupBy { it[TraceabilityBatchesTable.templateId].value }
|
.groupBy { it[TraceabilityBatchesTable.templateId].value }
|
||||||
@@ -99,6 +486,7 @@ object TraceabilityDao {
|
|||||||
productName = it[TraceabilityTemplatesTable.productName],
|
productName = it[TraceabilityTemplatesTable.productName],
|
||||||
industryName = it[TraceabilityTemplatesTable.industryName],
|
industryName = it[TraceabilityTemplatesTable.industryName],
|
||||||
coverImage = it[TraceabilityTemplatesTable.coverImage],
|
coverImage = it[TraceabilityTemplatesTable.coverImage],
|
||||||
|
coverImagePreviewUrl = resolveStoredImagePreviewUrl(it[TraceabilityTemplatesTable.coverImage]).orEmpty(),
|
||||||
themeColor = it[TraceabilityTemplatesTable.themeColor],
|
themeColor = it[TraceabilityTemplatesTable.themeColor],
|
||||||
status = it[TraceabilityTemplatesTable.status],
|
status = it[TraceabilityTemplatesTable.status],
|
||||||
nodeCount = nodeCountByTemplate[it[TraceabilityTemplatesTable.id].value] ?: 0,
|
nodeCount = nodeCountByTemplate[it[TraceabilityTemplatesTable.id].value] ?: 0,
|
||||||
@@ -120,6 +508,7 @@ object TraceabilityDao {
|
|||||||
productName = templateRow[TraceabilityTemplatesTable.productName],
|
productName = templateRow[TraceabilityTemplatesTable.productName],
|
||||||
industryName = templateRow[TraceabilityTemplatesTable.industryName],
|
industryName = templateRow[TraceabilityTemplatesTable.industryName],
|
||||||
coverImage = templateRow[TraceabilityTemplatesTable.coverImage],
|
coverImage = templateRow[TraceabilityTemplatesTable.coverImage],
|
||||||
|
coverImagePreviewUrl = resolveStoredImagePreviewUrl(templateRow[TraceabilityTemplatesTable.coverImage]).orEmpty(),
|
||||||
themeColor = templateRow[TraceabilityTemplatesTable.themeColor],
|
themeColor = templateRow[TraceabilityTemplatesTable.themeColor],
|
||||||
status = templateRow[TraceabilityTemplatesTable.status],
|
status = templateRow[TraceabilityTemplatesTable.status],
|
||||||
nodes = loadTemplateNodes(templateId),
|
nodes = loadTemplateNodes(templateId),
|
||||||
@@ -154,11 +543,17 @@ object TraceabilityDao {
|
|||||||
}
|
}
|
||||||
val existingNodeIds = TraceabilityTemplateNodesTable.selectAll()
|
val existingNodeIds = TraceabilityTemplateNodesTable.selectAll()
|
||||||
.where { TraceabilityTemplateNodesTable.templateId eq currentId }
|
.where { TraceabilityTemplateNodesTable.templateId eq currentId }
|
||||||
.map { it[TraceabilityTemplateNodesTable.id].value }
|
.associate {
|
||||||
|
it[TraceabilityTemplateNodesTable.id].value to it[TraceabilityTemplateNodesTable.fieldsJson]
|
||||||
|
}
|
||||||
if (existingNodeIds.isNotEmpty()) {
|
if (existingNodeIds.isNotEmpty()) {
|
||||||
existingNodeIds.forEach { nodeId ->
|
existingNodeIds.forEach { (nodeId, fieldsJson) ->
|
||||||
TraceabilityBatchStepsTable.deleteWhere {
|
TraceabilityBatchStepsTable.update({
|
||||||
TraceabilityBatchStepsTable.templateNodeId eq nodeId
|
TraceabilityBatchStepsTable.templateNodeId eq nodeId
|
||||||
|
}) {
|
||||||
|
it[TraceabilityBatchStepsTable.fieldsJson] = fieldsJson
|
||||||
|
it[templateNodeId] = null
|
||||||
|
it[updatedAt] = now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,6 +616,7 @@ object TraceabilityDao {
|
|||||||
productName = it[TraceabilityBatchesTable.productName],
|
productName = it[TraceabilityBatchesTable.productName],
|
||||||
summary = it[TraceabilityBatchesTable.summary],
|
summary = it[TraceabilityBatchesTable.summary],
|
||||||
coverImage = it[TraceabilityBatchesTable.coverImage],
|
coverImage = it[TraceabilityBatchesTable.coverImage],
|
||||||
|
coverImagePreviewUrl = resolveStoredImagePreviewUrl(it[TraceabilityBatchesTable.coverImage]).orEmpty(),
|
||||||
tags = decodeStringList(it[TraceabilityBatchesTable.tagsJson]),
|
tags = decodeStringList(it[TraceabilityBatchesTable.tagsJson]),
|
||||||
status = it[TraceabilityBatchesTable.status],
|
status = it[TraceabilityBatchesTable.status],
|
||||||
currentStep = it[TraceabilityBatchesTable.currentStep],
|
currentStep = it[TraceabilityBatchesTable.currentStep],
|
||||||
@@ -249,6 +645,7 @@ object TraceabilityDao {
|
|||||||
productName = batchRow[TraceabilityBatchesTable.productName],
|
productName = batchRow[TraceabilityBatchesTable.productName],
|
||||||
summary = batchRow[TraceabilityBatchesTable.summary],
|
summary = batchRow[TraceabilityBatchesTable.summary],
|
||||||
coverImage = batchRow[TraceabilityBatchesTable.coverImage],
|
coverImage = batchRow[TraceabilityBatchesTable.coverImage],
|
||||||
|
coverImagePreviewUrl = resolveStoredImagePreviewUrl(batchRow[TraceabilityBatchesTable.coverImage]).orEmpty(),
|
||||||
tags = decodeStringList(batchRow[TraceabilityBatchesTable.tagsJson]),
|
tags = decodeStringList(batchRow[TraceabilityBatchesTable.tagsJson]),
|
||||||
status = batchRow[TraceabilityBatchesTable.status],
|
status = batchRow[TraceabilityBatchesTable.status],
|
||||||
currentStep = batchRow[TraceabilityBatchesTable.currentStep],
|
currentStep = batchRow[TraceabilityBatchesTable.currentStep],
|
||||||
@@ -260,8 +657,8 @@ object TraceabilityDao {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createBatch(request: CreateTraceBatchRequest): TraceBatchDetailResponse = transaction {
|
fun createBatch(request: CreateTraceBatchRequest): TraceBatchDetailResponse? = transaction {
|
||||||
val template = getTemplate(request.templateUuid()) ?: error("template not found")
|
val template = getTemplate(request.templateUuid()) ?: return@transaction null
|
||||||
val now = timestampLiteral(nowInstant())
|
val now = timestampLiteral(nowInstant())
|
||||||
val batchId = TraceabilityBatchesTable.insertAndGetId {
|
val batchId = TraceabilityBatchesTable.insertAndGetId {
|
||||||
it[this.templateId] = request.templateUuid()
|
it[this.templateId] = request.templateUuid()
|
||||||
@@ -269,7 +666,7 @@ object TraceabilityDao {
|
|||||||
it[batchCode] = request.batchCode
|
it[batchCode] = request.batchCode
|
||||||
it[productName] = request.productName
|
it[productName] = request.productName
|
||||||
it[summary] = request.summary
|
it[summary] = request.summary
|
||||||
it[coverImage] = request.coverImage
|
it[coverImage] = request.coverImage.ifBlank { template.coverImage }
|
||||||
it[tagsJson] = json.encodeToString(request.tags)
|
it[tagsJson] = json.encodeToString(request.tags)
|
||||||
it[status] = "draft"
|
it[status] = "draft"
|
||||||
it[currentStep] = 0
|
it[currentStep] = 0
|
||||||
@@ -287,6 +684,7 @@ object TraceabilityDao {
|
|||||||
it[description] = node.description
|
it[description] = node.description
|
||||||
it[locked] = node.locked
|
it[locked] = node.locked
|
||||||
it[consumerVisible] = node.consumerVisible
|
it[consumerVisible] = node.consumerVisible
|
||||||
|
it[fieldsJson] = json.encodeToString(node.fields)
|
||||||
it[status] = "pending"
|
it[status] = "pending"
|
||||||
it[operatorName] = ""
|
it[operatorName] = ""
|
||||||
it[valuesJson] = buildDefaultValues(node.fields)
|
it[valuesJson] = buildDefaultValues(node.fields)
|
||||||
@@ -295,7 +693,7 @@ object TraceabilityDao {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getBatch(batchId)!!
|
getBatch(batchId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateBatchBase(batchId: UUID, request: UpdateTraceBatchBaseRequest): TraceBatchDetailResponse? = transaction {
|
fun updateBatchBase(batchId: UUID, request: UpdateTraceBatchBaseRequest): TraceBatchDetailResponse? = transaction {
|
||||||
@@ -357,11 +755,19 @@ object TraceabilityDao {
|
|||||||
getBatch(batchId)
|
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()
|
val batchRow = TraceabilityBatchesTable.selectAll()
|
||||||
.where { TraceabilityBatchesTable.batchCode eq batchCode }
|
.where { TraceabilityBatchesTable.batchCode eq batchCode }
|
||||||
.singleOrNull() ?: return@transaction null
|
.singleOrNull() ?: return@transaction null
|
||||||
|
|
||||||
|
if (onlyPublished && batchRow[TraceabilityBatchesTable.status] != "published") {
|
||||||
|
return@transaction null
|
||||||
|
}
|
||||||
|
|
||||||
if (increaseScan) {
|
if (increaseScan) {
|
||||||
TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchRow[TraceabilityBatchesTable.id].value }) {
|
TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchRow[TraceabilityBatchesTable.id].value }) {
|
||||||
it[scanCount] = batchRow[TraceabilityBatchesTable.scanCount] + 1
|
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 {
|
fun listFeedback(): List<TraceabilityFeedbackResponse> = transaction {
|
||||||
val batchMap = TraceabilityBatchesTable.selectAll()
|
val batchMap = TraceabilityBatchesTable.selectAll()
|
||||||
.associateBy { it[TraceabilityBatchesTable.id].value }
|
.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 {
|
val batchId = when {
|
||||||
!request.batchId.isNullOrBlank() -> UUID.fromString(request.batchId)
|
!request.batchId.isNullOrBlank() -> runCatching { UUID.fromString(request.batchId) }.getOrNull()
|
||||||
!request.batchCode.isNullOrBlank() -> TraceabilityBatchesTable.selectAll()
|
!request.batchCode.isNullOrBlank() -> TraceabilityBatchesTable.selectAll()
|
||||||
.where { TraceabilityBatchesTable.batchCode eq request.batchCode }
|
.where { TraceabilityBatchesTable.batchCode eq request.batchCode }
|
||||||
.single()[TraceabilityBatchesTable.id].value
|
.singleOrNull()?.get(TraceabilityBatchesTable.id)?.value
|
||||||
else -> error("batch not found")
|
else -> null
|
||||||
}
|
} ?: return@transaction null
|
||||||
|
|
||||||
val now = timestampLiteral(nowInstant())
|
val now = timestampLiteral(nowInstant())
|
||||||
val feedbackId = TraceabilityFeedbackTable.insertAndGetId {
|
val feedbackId = TraceabilityFeedbackTable.insertAndGetId {
|
||||||
@@ -420,7 +872,7 @@ object TraceabilityDao {
|
|||||||
it[createdAt] = now
|
it[createdAt] = now
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
listFeedback().first { it.id == feedbackId.toString() }
|
listFeedback().firstOrNull { it.id == feedbackId.toString() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadTemplateNodes(templateId: UUID): List<TraceTemplateNodeResponse> {
|
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> {
|
private fun loadBatchSteps(batchId: UUID): List<TraceBatchStepResponse> {
|
||||||
return TraceabilityBatchStepsTable.selectAll()
|
return TraceabilityBatchStepsTable.selectAll()
|
||||||
.where { TraceabilityBatchStepsTable.batchId eq batchId }
|
.where { TraceabilityBatchStepsTable.batchId eq batchId }
|
||||||
.orderBy(TraceabilityBatchStepsTable.sort, SortOrder.ASC)
|
.orderBy(TraceabilityBatchStepsTable.sort, SortOrder.ASC)
|
||||||
.map { row ->
|
.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()
|
TraceabilityTemplateNodesTable.selectAll()
|
||||||
.where { TraceabilityTemplateNodesTable.id eq nodeId }
|
.where { TraceabilityTemplateNodesTable.id eq nodeId }
|
||||||
.singleOrNull()
|
.singleOrNull()
|
||||||
@@ -465,6 +939,10 @@ object TraceabilityDao {
|
|||||||
status = row[TraceabilityBatchStepsTable.status],
|
status = row[TraceabilityBatchStepsTable.status],
|
||||||
operatorName = row[TraceabilityBatchStepsTable.operatorName],
|
operatorName = row[TraceabilityBatchStepsTable.operatorName],
|
||||||
values = decodeValues(row[TraceabilityBatchStepsTable.valuesJson]),
|
values = decodeValues(row[TraceabilityBatchStepsTable.valuesJson]),
|
||||||
|
valuePreviewUrls = buildValuePreviewUrls(
|
||||||
|
fields,
|
||||||
|
decodeValues(row[TraceabilityBatchStepsTable.valuesJson]),
|
||||||
|
),
|
||||||
completedAt = formatTimestamp(row[TraceabilityBatchStepsTable.completedAt]),
|
completedAt = formatTimestamp(row[TraceabilityBatchStepsTable.completedAt]),
|
||||||
fields = fields,
|
fields = fields,
|
||||||
)
|
)
|
||||||
@@ -479,9 +957,15 @@ object TraceabilityDao {
|
|||||||
type = it.type,
|
type = it.type,
|
||||||
required = it.required,
|
required = it.required,
|
||||||
visible = it.visible,
|
visible = it.visible,
|
||||||
|
fixedPreset = it.fixedPreset,
|
||||||
placeholder = it.placeholder,
|
placeholder = it.placeholder,
|
||||||
defaultValue = it.defaultValue,
|
defaultValue = it.defaultValue,
|
||||||
|
defaultPreviewUrl = if (it.type == "image") resolveImagePreviewUrl(it.defaultValue) else null,
|
||||||
options = it.options,
|
options = it.options,
|
||||||
|
fieldStyle = TraceFieldStyleResponse(
|
||||||
|
bold = it.fieldStyle.bold,
|
||||||
|
color = it.fieldStyle.color,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,6 +982,81 @@ object TraceabilityDao {
|
|||||||
emptyList()
|
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 {
|
private fun buildDefaultValues(fields: List<TraceFieldDefinitionResponse>): String {
|
||||||
val values = buildJsonObject {
|
val values = buildJsonObject {
|
||||||
fields.forEach { field ->
|
fields.forEach { field ->
|
||||||
@@ -511,6 +1070,15 @@ object TraceabilityDao {
|
|||||||
value?.toString()?.replace('T', ' ')?.replace("Z", "") ?: ""
|
value?.toString()?.replace('T', ' ')?.replace("Z", "") ?: ""
|
||||||
|
|
||||||
private fun publicUrl(code: String): String {
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,3 +40,7 @@ ktor:
|
|||||||
fallback-bucket: "system"
|
fallback-bucket: "system"
|
||||||
fallback-object: "favicon.ico"
|
fallback-object: "favicon.ico"
|
||||||
|
|
||||||
|
traceability:
|
||||||
|
# public-preview-base-url: "http://127.0.0.1:8081" # 开发测试用
|
||||||
|
public-preview-base-url: "https://ats.f10.bbitcn.com" # 生产环境用
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"@vben/types": "workspace:*",
|
"@vben/types": "workspace:*",
|
||||||
"@vben/utils": "workspace:*",
|
"@vben/utils": "workspace:*",
|
||||||
"@vueuse/core": "catalog:",
|
"@vueuse/core": "catalog:",
|
||||||
|
"@vueuse/integrations": "catalog:",
|
||||||
"ant-design-vue": "catalog:",
|
"ant-design-vue": "catalog:",
|
||||||
"axios": "catalog:",
|
"axios": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"markdown-it-table": "^4.1.1",
|
"markdown-it-table": "^4.1.1",
|
||||||
"pinia": "catalog:",
|
"pinia": "catalog:",
|
||||||
|
"qrcode": "catalog:",
|
||||||
"video.js": "^8.23.4",
|
"video.js": "^8.23.4",
|
||||||
"vue": "catalog:",
|
"vue": "catalog:",
|
||||||
"vue-router": "catalog:"
|
"vue-router": "catalog:"
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
export namespace TraceabilityApi {
|
export namespace TraceabilityApi {
|
||||||
|
export interface OssStoredValue {
|
||||||
|
bucketName: string;
|
||||||
|
objectName: string;
|
||||||
|
tempUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldStyle {
|
||||||
|
bold?: boolean;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Overview {
|
export interface Overview {
|
||||||
templateCount: number;
|
templateCount: number;
|
||||||
batchCount: number;
|
batchCount: number;
|
||||||
@@ -15,9 +26,12 @@ export namespace TraceabilityApi {
|
|||||||
type: string;
|
type: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
fixedPreset?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
defaultValue?: any;
|
defaultValue?: any;
|
||||||
|
defaultPreviewUrl?: string;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
|
fieldStyle?: FieldStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TemplateNode {
|
export interface TemplateNode {
|
||||||
@@ -31,6 +45,28 @@ export namespace TraceabilityApi {
|
|||||||
fields: FieldDefinition[];
|
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 {
|
export interface TemplateSummary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -38,6 +74,7 @@ export namespace TraceabilityApi {
|
|||||||
productName: string;
|
productName: string;
|
||||||
industryName: string;
|
industryName: string;
|
||||||
coverImage: string;
|
coverImage: string;
|
||||||
|
coverImagePreviewUrl?: string;
|
||||||
themeColor: string;
|
themeColor: string;
|
||||||
status: string;
|
status: string;
|
||||||
nodeCount: number;
|
nodeCount: number;
|
||||||
@@ -49,6 +86,24 @@ export namespace TraceabilityApi {
|
|||||||
nodes: TemplateNode[];
|
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 {
|
export interface BatchStep {
|
||||||
id: string;
|
id: string;
|
||||||
templateNodeId?: string;
|
templateNodeId?: string;
|
||||||
@@ -61,6 +116,7 @@ export namespace TraceabilityApi {
|
|||||||
status: string;
|
status: string;
|
||||||
operatorName: string;
|
operatorName: string;
|
||||||
values: Record<string, any>;
|
values: Record<string, any>;
|
||||||
|
valuePreviewUrls?: Record<string, string>;
|
||||||
completedAt: string;
|
completedAt: string;
|
||||||
fields: FieldDefinition[];
|
fields: FieldDefinition[];
|
||||||
}
|
}
|
||||||
@@ -74,6 +130,7 @@ export namespace TraceabilityApi {
|
|||||||
productName: string;
|
productName: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
coverImage: string;
|
coverImage: string;
|
||||||
|
coverImagePreviewUrl?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
status: string;
|
status: string;
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
@@ -116,6 +173,18 @@ export namespace TraceabilityApi {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
size?: number;
|
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() {
|
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) {
|
export function getTraceabilityTemplate(id: string) {
|
||||||
return requestClient.get<TraceabilityApi.TemplateDetail>(
|
return requestClient.get<TraceabilityApi.TemplateDetail>(
|
||||||
`/traceability/templates/${id}`,
|
`/traceability/templates/${id}`,
|
||||||
@@ -144,7 +275,7 @@ export function updateTraceabilityTemplate(
|
|||||||
id: string,
|
id: string,
|
||||||
data: Omit<TraceabilityApi.TemplateDetail, 'batchCount' | 'id' | 'nodeCount' | 'updatedAt'>,
|
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) {
|
export function deleteTraceabilityTemplate(id: string) {
|
||||||
@@ -180,7 +311,7 @@ export function deleteTraceabilityBatch(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updateTraceabilityBatchBase(id: string, data: any) {
|
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(
|
export function updateTraceabilityBatchStep(
|
||||||
@@ -193,7 +324,7 @@ export function updateTraceabilityBatchStep(
|
|||||||
completedAt?: string;
|
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) {
|
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() {
|
export function getTraceabilityFeedbackList() {
|
||||||
return requestClient.get<TraceabilityApi.FeedbackItem[]>(
|
return requestClient.get<TraceabilityApi.FeedbackItem[]>(
|
||||||
'/traceability/feedback',
|
'/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: {
|
export function getTraceabilityUploadToken(data: {
|
||||||
bucketName?: string;
|
bucketName?: string;
|
||||||
objectName: string;
|
objectName: string;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,25 @@
|
|||||||
<script lang="ts" setup>
|
<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 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 loading = ref(false);
|
||||||
const batches = ref<TraceabilityApi.BatchSummary[]>([]);
|
const batches = ref<TraceabilityApi.BatchSummary[]>([]);
|
||||||
@@ -38,7 +48,7 @@ async function search() {
|
|||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
detail.value = await getTraceabilityPublicDetail(queryCode.value.trim());
|
detail.value = await getTraceabilityPreviewDetail(queryCode.value.trim());
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -52,10 +62,7 @@ function getStatusLabel(status: string) {
|
|||||||
return status || '进行中';
|
return status || '进行中';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFieldLabel(
|
function getFieldLabel(fields: TraceabilityApi.FieldDefinition[], key: string) {
|
||||||
fields: TraceabilityApi.FieldDefinition[],
|
|
||||||
key: string,
|
|
||||||
) {
|
|
||||||
return fields.find((field) => field.key === key)?.label || key;
|
return fields.find((field) => field.key === key)?.label || key;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +71,7 @@ function getDisplayEntries(step: TraceabilityApi.BatchStep) {
|
|||||||
key,
|
key,
|
||||||
label: getFieldLabel(step.fields, key),
|
label: getFieldLabel(step.fields, key),
|
||||||
type: step.fields.find((field) => field.key === key)?.type || 'string',
|
type: step.fields.find((field) => field.key === key)?.type || 'string',
|
||||||
|
field: step.fields.find((field) => field.key === key),
|
||||||
value,
|
value,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -145,7 +153,12 @@ onMounted(loadBatches);
|
|||||||
<span>消费者访问地址</span>
|
<span>消费者访问地址</span>
|
||||||
<strong>{{ publicLink }}</strong>
|
<strong>{{ publicLink }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<p>{{ detail.batch.summary || '该批次已完成发布,可直接用于消费者扫码访问。' }}</p>
|
<p>
|
||||||
|
{{
|
||||||
|
detail.batch.summary ||
|
||||||
|
'该批次已完成发布,可直接用于消费者扫码访问。'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="access-meta">
|
<div class="access-meta">
|
||||||
<div class="access-card">
|
<div class="access-card">
|
||||||
@@ -154,7 +167,9 @@ onMounted(loadBatches);
|
|||||||
</div>
|
</div>
|
||||||
<div class="access-card">
|
<div class="access-card">
|
||||||
<span>产品名称</span>
|
<span>产品名称</span>
|
||||||
<strong>{{ detail.batch.productName || '未设置产品名称' }}</strong>
|
<strong>{{
|
||||||
|
detail.batch.productName || '未设置产品名称'
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="access-card">
|
<div class="access-card">
|
||||||
<span>所属模板</span>
|
<span>所属模板</span>
|
||||||
@@ -162,7 +177,9 @@ onMounted(loadBatches);
|
|||||||
</div>
|
</div>
|
||||||
<div class="access-card">
|
<div class="access-card">
|
||||||
<span>标签</span>
|
<span>标签</span>
|
||||||
<strong>{{ detail.batch.tags.join('、') || '暂无标签' }}</strong>
|
<strong>{{
|
||||||
|
detail.batch.tags.join('、') || '暂无标签'
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,11 +210,13 @@ onMounted(loadBatches);
|
|||||||
<span>{{ entry.label }}</span>
|
<span>{{ entry.label }}</span>
|
||||||
<img
|
<img
|
||||||
v-if="entry.type === 'image' && entry.value"
|
v-if="entry.type === 'image' && entry.value"
|
||||||
:src="String(entry.value)"
|
:src="getImagePreviewSrc(entry.value, item.valuePreviewUrls?.[entry.key])"
|
||||||
:alt="entry.label"
|
:alt="entry.label"
|
||||||
class="consumer-image"
|
class="consumer-image"
|
||||||
/>
|
/>
|
||||||
<strong v-else>{{ formatFieldValue(entry.value) }}</strong>
|
<strong v-else :style="getFieldDisplayStyle(entry.field)">
|
||||||
|
{{ formatFieldValue(entry.value) }}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,11 +256,16 @@ onMounted(loadBatches);
|
|||||||
<span>{{ entry.label }}</span>
|
<span>{{ entry.label }}</span>
|
||||||
<img
|
<img
|
||||||
v-if="entry.type === 'image' && entry.value"
|
v-if="entry.type === 'image' && entry.value"
|
||||||
:src="String(entry.value)"
|
:src="getImagePreviewSrc(entry.value, item.valuePreviewUrls?.[entry.key])"
|
||||||
:alt="entry.label"
|
:alt="entry.label"
|
||||||
class="consumer-image"
|
class="consumer-image"
|
||||||
/>
|
/>
|
||||||
<strong v-else>{{ formatFieldValue(entry.value) }}</strong>
|
<strong
|
||||||
|
v-else
|
||||||
|
:style="getFieldDisplayStyle(entry.field)"
|
||||||
|
>
|
||||||
|
{{ formatFieldValue(entry.value) }}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,14 @@ import {
|
|||||||
} from '#/api';
|
} from '#/api';
|
||||||
import type { TraceabilityApi } 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 loading = ref(false);
|
||||||
const selectedBatchId = ref('');
|
const selectedBatchId = ref('');
|
||||||
@@ -176,6 +183,21 @@ function updateFieldValue(field: TraceabilityApi.FieldDefinition, value: any) {
|
|||||||
currentStep.value.values[field.key] = normalizeFieldInput(field, value);
|
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) {
|
function sanitizeIntegerInput(value: string) {
|
||||||
const cleaned = value.replaceAll(/[^\d-]/g, '');
|
const cleaned = value.replaceAll(/[^\d-]/g, '');
|
||||||
const hasLeadingMinus = cleaned.startsWith('-');
|
const hasLeadingMinus = cleaned.startsWith('-');
|
||||||
@@ -230,7 +252,7 @@ async function handleImageUpload(field: TraceabilityApi.FieldDefinition, event:
|
|||||||
`traceability/${selectedBatchId.value}/${currentStep.value.id}/${field.key}`,
|
`traceability/${selectedBatchId.value}/${currentStep.value.id}/${field.key}`,
|
||||||
);
|
);
|
||||||
const result = await uploadTraceabilityImage(formData);
|
const result = await uploadTraceabilityImage(formData);
|
||||||
updateFieldValue(field, result.tempUrl || result.objectName);
|
updateFieldValue(field, buildOssStoredValue(result));
|
||||||
message.success('图片上传成功');
|
message.success('图片上传成功');
|
||||||
} catch {
|
} catch {
|
||||||
message.error('图片上传失败');
|
message.error('图片上传失败');
|
||||||
@@ -241,9 +263,10 @@ async function handleImageUpload(field: TraceabilityApi.FieldDefinition, event:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeBatch(id: string) {
|
function removeBatch(id: string) {
|
||||||
|
const target = batches.value.find((item) => item.id === id);
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '删除批次',
|
title: '删除批次',
|
||||||
content: '删除后该批次的填报记录和发布信息都会一起清除,是否继续?',
|
content: `确认删除批次“${target?.batchName || '未命名批次'}”吗?删除后该批次的填报记录和发布信息都会一起清除。`,
|
||||||
async onOk() {
|
async onOk() {
|
||||||
await deleteTraceabilityBatch(id);
|
await deleteTraceabilityBatch(id);
|
||||||
message.success('批次已删除');
|
message.success('批次已删除');
|
||||||
@@ -271,7 +294,7 @@ async function saveStep() {
|
|||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
operatorName: currentStep.value.operatorName,
|
operatorName: currentStep.value.operatorName,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
values: currentStep.value.values,
|
values: buildPersistedStepValues(currentStep.value),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
applyBatch(detail);
|
applyBatch(detail);
|
||||||
@@ -439,13 +462,19 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
<div class="field-entry">
|
<div class="field-entry">
|
||||||
<div class="field-entry__head">
|
<div class="field-entry__head">
|
||||||
<label class="field-label">{{ field.label }}</label>
|
<div class="field-entry__title">
|
||||||
<span class="field-type-tag">{{ getFieldTypeLabel(field.type) }}</span>
|
<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>
|
||||||
<div class="field-entry__body">
|
<div class="field-entry__body">
|
||||||
<Select
|
<Select
|
||||||
v-if="field.type === 'select'"
|
v-if="field.type === 'select'"
|
||||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
:disabled="isFieldValueLocked(field)"
|
||||||
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
|
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
|
||||||
:value="currentStep.values[field.key]"
|
:value="currentStep.values[field.key]"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@@ -453,7 +482,7 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
v-else-if="field.type === 'multi_select'"
|
v-else-if="field.type === 'multi_select'"
|
||||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
:disabled="isFieldValueLocked(field)"
|
||||||
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
|
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
|
||||||
:value="currentStep.values[field.key]"
|
:value="currentStep.values[field.key]"
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
@@ -462,7 +491,7 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
v-else-if="field.type === 'integer'"
|
v-else-if="field.type === 'integer'"
|
||||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
:disabled="isFieldValueLocked(field)"
|
||||||
:placeholder="field.placeholder || '请输入整数'"
|
:placeholder="field.placeholder || '请输入整数'"
|
||||||
:value="String(currentStep.values[field.key] ?? '')"
|
:value="String(currentStep.values[field.key] ?? '')"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@@ -473,7 +502,7 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
v-else-if="field.type === 'decimal'"
|
v-else-if="field.type === 'decimal'"
|
||||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
:disabled="isFieldValueLocked(field)"
|
||||||
:placeholder="field.placeholder || '请输入小数'"
|
:placeholder="field.placeholder || '请输入小数'"
|
||||||
:value="String(currentStep.values[field.key] ?? '')"
|
:value="String(currentStep.values[field.key] ?? '')"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@@ -484,7 +513,7 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
:disabled="isFieldValueLocked(field)"
|
||||||
:value="currentStep.values[field.key]"
|
:value="currentStep.values[field.key]"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
@@ -492,7 +521,7 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
v-else-if="field.type === 'datetime'"
|
v-else-if="field.type === 'datetime'"
|
||||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
:disabled="isFieldValueLocked(field)"
|
||||||
:value="currentStep.values[field.key]"
|
:value="currentStep.values[field.key]"
|
||||||
format="YYYY-MM-DD HH:mm:ss"
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
show-time
|
show-time
|
||||||
@@ -502,14 +531,14 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
v-else-if="field.type === 'link'"
|
v-else-if="field.type === 'link'"
|
||||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
:disabled="isFieldValueLocked(field)"
|
||||||
:placeholder="field.placeholder || '请输入链接地址'"
|
:placeholder="field.placeholder || '请输入链接地址'"
|
||||||
:value="currentStep.values[field.key]"
|
:value="currentStep.values[field.key]"
|
||||||
@update:value="(value) => updateFieldValue(field, value)"
|
@update:value="(value) => updateFieldValue(field, value)"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
v-else-if="field.type === 'string'"
|
v-else-if="field.type === 'string'"
|
||||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
:disabled="isFieldValueLocked(field)"
|
||||||
:placeholder="field.placeholder || '请输入内容'"
|
:placeholder="field.placeholder || '请输入内容'"
|
||||||
:value="currentStep.values[field.key]"
|
:value="currentStep.values[field.key]"
|
||||||
@update:value="(value) => updateFieldValue(field, value)"
|
@update:value="(value) => updateFieldValue(field, value)"
|
||||||
@@ -525,7 +554,12 @@ onMounted(async () => {
|
|||||||
class="image-preview-wrap"
|
class="image-preview-wrap"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="String(currentStep.values[field.key])"
|
:src="
|
||||||
|
getImagePreviewSrc(
|
||||||
|
currentStep.values[field.key],
|
||||||
|
currentStep.valuePreviewUrls?.[field.key],
|
||||||
|
)
|
||||||
|
"
|
||||||
alt="节点图片"
|
alt="节点图片"
|
||||||
class="image-preview"
|
class="image-preview"
|
||||||
/>
|
/>
|
||||||
@@ -536,11 +570,11 @@ onMounted(async () => {
|
|||||||
accept="image/*"
|
accept="image/*"
|
||||||
class="upload-input"
|
class="upload-input"
|
||||||
type="file"
|
type="file"
|
||||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
:disabled="isFieldValueLocked(field)"
|
||||||
@change="(event) => handleImageUpload(field, event)"
|
@change="(event) => handleImageUpload(field, event)"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
:disabled="isFieldValueLocked(field)"
|
||||||
:loading="uploadingFieldKey === getFieldUploadKey(field)"
|
:loading="uploadingFieldKey === getFieldUploadKey(field)"
|
||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -550,7 +584,7 @@ onMounted(async () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="currentStep.values[field.key]"
|
v-if="currentStep.values[field.key]"
|
||||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
:disabled="isFieldValueLocked(field)"
|
||||||
size="small"
|
size="small"
|
||||||
@click="clearImageValue(field)"
|
@click="clearImageValue(field)"
|
||||||
>
|
>
|
||||||
@@ -568,11 +602,14 @@ onMounted(async () => {
|
|||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
v-else
|
v-else
|
||||||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||||||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
:disabled="isFieldValueLocked(field)"
|
||||||
:placeholder="field.placeholder || '请输入内容'"
|
:placeholder="field.placeholder || '请输入内容'"
|
||||||
:value="currentStep.values[field.key]"
|
:value="currentStep.values[field.key]"
|
||||||
@update:value="(value) => updateFieldValue(field, value)"
|
@update:value="(value) => updateFieldValue(field, value)"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="field.fixedPreset" class="field-fixed-tip">
|
||||||
|
当前字段已启用固定预设值,模板和批次中不可改动。
|
||||||
|
</div>
|
||||||
<div class="field-preview">
|
<div class="field-preview">
|
||||||
当前值:{{ formatFieldValue(currentStep.values[field.key]) }}
|
当前值:{{ formatFieldValue(currentStep.values[field.key]) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -650,11 +687,11 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.batch-panel-card {
|
.batch-panel-card {
|
||||||
height: 100%;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-panel-card :deep(.ant-card-body) {
|
.batch-panel-card :deep(.ant-card-body) {
|
||||||
height: calc(100% - 57px);
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-list {
|
.batch-list {
|
||||||
@@ -716,12 +753,28 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.step-strip {
|
.step-strip {
|
||||||
margin-bottom: 20px;
|
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 {
|
.step-editor {
|
||||||
border-top: 1px solid #f0f2f5;
|
border-top: 1px solid #f0f2f5;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-strip--published,
|
.step-strip--published,
|
||||||
@@ -752,7 +805,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dynamic-fields {
|
.dynamic-fields {
|
||||||
margin-top: 8px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-panel {
|
.publish-panel {
|
||||||
@@ -794,20 +847,48 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.field-entry {
|
.field-entry {
|
||||||
display: grid;
|
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 {
|
.field-entry__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
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 {
|
.field-entry__body {
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
border: 1px solid #edf1f7;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-entry__body strong {
|
.field-entry__body strong {
|
||||||
@@ -832,6 +913,16 @@ onMounted(async () => {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.step-strip :deep(.ant-steps-item) {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-entry__head {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.upload-trigger {
|
.upload-trigger {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -858,6 +949,17 @@ onMounted(async () => {
|
|||||||
.field-preview {
|
.field-preview {
|
||||||
color: #7d8899;
|
color: #7d8899;
|
||||||
font-size: 12px;
|
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 {
|
.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';
|
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 {
|
export interface TraceabilityNodeLibraryItem {
|
||||||
id: string;
|
id: string;
|
||||||
category: 'business' | 'public';
|
category: 'business' | 'public';
|
||||||
@@ -92,23 +171,33 @@ export function createField(
|
|||||||
type,
|
type,
|
||||||
required: false,
|
required: false,
|
||||||
visible: true,
|
visible: true,
|
||||||
|
fixedPreset: false,
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
options: [],
|
options: [],
|
||||||
|
fieldStyle: {
|
||||||
|
bold: false,
|
||||||
|
color: '',
|
||||||
|
},
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptyField(): TraceabilityApi.FieldDefinition {
|
export function createEmptyField(): TraceabilityApi.FieldDefinition {
|
||||||
return {
|
return {
|
||||||
key: `field_${Date.now()}`,
|
key: createLocalUniqueId('field'),
|
||||||
label: '新字段',
|
label: '新字段',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: false,
|
required: false,
|
||||||
visible: true,
|
visible: true,
|
||||||
|
fixedPreset: false,
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
options: [],
|
options: [],
|
||||||
|
fieldStyle: {
|
||||||
|
bold: false,
|
||||||
|
color: '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +205,7 @@ export function createEmptyNode(
|
|||||||
category: TraceabilityApi.TemplateNode['category'] = 'business',
|
category: TraceabilityApi.TemplateNode['category'] = 'business',
|
||||||
): TraceabilityApi.TemplateNode {
|
): TraceabilityApi.TemplateNode {
|
||||||
return {
|
return {
|
||||||
|
id: createLocalUniqueId('node'),
|
||||||
category,
|
category,
|
||||||
name: category === 'public' ? '公开资料节点' : '业务流程节点',
|
name: category === 'public' ? '公开资料节点' : '业务流程节点',
|
||||||
description: '',
|
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(
|
export function cloneNodeFromLibrary(
|
||||||
preset: TraceabilityNodeLibraryItem,
|
preset: TraceabilityNodeLibraryItem,
|
||||||
): TraceabilityApi.TemplateNode {
|
): TraceabilityApi.TemplateNode {
|
||||||
return {
|
return {
|
||||||
|
id: createLocalUniqueId('node'),
|
||||||
category: preset.category,
|
category: preset.category,
|
||||||
name: preset.name,
|
name: preset.name,
|
||||||
description: preset.description,
|
description: preset.description,
|
||||||
@@ -136,6 +242,10 @@ export function cloneNodeFromLibrary(
|
|||||||
fields: preset.fields.map((field) => ({
|
fields: preset.fields.map((field) => ({
|
||||||
...field,
|
...field,
|
||||||
options: [...(field.options ?? [])],
|
options: [...(field.options ?? [])],
|
||||||
|
fieldStyle: {
|
||||||
|
bold: field.fieldStyle?.bold ?? false,
|
||||||
|
color: field.fieldStyle?.color ?? '',
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -148,7 +258,7 @@ export function cloneTemplateForSave(
|
|||||||
description: template.description ?? '',
|
description: template.description ?? '',
|
||||||
productName: template.productName ?? '',
|
productName: template.productName ?? '',
|
||||||
industryName: template.industryName ?? '',
|
industryName: template.industryName ?? '',
|
||||||
coverImage: template.coverImage ?? '',
|
coverImage: serializeImageValue(template.coverImage ?? ''),
|
||||||
themeColor: template.themeColor ?? '#1f4fd6',
|
themeColor: template.themeColor ?? '#1f4fd6',
|
||||||
status: template.status ?? 'draft',
|
status: template.status ?? 'draft',
|
||||||
nodes: (template.nodes ?? []).map((node) => ({
|
nodes: (template.nodes ?? []).map((node) => ({
|
||||||
@@ -163,14 +273,74 @@ export function cloneTemplateForSave(
|
|||||||
type: field.type ?? 'string',
|
type: field.type ?? 'string',
|
||||||
required: field.required ?? false,
|
required: field.required ?? false,
|
||||||
visible: field.visible ?? true,
|
visible: field.visible ?? true,
|
||||||
|
fixedPreset: field.fixedPreset ?? false,
|
||||||
placeholder: field.placeholder ?? '',
|
placeholder: field.placeholder ?? '',
|
||||||
defaultValue: field.defaultValue ?? '',
|
defaultValue:
|
||||||
|
field.type === 'image'
|
||||||
|
? stripOssTempUrl(field.defaultValue ?? '')
|
||||||
|
: field.defaultValue ?? '',
|
||||||
options: field.options ?? [],
|
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) {
|
export function formatFieldValue(value: any) {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return '未填写';
|
return '未填写';
|
||||||
@@ -179,6 +349,9 @@ export function formatFieldValue(value: any) {
|
|||||||
return value.join('、');
|
return value.join('、');
|
||||||
}
|
}
|
||||||
if (typeof value === 'object') {
|
if (typeof value === 'object') {
|
||||||
|
if (isOssStoredValue(value)) {
|
||||||
|
return value.objectName || '已上传图片';
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Generated
+9
-3
@@ -145,10 +145,10 @@ catalogs:
|
|||||||
specifier: ^2.4.6
|
specifier: ^2.4.6
|
||||||
version: 2.4.6
|
version: 2.4.6
|
||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^13.4.0
|
specifier: 13.9.0
|
||||||
version: 13.9.0
|
version: 13.9.0
|
||||||
'@vueuse/integrations':
|
'@vueuse/integrations':
|
||||||
specifier: ^14.0.0
|
specifier: 14.0.0
|
||||||
version: 14.0.0
|
version: 14.0.0
|
||||||
'@vueuse/motion':
|
'@vueuse/motion':
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
@@ -361,7 +361,7 @@ catalogs:
|
|||||||
specifier: ^0.3.12
|
specifier: ^0.3.12
|
||||||
version: 0.3.15
|
version: 0.3.15
|
||||||
qrcode:
|
qrcode:
|
||||||
specifier: ^1.5.4
|
specifier: 1.5.4
|
||||||
version: 1.5.4
|
version: 1.5.4
|
||||||
qs:
|
qs:
|
||||||
specifier: ^6.14.0
|
specifier: ^6.14.0
|
||||||
@@ -687,6 +687,9 @@ importers:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 13.9.0(vue@3.5.24(typescript@5.9.3))
|
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:
|
ant-design-vue:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 4.2.6(vue@3.5.24(typescript@5.9.3))
|
version: 4.2.6(vue@3.5.24(typescript@5.9.3))
|
||||||
@@ -714,6 +717,9 @@ importers:
|
|||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.24(typescript@5.9.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:
|
video.js:
|
||||||
specifier: ^8.23.4
|
specifier: ^8.23.4
|
||||||
version: 8.23.4
|
version: 8.23.4
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ catalog:
|
|||||||
'@vue/reactivity': ^3.5.17
|
'@vue/reactivity': ^3.5.17
|
||||||
'@vue/shared': ^3.5.24
|
'@vue/shared': ^3.5.24
|
||||||
'@vue/test-utils': ^2.4.6
|
'@vue/test-utils': ^2.4.6
|
||||||
'@vueuse/core': ^13.4.0
|
'@vueuse/core': 13.9.0
|
||||||
'@vueuse/integrations': ^14.0.0
|
'@vueuse/integrations': 14.0.0
|
||||||
'@vueuse/motion': ^3.0.3
|
'@vueuse/motion': ^3.0.3
|
||||||
ant-design-vue: ^4.2.6
|
ant-design-vue: ^4.2.6
|
||||||
archiver: ^7.0.1
|
archiver: ^7.0.1
|
||||||
@@ -143,7 +143,7 @@ catalog:
|
|||||||
prettier: ^3.6.2
|
prettier: ^3.6.2
|
||||||
prettier-plugin-tailwindcss: ^0.7.1
|
prettier-plugin-tailwindcss: ^0.7.1
|
||||||
publint: ^0.3.12
|
publint: ^0.3.12
|
||||||
qrcode: ^1.5.4
|
qrcode: 1.5.4
|
||||||
qs: ^6.14.0
|
qs: ^6.14.0
|
||||||
reka-ui: ^2.6.0
|
reka-ui: ^2.6.0
|
||||||
resolve.exports: ^2.0.3
|
resolve.exports: ^2.0.3
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# push_docker.ps1
|
# push_docker.ps1
|
||||||
|
|
||||||
# Set version
|
# Set version
|
||||||
$env:VERSION = "1.5.2"
|
$env:VERSION = "1.5.3"
|
||||||
|
|
||||||
# Docker registry/repository
|
# Docker registry/repository
|
||||||
$registry = "ai.ronsunny.cn:13011/bbit_ai/ce_vue"
|
$registry = "ai.ronsunny.cn:13011/bbit_ai/ce_vue"
|
||||||
|
|||||||
Reference in New Issue
Block a user