From c4e24a29daa5c876f961bebc07cdca076df747cf Mon Sep 17 00:00:00 2001 From: BBIT-Kai <2911862937@qq.com> Date: Wed, 8 Jul 2026 10:20:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E6=94=BE=E6=BA=AF=E6=BA=90=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/controller/Traceability.kt | 37 ++ .../model/request/TraceabilityRequest.kt | 24 + .../model/response/TraceabilityResponse.kt | 10 + .../server/utils/dao/TraceabilityDao.kt | 427 ++++++++++++++++++ .../web-antd/src/views/traceability/admin.vue | 44 ++ vue2/scripts/push_docker.ps1 | 2 +- 6 files changed, 543 insertions(+), 1 deletion(-) diff --git a/ktor/src/main/kotlin/ink/snowflake/server/controller/Traceability.kt b/ktor/src/main/kotlin/ink/snowflake/server/controller/Traceability.kt index ca491b4..5e189c7 100644 --- a/ktor/src/main/kotlin/ink/snowflake/server/controller/Traceability.kt +++ b/ktor/src/main/kotlin/ink/snowflake/server/controller/Traceability.kt @@ -1,6 +1,7 @@ package ink.snowflake.server.controller import ink.snowflake.server.model.request.CreateTraceBatchRequest +import ink.snowflake.server.model.request.CreateTraceabilityPublicBatchRequest import ink.snowflake.server.model.request.SaveTraceTemplateRequest import ink.snowflake.server.model.request.SaveTraceNodeLibraryRequest import ink.snowflake.server.model.request.SaveTracePreviewPageRequest @@ -19,6 +20,7 @@ import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse import ink.snowflake.server.utils.AppConfig import ink.snowflake.server.utils.OSSUtils import ink.snowflake.server.utils.dao.TraceabilityDao +import ink.snowflake.server.utils.dao.TraceabilityPublicBatchError import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.content.* @@ -89,6 +91,23 @@ private suspend fun handleUpdateBatchStep(call: io.ktor.server.application.Appli call.respond(BaseResponse(data = data)) } +private suspend fun handleCreatePublicBatch(call: io.ktor.server.application.ApplicationCall) { + val request = call.receive() + val result = TraceabilityDao.createPublicBatch(request) + val data = result.data + if (data != null) { + call.respond(BaseResponse(message = result.message.ifBlank { "批次已创建并发布" }, data = data)) + return + } + + val statusCode = when (result.error) { + TraceabilityPublicBatchError.NOT_FOUND -> HttpStatusCode.NotFound + TraceabilityPublicBatchError.CONFLICT -> HttpStatusCode.Conflict + else -> HttpStatusCode.BadRequest + } + call.respond(statusCode, BaseResponse(status = false, message = result.message.ifBlank { "批次创建失败" }, data = null)) +} + fun Application.Traceability(config: AppConfig) { TraceabilityDao.init(config) TraceabilityDao.initSchema() @@ -318,6 +337,24 @@ fun Application.Traceability(config: AppConfig) { } } route("/public") { + route("/integration") { + post("/batches") { + handleCreatePublicBatch(call) + } + get("/batches/{id}/public-url") { + val id = parseUuidOrNull(call.parameters["id"]) + if (id == null) { + call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null)) + return@get + } + val data = TraceabilityDao.getPublicBatchUrl(id) + if (data == null) { + call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null)) + return@get + } + call.respond(BaseResponse(data = data)) + } + } get("/by-code/{code}") { val code = call.parameters["code"] ?: "" val increaseScan = call.request.queryParameters["increaseScan"] == "true" diff --git a/ktor/src/main/kotlin/ink/snowflake/server/model/request/TraceabilityRequest.kt b/ktor/src/main/kotlin/ink/snowflake/server/model/request/TraceabilityRequest.kt index 6164e25..bbb86e2 100644 --- a/ktor/src/main/kotlin/ink/snowflake/server/model/request/TraceabilityRequest.kt +++ b/ktor/src/main/kotlin/ink/snowflake/server/model/request/TraceabilityRequest.kt @@ -93,6 +93,30 @@ data class CreateTraceBatchRequest( val tags: List = emptyList(), ) +@Serializable +data class CreateTraceabilityPublicBatchRequest( + val templateId: String, + val batchName: String, + val batchCode: String, + val productName: String = "", + val summary: String = "", + val coverImage: String = "", + val tags: List = emptyList(), + val overwrite: Boolean = false, + val operatorName: String = "", + val completedAt: String? = null, + val steps: List = emptyList(), +) + +@Serializable +data class TraceabilityPublicBatchStepRequest( + val templateNodeId: String? = null, + val nodeName: String? = null, + val operatorName: String = "", + val completedAt: String? = null, + val values: JsonObject = JsonObject(emptyMap()), +) + @Serializable data class UpdateTraceBatchBaseRequest( val batchName: String, diff --git a/ktor/src/main/kotlin/ink/snowflake/server/model/response/TraceabilityResponse.kt b/ktor/src/main/kotlin/ink/snowflake/server/model/response/TraceabilityResponse.kt index ffe55a0..48ad032 100644 --- a/ktor/src/main/kotlin/ink/snowflake/server/model/response/TraceabilityResponse.kt +++ b/ktor/src/main/kotlin/ink/snowflake/server/model/response/TraceabilityResponse.kt @@ -195,6 +195,16 @@ data class TraceBatchDetailResponse( val publishedAt: String = "", ) +@Serializable +data class TraceabilityPublicBatchResponse( + val batchId: String, + val batchCode: String, + val publicUrl: String, + val status: String, + val publishedAt: String = "", + val overwritten: Boolean = false, +) + @Serializable data class TraceabilityFeedbackResponse( val id: String, diff --git a/ktor/src/main/kotlin/ink/snowflake/server/utils/dao/TraceabilityDao.kt b/ktor/src/main/kotlin/ink/snowflake/server/utils/dao/TraceabilityDao.kt index eb9b4d2..a99c39d 100644 --- a/ktor/src/main/kotlin/ink/snowflake/server/utils/dao/TraceabilityDao.kt +++ b/ktor/src/main/kotlin/ink/snowflake/server/utils/dao/TraceabilityDao.kt @@ -10,11 +10,13 @@ import ink.snowflake.server.model.database.TraceabilityPreviewPagesTable import ink.snowflake.server.model.database.TraceabilityTemplateNodesTable import ink.snowflake.server.model.database.TraceabilityTemplatesTable import ink.snowflake.server.model.request.CreateTraceBatchRequest +import ink.snowflake.server.model.request.CreateTraceabilityPublicBatchRequest import ink.snowflake.server.model.request.SaveTraceTemplateRequest import ink.snowflake.server.model.request.SaveTraceNodeLibraryRequest import ink.snowflake.server.model.request.SaveTracePreviewPageRequest import ink.snowflake.server.model.request.SubmitTraceabilityFeedbackRequest import ink.snowflake.server.model.request.TraceFieldDefinitionRequest +import ink.snowflake.server.model.request.TraceabilityPublicBatchStepRequest import ink.snowflake.server.model.request.TracePreviewNodeRequest import ink.snowflake.server.model.request.UpdateTraceBatchBaseRequest import ink.snowflake.server.model.request.UpdateTraceBatchStepRequest @@ -34,18 +36,21 @@ import ink.snowflake.server.model.response.TraceTemplateSummaryResponse import ink.snowflake.server.model.response.TraceabilityFeedbackResponse import ink.snowflake.server.model.response.TraceabilityFileAssetResponse import ink.snowflake.server.model.response.TraceabilityOverviewResponse +import ink.snowflake.server.model.response.TraceabilityPublicBatchResponse import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse import ink.snowflake.server.utils.AppConfig import ink.snowflake.server.utils.OSSUtils import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import org.jetbrains.exposed.v1.core.SortOrder @@ -58,15 +63,48 @@ import org.jetbrains.exposed.v1.jdbc.insertAndGetId import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.update +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter import java.util.UUID +enum class TraceabilityPublicBatchError { + BAD_REQUEST, + NOT_FOUND, + CONFLICT, +} + +data class TraceabilityPublicBatchOperationResult( + val data: TraceabilityPublicBatchResponse? = null, + val message: String = "", + val error: TraceabilityPublicBatchError? = null, +) + object TraceabilityDao { private val json = Json { ignoreUnknownKeys = true encodeDefaults = true } + private val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") private var publicPreviewBaseUrl: String = "http://127.0.0.1:8081" + private data class PreparedPublicBatchStep( + val node: TraceTemplateNodeResponse, + val values: JsonObject, + val operatorName: String, + val completedAt: Instant, + ) + + private data class PreparedPublicBatchStepsResult( + val steps: List = emptyList(), + val error: TraceabilityPublicBatchOperationResult? = null, + ) + + private data class FieldNormalizationResult( + val value: JsonElement? = null, + val error: String = "", + ) + fun init(config: AppConfig) { publicPreviewBaseUrl = config.traceabilityPublicPreviewBaseUrl } @@ -705,6 +743,111 @@ object TraceabilityDao { getBatch(batchId) } + fun createPublicBatch(request: CreateTraceabilityPublicBatchRequest): TraceabilityPublicBatchOperationResult = transaction { + val templateId = runCatching { UUID.fromString(request.templateId) }.getOrNull() + ?: return@transaction publicBatchBadRequest("模板ID无效") + val template = getTemplate(templateId) + ?: return@transaction publicBatchNotFound("模板不存在") + + if (template.status != "active") { + return@transaction publicBatchBadRequest("模板未发布,请先发布模板后再通过接口创建批次") + } + + val batchCode = request.batchCode.trim() + val batchName = request.batchName.trim() + if (batchCode.isBlank()) { + return@transaction publicBatchBadRequest("batchCode 不能为空") + } + if (batchName.isBlank()) { + return@transaction publicBatchBadRequest("batchName 不能为空") + } + + val nowInstant = nowInstant() + val preparedSteps = preparePublicBatchSteps(template, request, nowInstant) + if (preparedSteps.error != null) { + return@transaction preparedSteps.error + } + val steps = preparedSteps.steps + + val existingBatch = TraceabilityBatchesTable.selectAll() + .where { TraceabilityBatchesTable.batchCode eq batchCode } + .singleOrNull() + val overwritten = existingBatch != null + if (existingBatch != null && !request.overwrite) { + return@transaction TraceabilityPublicBatchOperationResult( + message = "批次编码已存在,如需覆盖请传 overwrite=true", + error = TraceabilityPublicBatchError.CONFLICT, + ) + } + + val now = timestampLiteral(nowInstant) + val tagsJson = json.encodeToString(request.tags.map { it.trim() }.filter { it.isNotBlank() }) + val batchId = if (existingBatch == null) { + TraceabilityBatchesTable.insertAndGetId { + it[this.templateId] = templateId + it[this.batchName] = batchName + it[this.batchCode] = batchCode + it[productName] = request.productName.trim() + it[summary] = request.summary + it[coverImage] = request.coverImage.ifBlank { template.coverImage } + it[this.tagsJson] = tagsJson + it[status] = "published" + it[currentStep] = (steps.size - 1).coerceAtLeast(0) + it[publishedAt] = now + it[createdAt] = now + it[updatedAt] = now + }.value + } else { + val currentBatchId = existingBatch[TraceabilityBatchesTable.id].value + TraceabilityBatchStepsTable.deleteWhere { TraceabilityBatchStepsTable.batchId eq currentBatchId } + TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq currentBatchId }) { + it[this.templateId] = templateId + it[this.batchName] = batchName + it[this.batchCode] = batchCode + it[productName] = request.productName.trim() + it[summary] = request.summary + it[coverImage] = request.coverImage.ifBlank { template.coverImage } + it[this.tagsJson] = tagsJson + it[status] = "published" + it[currentStep] = (steps.size - 1).coerceAtLeast(0) + it[publishedAt] = now + it[updatedAt] = now + } + currentBatchId + } + + steps.forEach { step -> + TraceabilityBatchStepsTable.insertAndGetId { + it[this.batchId] = batchId + it[this.templateNodeId] = UUID.fromString(step.node.id) + it[sort] = step.node.sort + it[category] = step.node.category + it[name] = step.node.name + it[description] = step.node.description + it[locked] = step.node.locked + it[consumerVisible] = step.node.consumerVisible + it[fieldsJson] = json.encodeToString(step.node.fields) + it[status] = "completed" + it[operatorName] = step.operatorName + it[valuesJson] = json.encodeToString(step.values) + it[completedAt] = timestampLiteral(step.completedAt) + it[createdAt] = now + it[updatedAt] = now + } + } + + val batch = getBatch(batchId) + ?: return@transaction publicBatchNotFound("批次创建失败") + TraceabilityPublicBatchOperationResult( + message = if (overwritten) "批次已覆盖并发布" else "批次已创建并发布", + data = batch.toPublicBatchResponse(overwritten), + ) + } + + fun getPublicBatchUrl(batchId: UUID): TraceabilityPublicBatchResponse? = transaction { + getBatch(batchId)?.toPublicBatchResponse() + } + fun updateBatchBase(batchId: UUID, request: UpdateTraceBatchBaseRequest): TraceBatchDetailResponse? = transaction { val now = timestampLiteral(nowInstant()) val updated = TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchId }) { @@ -894,6 +1037,290 @@ object TraceabilityDao { listFeedback().firstOrNull { it.id == feedbackId.toString() } } + private fun preparePublicBatchSteps( + template: TraceTemplateDetailResponse, + request: CreateTraceabilityPublicBatchRequest, + now: Instant, + ): PreparedPublicBatchStepsResult { + val requestByNodeId = mutableMapOf() + request.steps.forEachIndexed { index, stepRequest -> + val node = resolveTemplateNode(template.nodes, stepRequest) + ?: return PreparedPublicBatchStepsResult( + error = publicBatchBadRequest("steps[$index] 未匹配到模板节点,请传 templateNodeId 或唯一 nodeName"), + ) + if (requestByNodeId.put(node.id, stepRequest) != null) { + return PreparedPublicBatchStepsResult( + error = publicBatchBadRequest("节点“${node.name}”重复传入"), + ) + } + } + + val steps = template.nodes.map { node -> + val stepRequest = requestByNodeId[node.id] + val values = normalizePublicBatchStepValues(node, stepRequest?.values ?: JsonObject(emptyMap())) + if (values.error.isNotBlank()) { + return PreparedPublicBatchStepsResult(error = publicBatchBadRequest(values.error)) + } + val completedAt = parsePublicBatchCompletedAt(stepRequest?.completedAt ?: request.completedAt, now) + ?: return PreparedPublicBatchStepsResult( + error = publicBatchBadRequest("节点“${node.name}”的 completedAt 格式无效,请使用 ISO-8601 时间"), + ) + PreparedPublicBatchStep( + node = node, + values = values.value as JsonObject, + operatorName = stepRequest?.operatorName?.trim()?.takeIf { it.isNotBlank() } + ?: request.operatorName.trim(), + completedAt = completedAt, + ) + } + return PreparedPublicBatchStepsResult(steps = steps) + } + + private fun resolveTemplateNode( + nodes: List, + request: TraceabilityPublicBatchStepRequest, + ): TraceTemplateNodeResponse? { + val nodeId = request.templateNodeId?.trim().orEmpty() + if (nodeId.isNotBlank()) { + return nodes.find { it.id == nodeId } + } + + val nodeName = request.nodeName?.trim().orEmpty() + if (nodeName.isBlank()) { + return null + } + val matched = nodes.filter { it.name == nodeName } + return matched.singleOrNull() + } + + private fun normalizePublicBatchStepValues( + node: TraceTemplateNodeResponse, + providedValues: JsonObject, + ): FieldNormalizationResult { + val fieldByKey = node.fields.associateBy { it.key } + if (fieldByKey.size != node.fields.size) { + return FieldNormalizationResult(error = "节点“${node.name}”存在重复字段 Key,无法通过接口写入") + } + + providedValues.keys.forEach { key -> + if (!fieldByKey.containsKey(key)) { + return FieldNormalizationResult(error = "节点“${node.name}”不包含字段“$key”") + } + } + + val values = linkedMapOf() + node.fields.forEach { field -> + val hasProvidedValue = providedValues.containsKey(field.key) + val defaultValue = field.defaultValue ?: JsonNull + val normalized = if (field.fixedPreset) { + val normalizedDefault = normalizePublicFieldValue(field, defaultValue) + if (normalizedDefault.error.isNotBlank()) { + return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”的固定预设值无效:${normalizedDefault.error}") + } + if (hasProvidedValue) { + val normalizedProvided = normalizePublicFieldValue(field, providedValues[field.key] ?: JsonNull) + if (normalizedProvided.error.isNotBlank()) { + return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”无效:${normalizedProvided.error}") + } + if (normalizedProvided.value != normalizedDefault.value) { + return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”为固定预设值,不允许覆盖") + } + } + normalizedDefault + } else { + normalizePublicFieldValue(field, providedValues[field.key] ?: defaultValue) + } + + if (normalized.error.isNotBlank()) { + return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”无效:${normalized.error}") + } + if (field.required && isBlankJsonValue(normalized.value)) { + return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”为必填") + } + values[field.key] = normalized.value ?: JsonNull + } + return FieldNormalizationResult(value = JsonObject(values)) + } + + private fun normalizePublicFieldValue( + field: TraceFieldDefinitionResponse, + value: JsonElement, + ): FieldNormalizationResult { + if (value == JsonNull) { + return when (field.type) { + "multi_select" -> FieldNormalizationResult(JsonArray(emptyList())) + else -> FieldNormalizationResult(JsonNull) + } + } + + return when (field.type) { + "integer" -> normalizeIntegerValue(value) + "decimal" -> normalizeDecimalValue(value) + "date" -> normalizeDateValue(value) + "datetime" -> normalizeDateTimeValue(value) + "coordinate" -> normalizeCoordinateValue(value) + "select" -> normalizeSelectValue(field, value) + "multi_select" -> normalizeMultiSelectValue(field, value) + "image" -> normalizeImageValue(value) + "link", "video_url" -> normalizeUrlValue(value) + "json" -> FieldNormalizationResult(value) + else -> normalizeTextValue(value) + } + } + + private fun normalizeIntegerValue(value: JsonElement): FieldNormalizationResult { + val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是整数") + if (text.isBlank()) return FieldNormalizationResult(JsonNull) + val parsed = text.toLongOrNull() ?: return FieldNormalizationResult(error = "必须是整数") + return FieldNormalizationResult(JsonPrimitive(parsed)) + } + + private fun normalizeDecimalValue(value: JsonElement): FieldNormalizationResult { + val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是小数") + if (text.isBlank()) return FieldNormalizationResult(JsonNull) + val parsed = text.toDoubleOrNull() ?: return FieldNormalizationResult(error = "必须是小数") + return FieldNormalizationResult(JsonPrimitive(parsed)) + } + + private fun normalizeDateValue(value: JsonElement): FieldNormalizationResult { + val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是日期字符串") + if (text.isBlank()) return FieldNormalizationResult(JsonNull) + val parsed = runCatching { LocalDate.parse(text) }.getOrNull() + ?: return FieldNormalizationResult(error = "日期格式应为 yyyy-MM-dd") + return FieldNormalizationResult(JsonPrimitive(parsed.toString())) + } + + private fun normalizeDateTimeValue(value: JsonElement): FieldNormalizationResult { + val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是日期时间字符串") + if (text.isBlank()) return FieldNormalizationResult(JsonNull) + val localDateTime = runCatching { LocalDateTime.parse(text.replace(' ', 'T')) }.getOrNull() + if (localDateTime != null) { + return FieldNormalizationResult(JsonPrimitive(localDateTime.format(dateTimeFormatter))) + } + val instant = runCatching { Instant.parse(text) }.getOrNull() + ?: return FieldNormalizationResult(error = "日期时间格式应为 yyyy-MM-dd HH:mm:ss 或 ISO-8601") + return FieldNormalizationResult(JsonPrimitive(instant.toString())) + } + + private fun normalizeCoordinateValue(value: JsonElement): FieldNormalizationResult { + val coordinate = value as? JsonObject ?: return FieldNormalizationResult(error = "必须是坐标对象") + val lng = primitiveText(coordinate["lng"] ?: JsonNull)?.toDoubleOrNull() + val lat = primitiveText(coordinate["lat"] ?: JsonNull)?.toDoubleOrNull() + if (lng == null || lat == null) { + return FieldNormalizationResult(error = "坐标必须包含数值 lng 和 lat") + } + return FieldNormalizationResult( + buildJsonObject { + put("lng", lng) + put("lat", lat) + primitiveText(coordinate["address"] ?: JsonNull)?.takeIf { it.isNotBlank() }?.let { + put("address", it) + } + primitiveText(coordinate["source"] ?: JsonNull)?.takeIf { it.isNotBlank() }?.let { + put("source", it) + } + }, + ) + } + + private fun normalizeSelectValue( + field: TraceFieldDefinitionResponse, + value: JsonElement, + ): FieldNormalizationResult { + val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是字符串") + if (text.isBlank()) return FieldNormalizationResult(JsonNull) + if (field.options.isNotEmpty() && text !in field.options) { + return FieldNormalizationResult(error = "必须是可选项之一:${field.options.joinToString("、")}") + } + return FieldNormalizationResult(JsonPrimitive(text)) + } + + private fun normalizeMultiSelectValue( + field: TraceFieldDefinitionResponse, + value: JsonElement, + ): FieldNormalizationResult { + val array = value as? JsonArray ?: return FieldNormalizationResult(error = "必须是字符串数组") + val items = array.mapIndexed { index, item -> + primitiveText(item)?.takeIf { it.isNotBlank() } + ?: return FieldNormalizationResult(error = "第 ${index + 1} 项必须是非空字符串") + } + val invalid = if (field.options.isEmpty()) emptyList() else items.filter { it !in field.options } + if (invalid.isNotEmpty()) { + return FieldNormalizationResult(error = "包含非法选项:${invalid.joinToString("、")}") + } + return FieldNormalizationResult(JsonArray(items.map(::JsonPrimitive))) + } + + private fun normalizeImageValue(value: JsonElement): FieldNormalizationResult { + if (value is JsonPrimitive) { + val text = value.contentOrNull?.trim().orEmpty() + return FieldNormalizationResult(if (text.isBlank()) JsonNull else JsonPrimitive(text)) + } + val image = value as? JsonObject ?: return FieldNormalizationResult(error = "必须是图片地址字符串或 OSS 对象") + val objectName = primitiveText(image["objectName"] ?: JsonNull).orEmpty() + if (objectName.isBlank()) { + return FieldNormalizationResult(error = "OSS 图片对象必须包含 objectName") + } + val bucketName = primitiveText(image["bucketName"] ?: JsonNull).orEmpty().ifBlank { OSSUtils.defaultBucket() } + return FieldNormalizationResult( + buildJsonObject { + put("bucketName", bucketName) + put("objectName", objectName) + }, + ) + } + + private fun normalizeUrlValue(value: JsonElement): FieldNormalizationResult { + val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是链接字符串") + if (text.isBlank()) return FieldNormalizationResult(JsonNull) + if (!text.startsWith("http://") && !text.startsWith("https://")) { + return FieldNormalizationResult(error = "链接必须以 http:// 或 https:// 开头") + } + return FieldNormalizationResult(JsonPrimitive(text)) + } + + private fun normalizeTextValue(value: JsonElement): FieldNormalizationResult { + val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是字符串") + return FieldNormalizationResult(JsonPrimitive(text)) + } + + private fun primitiveText(value: JsonElement): String? { + if (value == JsonNull) return null + return (value as? JsonPrimitive)?.contentOrNull?.trim() + } + + private fun isBlankJsonValue(value: JsonElement?): Boolean = when (value) { + null, JsonNull -> true + is JsonPrimitive -> value.contentOrNull?.trim().isNullOrEmpty() + is JsonArray -> value.isEmpty() + is JsonObject -> value.isEmpty() + else -> false + } + + private fun parsePublicBatchCompletedAt(raw: String?, fallback: Instant): Instant? { + val text = raw?.trim().orEmpty() + if (text.isBlank()) { + return fallback + } + return runCatching { Instant.parse(text) }.getOrNull() + } + + private fun publicBatchBadRequest(message: String): TraceabilityPublicBatchOperationResult = + TraceabilityPublicBatchOperationResult(message = message, error = TraceabilityPublicBatchError.BAD_REQUEST) + + private fun publicBatchNotFound(message: String): TraceabilityPublicBatchOperationResult = + TraceabilityPublicBatchOperationResult(message = message, error = TraceabilityPublicBatchError.NOT_FOUND) + + private fun TraceBatchDetailResponse.toPublicBatchResponse(overwritten: Boolean = false): TraceabilityPublicBatchResponse = + TraceabilityPublicBatchResponse( + batchId = id, + batchCode = batchCode, + publicUrl = publicUrl, + status = status, + publishedAt = publishedAt, + overwritten = overwritten, + ) + private fun loadTemplateNodes(templateId: UUID): List { return TraceabilityTemplateNodesTable.selectAll() .where { TraceabilityTemplateNodesTable.templateId eq templateId } diff --git a/vue2/apps/web-antd/src/views/traceability/admin.vue b/vue2/apps/web-antd/src/views/traceability/admin.vue index 35decd3..6caea75 100644 --- a/vue2/apps/web-antd/src/views/traceability/admin.vue +++ b/vue2/apps/web-antd/src/views/traceability/admin.vue @@ -456,6 +456,15 @@ async function copyTemplate(id: string) { } } +async function copyTemplateId(id: string) { + try { + await navigator.clipboard.writeText(id); + message.success('模板ID已复制'); + } catch { + message.warning('当前浏览器不支持自动复制,请手动复制模板ID'); + } +} + async function createLibraryNode(category: 'business' | 'public') { const node: EditableTemplateNode = { ...createEmptyNode(category), @@ -1040,6 +1049,13 @@ onMounted(async () => { {{ item.nodeCount }} 节点 {{ item.batchCount }} 批次 +
+ 模板ID + {{ item.id }} + +

{{ item.remark || '暂无备注' }}

@@ -2816,6 +2832,34 @@ onMounted(async () => { margin: 8px 0 6px; } +.template-card__id { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + margin: 8px 0; + padding: 8px 10px; + border: 1px solid #dbe7ff; + border-radius: 10px; + background: #f3f7ff; +} + +.template-card__id code { + min-width: 0; + overflow: hidden; + color: #1f4fd6; + font-size: 12px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.template-card__id :deep(.ant-btn) { + height: auto; + padding: 0; + font-size: 12px; +} + .template-card__remark { margin: 6px 0 0; color: #556070; diff --git a/vue2/scripts/push_docker.ps1 b/vue2/scripts/push_docker.ps1 index e0aaef5..db3520a 100644 --- a/vue2/scripts/push_docker.ps1 +++ b/vue2/scripts/push_docker.ps1 @@ -1,7 +1,7 @@ # push_docker.ps1 # Set version -$env:VERSION = "1.5.6" +$env:VERSION = "1.5.7" # Docker registry/repository $registry = "docker.bbitcn.net/bbit_ai/ce_vue"