溯源系统初版

This commit is contained in:
BBIT-Kai
2026-04-10 18:51:00 +08:00
parent 5971791038
commit 0a43f5e4b9
40 changed files with 7910 additions and 30 deletions
+36
View File
@@ -0,0 +1,36 @@
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
+41
View File
@@ -0,0 +1,41 @@
val kotlin_version: String by project
val logback_version: String by project
plugins {
kotlin("jvm") version "2.3.0"
id("io.ktor.plugin") version "3.4.2"
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.0"
}
group = "com.bbitcn"
version = "0.0.1"
application {
mainClass = "io.ktor.server.netty.EngineMain"
}
kotlin {
jvmToolchain(21)
}
dependencies {
implementation("io.ktor:ktor-server-cors")
implementation("io.ktor:ktor-server-default-headers")
implementation("io.ktor:ktor-server-core")
implementation("io.ktor:ktor-server-host-common")
implementation("io.ktor:ktor-server-status-pages")
implementation("io.ktor:ktor-server-compression")
implementation("io.ktor:ktor-server-caching-headers")
implementation("io.ktor:ktor-server-content-negotiation")
implementation("io.ktor:ktor-serialization-kotlinx-json")
implementation("io.ktor:ktor-server-freemarker")
implementation("io.ktor:ktor-client-core")
implementation("io.ktor:ktor-client-cio")
implementation("io.ktor:ktor-client-content-negotiation")
implementation("io.ktor:ktor-client-logging")
implementation("io.ktor:ktor-server-netty")
implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.ktor:ktor-server-config-yaml")
testImplementation("io.ktor:ktor-server-test-host")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
+4
View File
@@ -0,0 +1,4 @@
kotlin.code.style=official
kotlin_version=2.3.0
ktor_version=3.4.2
logback_version=1.4.14
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.3.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+7
View File
@@ -0,0 +1,7 @@
rootProject.name = "f10"
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
+24
View File
@@ -0,0 +1,24 @@
package com.bbitcn
import io.ktor.server.application.*
fun main(args: Array<String>) {
io.ktor.server.netty.EngineMain.main(args)
}
fun Application.module() {
val traceabilityConfig = environment.config.toTraceabilityPublicConfig()
val traceabilityClient = TraceabilityClient(traceabilityConfig.coreBaseUrl)
val traceabilityService = TraceabilityService(traceabilityConfig, traceabilityClient)
monitor.subscribe(ApplicationStopped) {
traceabilityClient.close()
}
attributes.put(TraceabilityAttributes.ServiceKey, traceabilityService)
configureHTTP()
configureSerialization()
configureTemplating()
configureRouting()
}
+27
View File
@@ -0,0 +1,27 @@
package com.bbitcn
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.cachingheaders.CachingHeaders
import io.ktor.server.plugins.compression.Compression
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.plugins.defaultheaders.DefaultHeaders
fun Application.configureHTTP() {
install(CORS) {
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowHeader(HttpHeaders.ContentType)
anyHost()
}
install(DefaultHeaders) {
header("X-Service", "traceability-public")
}
install(Compression)
install(CachingHeaders)
}
+104
View File
@@ -0,0 +1,104 @@
package com.bbitcn
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.application.call
import io.ktor.server.freemarker.FreeMarkerContent
import io.ktor.server.http.content.staticResources
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.request.receiveParameters
import io.ktor.server.response.respond
import io.ktor.server.response.respondRedirect
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import io.ktor.util.AttributeKey
object TraceabilityAttributes {
val ServiceKey = AttributeKey<TraceabilityService>("traceability.service")
}
fun Application.configureRouting() {
install(StatusPages) {
exception<Throwable> { call, cause ->
this@configureRouting.environment.log.error("Public page error", cause)
call.respondText("服务异常,请稍后重试", status = HttpStatusCode.InternalServerError)
}
}
routing {
get("/") {
call.respondText("traceability public server ok")
}
get("/health") {
call.respond(mapOf("status" to "ok"))
}
get("/p/{code}") {
val code = call.parameters["code"]?.trim().orEmpty()
if (code.isBlank()) {
call.respondText("批次编码不能为空", status = HttpStatusCode.BadRequest)
return@get
}
val page = call.traceabilityService().loadPage(code)
if (page == null) {
call.respond(
HttpStatusCode.NotFound,
FreeMarkerContent(
"error.ftl",
mapOf("message" to "未找到对应的溯源批次,请确认二维码或编码是否正确。"),
),
)
return@get
}
val result = call.request.queryParameters["result"].orEmpty()
val message = when (result) {
"success" -> "反馈已提交,感谢你的建议。"
"failed" -> "提交失败,请稍后再试。"
else -> ""
}
call.respond(
FreeMarkerContent(
"traceability.ftl",
mapOf(
"page" to page,
"feedbackMessage" to message,
),
),
)
}
post("/feedback") {
val params = call.receiveParameters()
val code = params["batchCode"]?.trim().orEmpty()
val content = params["content"]?.trim().orEmpty()
if (code.isBlank() || content.isBlank()) {
call.respondText("批次编码和反馈内容不能为空", status = HttpStatusCode.BadRequest)
return@post
}
val response = call.traceabilityService().submitFeedback(
code = code,
type = params["type"].orEmpty(),
contact = params["contact"].orEmpty(),
content = content,
rating = params["rating"]?.toIntOrNull() ?: 5,
)
val result = if (response.status) "success" else "failed"
call.respondRedirect("/p/$code?result=$result")
}
staticResources("/static", "static")
}
}
private fun ApplicationCall.traceabilityService(): TraceabilityService {
return application.attributes[TraceabilityAttributes.ServiceKey]
}
+18
View File
@@ -0,0 +1,18 @@
package com.bbitcn
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import kotlinx.serialization.json.Json
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
encodeDefaults = true
},
)
}
}
+12
View File
@@ -0,0 +1,12 @@
package com.bbitcn
import freemarker.cache.ClassTemplateLoader
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.freemarker.FreeMarker
fun Application.configureTemplating() {
install(FreeMarker) {
templateLoader = ClassTemplateLoader(this::class.java.classLoader, "templates")
}
}
+75
View File
@@ -0,0 +1,75 @@
package com.bbitcn
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.isSuccess
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
class TraceabilityClient(
private val coreBaseUrl: String,
) {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(json)
}
install(Logging) {
level = LogLevel.INFO
}
}
suspend fun fetchPublicDetail(
code: String,
increaseScan: Boolean,
): TraceabilityPublicDetailResponse? {
val response = client.get {
url("$coreBaseUrl/traceability/public/by-code/$code")
parameter("increaseScan", increaseScan)
accept(ContentType.Application.Json)
}
if (!response.status.isSuccess()) {
return null
}
val payload = response.body<ApiResponse<TraceabilityPublicDetailResponse>>()
return payload.data
}
suspend fun submitFeedback(request: SubmitTraceabilityFeedbackRequest): ApiResponse<TraceabilityFeedbackResponse> {
val response = client.post {
url("$coreBaseUrl/traceability/public/feedback")
contentType(ContentType.Application.Json)
setBody(request)
accept(ContentType.Application.Json)
}
if (!response.status.isSuccess()) {
return ApiResponse(
status = false,
message = response.bodyAsText(),
data = null,
)
}
return response.body()
}
fun close() {
client.close()
}
}
+130
View File
@@ -0,0 +1,130 @@
package com.bbitcn
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@Serializable
data class ApiResponse<T>(
val status: Boolean = true,
val message: String = "",
val data: T? = null,
)
@Serializable
data class TraceFieldDefinitionResponse(
val key: String,
val label: String,
val type: String = "string",
val required: Boolean = false,
val visible: Boolean = true,
val placeholder: String = "",
val defaultValue: JsonElement? = null,
val options: List<String> = emptyList(),
)
@Serializable
data class TraceBatchStepResponse(
val id: String,
val templateNodeId: String? = null,
val sort: Int,
val category: String,
val name: String,
val description: String,
val consumerVisible: Boolean,
val status: String,
val operatorName: String,
val values: JsonObject,
val completedAt: String = "",
val fields: List<TraceFieldDefinitionResponse> = emptyList(),
)
@Serializable
data class TraceBatchDetailResponse(
val id: String,
val templateId: String,
val templateName: String,
val batchName: String,
val batchCode: String,
val productName: String,
val summary: String,
val coverImage: String,
val tags: List<String>,
val status: String,
val currentStep: Int,
val scanCount: Int,
val publicUrl: String,
val steps: List<TraceBatchStepResponse>,
val updatedAt: String,
val publishedAt: String = "",
)
@Serializable
data class TraceabilityPublicDetailResponse(
val batch: TraceBatchDetailResponse,
val companySectionTitle: String = "企业公开资料",
val publicSections: List<TraceBatchStepResponse>,
val businessSections: List<TraceBatchStepResponse>,
)
@Serializable
data class SubmitTraceabilityFeedbackRequest(
val batchCode: String? = null,
val batchId: String? = null,
val type: String = "suggestion",
val contact: String = "",
val content: String,
val source: String = "public",
val rating: Int = 5,
)
@Serializable
data class TraceabilityFeedbackResponse(
val id: String,
val batchId: String,
val batchCode: String,
val batchName: String,
val type: String,
val contact: String,
val content: String,
val source: String,
val rating: Int,
val createdAt: String,
)
data class DisplayEntry(
val label: String,
val value: String,
val type: String = "string",
)
data class PublicSectionView(
val id: String,
val name: String,
val description: String,
val entries: List<DisplayEntry>,
)
data class TimelineSectionView(
val id: String,
val name: String,
val description: String,
val status: String,
val completedAt: String,
val entries: List<DisplayEntry>,
)
data class PageViewModel(
val code: String,
val pageUrl: String,
val batchName: String,
val productName: String,
val templateName: String,
val summary: String,
val coverImage: String,
val scanCount: Int,
val publishedAt: String,
val tagsText: String,
val publicSections: List<PublicSectionView>,
val businessSections: List<TimelineSectionView>,
)
+111
View File
@@ -0,0 +1,111 @@
package com.bbitcn
import io.ktor.server.config.ApplicationConfig
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
data class TraceabilityPublicConfig(
val coreBaseUrl: String,
val publicBaseUrl: String,
)
class TraceabilityService(
private val config: TraceabilityPublicConfig,
private val client: TraceabilityClient,
) {
suspend fun loadPage(code: String): PageViewModel? {
val detail = client.fetchPublicDetail(code, increaseScan = true) ?: return null
val batch = detail.batch
return PageViewModel(
code = batch.batchCode,
pageUrl = "${config.publicBaseUrl.trimEnd('/')}/p/${batch.batchCode}",
batchName = batch.batchName,
productName = batch.productName.ifBlank { batch.templateName },
templateName = batch.templateName,
summary = batch.summary.ifBlank { "该批次已完成关键环节留痕,可查看公开资料与业务流程。" },
coverImage = batch.coverImage,
scanCount = batch.scanCount,
publishedAt = formatDateOnly(batch.publishedAt),
tagsText = batch.tags.joinToString("").ifBlank { "暂无标签" },
publicSections = detail.publicSections.map(::toPublicSectionView),
businessSections = detail.businessSections.map(::toTimelineSectionView),
)
}
suspend fun submitFeedback(
code: String,
type: String,
contact: String,
content: String,
rating: Int,
): ApiResponse<TraceabilityFeedbackResponse> {
val normalizedType = when (type) {
"complaint", "consult", "suggestion" -> type
else -> "suggestion"
}
return client.submitFeedback(
SubmitTraceabilityFeedbackRequest(
batchCode = code,
type = normalizedType,
contact = contact.trim(),
content = content.trim(),
rating = rating.coerceIn(1, 5),
source = "public",
),
)
}
private fun toPublicSectionView(step: TraceBatchStepResponse): PublicSectionView {
return PublicSectionView(
id = step.id,
name = step.name,
description = step.description.ifBlank { "公开展示资料" },
entries = toDisplayEntries(step),
)
}
private fun toTimelineSectionView(step: TraceBatchStepResponse): TimelineSectionView {
return TimelineSectionView(
id = step.id,
name = step.name,
description = step.description.ifBlank { "流程记录" },
status = step.status,
completedAt = formatDateOnly(step.completedAt),
entries = toDisplayEntries(step),
)
}
private fun toDisplayEntries(step: TraceBatchStepResponse): List<DisplayEntry> {
return step.values.entries.map { (key, value) ->
val field = step.fields.find { it.key == key }
DisplayEntry(
label = field?.label ?: key,
value = formatJsonValue(value),
type = field?.type ?: "string",
)
}
}
private fun formatJsonValue(value: JsonElement): String = when (value) {
is JsonArray -> value.joinToString("") { formatJsonValue(it) }
is JsonObject -> value.entries.joinToString("") { "${it.key}: ${formatJsonValue(it.value)}" }
else -> value.toString().trim('"').ifBlank { "未填写" }
}
private fun formatDateOnly(value: String): String {
val text = value.trim()
if (text.isBlank()) {
return "未发布"
}
return text.substringBefore(" ").substringBefore("T")
}
}
fun ApplicationConfig.toTraceabilityPublicConfig(): TraceabilityPublicConfig {
return TraceabilityPublicConfig(
coreBaseUrl = property("traceability.core-base-url").getString().trimEnd('/'),
publicBaseUrl = property("traceability.public-base-url").getString().trimEnd('/'),
)
}
+10
View File
@@ -0,0 +1,10 @@
ktor:
application:
modules:
- com.bbitcn.ApplicationKt.module
deployment:
port: 8081
traceability:
core-base-url: "http://127.0.0.1:8089"
public-base-url: "http://127.0.0.1:8081"
+12
View File
@@ -0,0 +1,12 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>
@@ -0,0 +1,376 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
color: #182235;
background:
radial-gradient(circle at top left, rgba(23, 92, 230, 0.12), transparent 32%),
linear-gradient(180deg, #f9fbff 0%, #f3f6fb 100%);
}
a {
color: #1d4ed8;
text-decoration: none;
}
.page-shell {
max-width: 1240px;
margin: 0 auto;
padding: 28px 16px 48px;
}
.hero,
.panel {
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);
}
.hero {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
padding: 26px;
}
.hero--with-cover {
grid-template-columns: minmax(0, 1.2fr) 320px;
align-items: stretch;
}
.hero h1,
.panel h2,
.info-card h3,
.timeline-item__body h3 {
margin: 0;
}
.hero h1 {
margin-top: 16px;
font-size: 34px;
line-height: 1.2;
}
.hero p,
.panel__head p,
.info-card__desc,
.timeline-item__body p {
color: #667085;
line-height: 1.75;
}
.hero p {
margin: 14px 0 0;
}
.hero__stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.hero__cover {
overflow: hidden;
border: 1px solid #e8eef7;
border-radius: 22px;
background: #fff;
min-height: 240px;
}
.hero__cover img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.stat-card,
.summary-card,
.kv-card,
.info-card,
.timeline-item__body,
.form-item input,
.form-item select,
.form-item textarea {
border: 1px solid #e8eef7;
border-radius: 18px;
background: #fff;
}
.stat-card,
.summary-card {
padding: 14px 16px;
}
.summary-card {
min-height: 104px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.stat-card span,
.summary-card span,
.kv-card span,
.form-item span {
display: block;
color: #7d8899;
font-size: 12px;
}
.stat-card strong,
.summary-card strong,
.kv-card strong {
display: block;
margin-top: 8px;
line-height: 1.6;
word-break: break-word;
}
.hero__aside {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.panel {
margin-top: 18px;
padding: 24px;
}
.tabs-panel {
padding-top: 18px;
}
.tabs-nav {
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
padding: 8px;
border: 1px solid #e8eef7;
border-radius: 999px;
background: #f7faff;
margin-bottom: 18px;
}
.tab-btn {
min-width: 112px;
min-height: 42px;
padding: 0 18px;
border: none;
border-radius: 999px;
background: transparent;
color: #667085;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.tab-btn.active {
background: linear-gradient(135deg, #2b63e3, #1f4fd6);
color: #fff;
box-shadow: 0 10px 24px rgba(29, 78, 216, 0.2);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.panel__head {
margin-bottom: 16px;
}
.empty-state {
border: 1px dashed #d7e1f0;
border-radius: 18px;
background: #fafcff;
color: #7d8899;
padding: 24px 18px;
}
.notice {
margin-top: 18px;
border-radius: 18px;
background: #ecfdf3;
border: 1px solid #ccebd9;
color: #0b7a4b;
padding: 14px 16px;
}
.public-grid {
display: grid;
gap: 16px;
}
.info-card {
padding: 18px;
}
.info-card__desc {
margin: 10px 0 0;
}
.kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.kv-card {
padding: 12px 14px;
background: #fafcff;
}
.timeline {
display: grid;
gap: 18px;
}
.timeline-item {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
gap: 16px;
}
.timeline-item__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #1d4ed8;
box-shadow: 0 0 0 5px rgba(29, 78, 216, 0.12);
}
.line {
width: 2px;
flex: 1;
min-height: 70px;
margin-top: 8px;
background: linear-gradient(180deg, rgba(29, 78, 216, 0.32), rgba(29, 78, 216, 0.04));
}
.timeline-item:last-child .line {
display: none;
}
.timeline-item__body {
padding: 18px;
background: linear-gradient(180deg, #fff 0%, #fbfcff 100%);
}
.timeline-item__head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.kv-image {
display: block;
width: 100%;
max-height: 280px;
object-fit: cover;
border-radius: 14px;
margin-top: 10px;
border: 1px solid #e6edf8;
background: #fff;
}
.feedback-form {
display: grid;
gap: 14px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.form-item {
display: grid;
gap: 8px;
}
.form-item--full {
margin-top: 2px;
}
.form-item input,
.form-item select,
.form-item textarea {
width: 100%;
padding: 12px 14px;
font: inherit;
color: #182235;
}
.form-item textarea {
min-height: 140px;
resize: vertical;
}
.submit-btn,
.back-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 46px;
padding: 0 18px;
border: none;
border-radius: 14px;
background: linear-gradient(135deg, #2b63e3, #1f4fd6);
color: #fff;
font: inherit;
cursor: pointer;
}
.back-link {
margin-top: 8px;
width: fit-content;
}
.error-panel {
margin-top: 64px;
}
@media (max-width: 992px) {
.hero,
.form-grid,
.hero__aside,
.kv-grid {
grid-template-columns: 1fr;
}
.hero__stats {
grid-template-columns: 1fr;
}
.timeline-item__head {
flex-direction: column;
}
.tabs-nav {
display: grid;
grid-template-columns: 1fr;
border-radius: 20px;
}
.tab-btn {
width: 100%;
}
}
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>未找到溯源信息</title>
<link rel="stylesheet" href="/static/traceability.css" />
</head>
<body>
<div class="page-shell">
<section class="panel error-panel">
<div class="panel__head">
<div>
<h2>未找到溯源信息</h2>
<p>${message}</p>
</div>
</div>
<a class="back-link" href="/">返回服务首页</a>
</section>
</div>
</body>
</html>
@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${page.batchName} - 溯源信息</title>
<link rel="stylesheet" href="/static/traceability.css" />
</head>
<body>
<div class="page-shell">
<section class="hero<#if page.coverImage?has_content> hero--with-cover</#if>">
<div class="hero__content">
<h1>${page.batchName}</h1>
<p>${page.summary}</p>
<div class="hero__stats">
<div class="stat-card">
<span>批次编码</span>
<strong>${page.code}</strong>
</div>
<div class="stat-card">
<span>产品名称</span>
<strong>${page.productName}</strong>
</div>
<div class="stat-card">
<span>所属模板</span>
<strong>${page.templateName}</strong>
</div>
<div class="stat-card">
<span>累计访问</span>
<strong>${page.scanCount}</strong>
</div>
</div>
</div>
<#if page.coverImage?has_content>
<div class="hero__cover">
<img src="${page.coverImage}" alt="${page.productName}" />
</div>
</#if>
<div class="hero__aside">
<div class="summary-card">
<span>发布时间</span>
<strong>${page.publishedAt}</strong>
</div>
<div class="summary-card">
<span>标签</span>
<strong>${page.tagsText}</strong>
</div>
</div>
</section>
<#if feedbackMessage?has_content>
<section class="notice">${feedbackMessage}</section>
</#if>
<section class="panel tabs-panel">
<div class="tabs-nav" role="tablist" aria-label="溯源页面内容切换">
<button class="tab-btn active" data-tab-target="timeline-panel" type="button">溯源链</button>
<button class="tab-btn" data-tab-target="public-panel" type="button">公开资料</button>
<button class="tab-btn" data-tab-target="feedback-panel" type="button">反馈与投诉</button>
</div>
<div id="timeline-panel" class="tab-panel active">
<div class="panel__head">
<div>
<h2>溯源链</h2>
<p>按业务流程顺序查看本批次的处理过程与留痕信息。</p>
</div>
</div>
<#if page.businessSections?size gt 0>
<div class="timeline">
<#list page.businessSections as section>
<article class="timeline-item">
<div class="timeline-item__rail">
<span class="dot"></span>
<span class="line"></span>
</div>
<div class="timeline-item__body">
<div class="timeline-item__head">
<div>
<h3>${section.name}</h3>
<p>${section.description}</p>
</div>
</div>
<div class="kv-grid">
<#list section.entries as entry>
<div class="kv-card">
<span>${entry.label}</span>
<#if entry.type == "image" && entry.value != "未填写">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
<#else>
<strong>${entry.value}</strong>
</#if>
</div>
</#list>
</div>
</div>
</article>
</#list>
</div>
<#else>
<div class="empty-state">当前批次还没有可展示的业务流程节点。</div>
</#if>
</div>
<div id="public-panel" class="tab-panel">
<div class="panel__head">
<div>
<h2>公开资料</h2>
<p>面向消费者展示的企业资料、资质证明及其他公开信息。</p>
</div>
</div>
<#if page.publicSections?size gt 0>
<div class="public-grid">
<#list page.publicSections as section>
<article class="info-card">
<div class="info-card__head">
<h3>${section.name}</h3>
</div>
<p class="info-card__desc">${section.description}</p>
<div class="kv-grid">
<#list section.entries as entry>
<div class="kv-card">
<span>${entry.label}</span>
<#if entry.type == "image" && entry.value != "未填写">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
<#else>
<strong>${entry.value}</strong>
</#if>
</div>
</#list>
</div>
</article>
</#list>
</div>
<#else>
<div class="empty-state">当前批次还没有可展示的公开资料。</div>
</#if>
</div>
<div id="feedback-panel" class="tab-panel">
<div class="panel__head">
<div>
<h2>反馈与投诉</h2>
<p>如发现信息异常、商品质量问题,或有建议,可直接提交。</p>
</div>
</div>
<form class="feedback-form" method="post" action="/feedback">
<input type="hidden" name="batchCode" value="${page.code}" />
<div class="form-grid">
<label class="form-item">
<span>反馈类型</span>
<select name="type">
<option value="complaint">投诉</option>
<option value="suggestion">建议</option>
<option value="consult">咨询</option>
</select>
</label>
<label class="form-item">
<span>满意度</span>
<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 class="form-item">
<span>联系方式</span>
<input name="contact" placeholder="手机号 / 邮箱 / 微信" />
</label>
</div>
<label class="form-item form-item--full">
<span>反馈内容</span>
<textarea name="content" placeholder="请填写你要反馈的问题或建议" required></textarea>
</label>
<button type="submit" class="submit-btn">提交反馈</button>
</form>
</div>
</section>
</div>
<script>
const tabButtons = document.querySelectorAll('.tab-btn');
const tabPanels = document.querySelectorAll('.tab-panel');
tabButtons.forEach((button) => {
button.addEventListener('click', () => {
const targetId = button.dataset.tabTarget;
tabButtons.forEach((item) => item.classList.remove('active'));
tabPanels.forEach((panel) => panel.classList.remove('active'));
button.classList.add('active');
document.getElementById(targetId)?.classList.add('active');
});
});
if (window.location.search.includes('result=')) {
const nextUrl = window.location.pathname + window.location.hash;
window.history.replaceState({}, '', nextUrl);
}
</script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
package com.bbitcn
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.Test
import kotlin.test.assertEquals
class ApplicationTest {
@Test
fun testRoot() = testApplication {
application {
module()
}
client.get("/").apply {
assertEquals(HttpStatusCode.OK, status)
}
}
}
@@ -6,9 +6,11 @@ import ink.snowflake.server.controller.chat
import ink.snowflake.server.utils.plugins.configureSockets
import ink.snowflake.server.controller.ImageAnalytics
import ink.snowflake.server.controller.RemoteDebug
import ink.snowflake.server.controller.Traceability
import ink.snowflake.server.controller.VideoAnalytics
import ink.snowflake.server.controller.VideoAnalyticsJetson
import ink.snowflake.server.utils.AppConfig
import ink.snowflake.server.utils.OSSUtils
import ink.snowflake.server.utils.plugins.configureCORS
import ink.snowflake.server.utils.plugins.configureDatabases
import ink.snowflake.server.utils.plugins.configureSecurity
@@ -54,6 +56,8 @@ fun Application.module() {
configureCORS()
// 设置数据库
configureDatabases(appConfig)
// OSS / MinIO
OSSUtils.init(appConfig)
// 状态拦截
configureStatusPages()
// 设置-WebSocket
@@ -71,4 +75,5 @@ fun Application.module() {
VideoAnalyticsJetson()
// 业务-图片分析
ImageAnalytics()
Traceability()
}
@@ -0,0 +1,475 @@
package ink.snowflake.server.controller
import ink.snowflake.server.model.request.CreateTraceBatchRequest
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
import ink.snowflake.server.model.request.SubmitTraceabilityFeedbackRequest
import ink.snowflake.server.model.request.TraceabilityOssDeleteRequest
import ink.snowflake.server.model.request.TraceabilityOssMoveRequest
import ink.snowflake.server.model.request.TraceabilityOssPresignRequest
import ink.snowflake.server.model.request.TraceabilityOssTempUrlRequest
import ink.snowflake.server.model.request.UpdateTraceBatchBaseRequest
import ink.snowflake.server.model.request.UpdateTraceBatchStepRequest
import ink.snowflake.server.model.response.BaseResponse
import ink.snowflake.server.model.response.TraceBatchStepResponse
import ink.snowflake.server.model.response.TraceabilityOssFileResponse
import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
import ink.snowflake.server.utils.OSSUtils
import ink.snowflake.server.utils.dao.TraceabilityDao
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.*
import io.ktor.server.application.Application
import io.ktor.server.request.receiveMultipart
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import java.util.Locale
import java.util.UUID
fun Application.Traceability() {
TraceabilityDao.initSchema()
routing {
route("/traceability") {
get("/overview") {
call.respond(BaseResponse(data = TraceabilityDao.getOverview()))
}
route("/templates") {
get {
call.respond(BaseResponse(data = TraceabilityDao.listTemplates()))
}
post {
val request = call.receive<SaveTraceTemplateRequest>()
call.respond(BaseResponse(data = TraceabilityDao.saveTemplate(null, request)))
}
get("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
val data = id?.let(TraceabilityDao::getTemplate)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "模板不存在", data = null))
return@get
}
call.respond(BaseResponse(data = data))
}
put("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
return@put
}
val request = call.receive<SaveTraceTemplateRequest>()
call.respond(BaseResponse(data = TraceabilityDao.saveTemplate(id, request)))
}
delete("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
return@delete
}
val deleted = TraceabilityDao.deleteTemplate(id)
call.respond(BaseResponse(status = deleted, message = if (deleted) "模板已删除" else "模板不存在", data = deleted))
}
}
route("/batches") {
get {
call.respond(BaseResponse(data = TraceabilityDao.listBatches()))
}
post {
val request = call.receive<CreateTraceBatchRequest>()
call.respond(BaseResponse(data = TraceabilityDao.createBatch(request)))
}
delete("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
return@delete
}
val deleted = TraceabilityDao.deleteBatch(id)
call.respond(BaseResponse(status = deleted, message = if (deleted) "批次已删除" else "批次不存在", data = deleted))
}
get("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
val data = id?.let(TraceabilityDao::getBatch)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
return@get
}
call.respond(BaseResponse(data = data))
}
put("/{id}/base") {
val id = call.parameters["id"]?.let(UUID::fromString)
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
return@put
}
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}") {
val id = call.parameters["id"]?.let(UUID::fromString)
val stepId = call.parameters["stepId"]?.let(UUID::fromString)
if (id == null || stepId == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "步骤ID无效", data = null))
return@put
}
val request = call.receive<UpdateTraceBatchStepRequest>()
val data = TraceabilityDao.updateBatchStep(id, stepId, request)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "步骤不存在", data = null))
return@put
}
call.respond(BaseResponse(data = data))
}
post("/{id}/publish") {
val id = call.parameters["id"]?.let(UUID::fromString)
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
return@post
}
val data = TraceabilityDao.publishBatch(id)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
return@post
}
call.respond(BaseResponse(message = "批次已发布", data = data))
}
}
route("/feedback") {
get {
call.respond(BaseResponse(data = TraceabilityDao.listFeedback()))
}
post {
val request = call.receive<SubmitTraceabilityFeedbackRequest>()
call.respond(BaseResponse(message = "反馈已提交", data = TraceabilityDao.submitFeedback(request)))
}
}
route("/public") {
get("/by-code/{code}") {
val code = call.parameters["code"] ?: ""
val increaseScan = call.request.queryParameters["increaseScan"] == "true"
val data = TraceabilityDao.getPublicDetailByCode(code, increaseScan)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "未找到对应批次", data = null))
return@get
}
call.respond(BaseResponse(data = data))
}
post("/feedback") {
val request = call.receive<SubmitTraceabilityFeedbackRequest>()
call.respond(BaseResponse(message = "感谢反馈,我们会尽快处理", data = TraceabilityDao.submitFeedback(request)))
}
get("/page/{code}") {
val code = call.parameters["code"] ?: ""
val data = TraceabilityDao.getPublicDetailByCode(code, true)
if (data == null) {
call.respondText("Traceability data not found", status = HttpStatusCode.NotFound)
return@get
}
call.respondText(renderTraceabilityPage(data), ContentType.Text.Html)
}
}
route("/files") {
post("/upload-image") {
val multipart = call.receiveMultipart()
var bucketName = OSSUtils.defaultBucket()
var objectDir = "traceability/images"
var objectName = ""
var response: TraceabilityOssFileResponse? = null
multipart.forEachPart { part ->
when (part) {
is PartData.FormItem -> {
when (part.name) {
"bucketName" -> bucketName = part.value.ifBlank { OSSUtils.defaultBucket() }
"objectDir" -> objectDir = part.value.ifBlank { "traceability/images" }
"objectName" -> objectName = part.value
}
}
is PartData.FileItem -> {
val fileName = part.originalFileName ?: "image"
val ext = fileName.substringAfterLast('.', "").lowercase(Locale.getDefault())
val finalObjectName = objectName.ifBlank {
listOfNotNull(
objectDir.takeIf { it.isNotBlank() },
"${UUID.randomUUID()}${if (ext.isNotBlank()) ".$ext" else ""}",
).joinToString("/")
}
val contentType = part.contentType?.toString() ?: "application/octet-stream"
val bytes = part.streamProvider().use { input -> input.readBytes() }
OSSUtils.pushFile(bucketName, finalObjectName, bytes, contentType)
response = TraceabilityOssFileResponse(
bucketName = bucketName,
objectName = finalObjectName,
tempUrl = OSSUtils.getTempUrl(bucketName, finalObjectName),
contentType = contentType,
fileName = fileName,
size = bytes.size.toLong(),
)
}
else -> {}
}
part.dispose()
}
if (response == null) {
call.respond(
HttpStatusCode.BadRequest,
BaseResponse(status = false, message = "请选择要上传的图片", data = null),
)
return@post
}
call.respond(BaseResponse(message = "图片上传成功", data = response))
}
post("/presigned-put") {
val request = call.receive<TraceabilityOssPresignRequest>()
val bucketName = request.bucketName?.ifBlank { OSSUtils.defaultBucket() } ?: OSSUtils.defaultBucket()
val uploadUrl = OSSUtils.getUploadToken(
bucketName = bucketName,
objectName = request.objectName,
expiryMinutes = request.expiresMinutes,
)
call.respond(
BaseResponse(
data = TraceabilityOssFileResponse(
bucketName = bucketName,
objectName = request.objectName,
uploadUrl = uploadUrl,
tempUrl = OSSUtils.getTempUrl(bucketName, request.objectName),
),
),
)
}
post("/temp-url") {
val request = call.receive<TraceabilityOssTempUrlRequest>()
val bucketName = request.bucketName?.ifBlank { OSSUtils.defaultBucket() } ?: OSSUtils.defaultBucket()
val resolvedObjectName = when {
!request.objectName.isNullOrBlank() -> request.objectName
!request.objectDir.isNullOrBlank() -> request.objectDir
else -> null
}
val tempUrl = if (!request.objectDir.isNullOrBlank() && !request.objectName.isNullOrBlank()) {
OSSUtils.getTempUrlDict(
bucketName = bucketName,
objectDir = request.objectDir,
objectName = request.objectName,
seconds = request.expiresSeconds,
)
} else {
OSSUtils.getTempUrl(
bucketName = bucketName,
objectName = resolvedObjectName,
seconds = request.expiresSeconds,
)
}
call.respond(
BaseResponse(
data = TraceabilityOssFileResponse(
bucketName = bucketName,
objectName = resolvedObjectName ?: "",
tempUrl = tempUrl,
),
),
)
}
post("/move") {
val request = call.receive<TraceabilityOssMoveRequest>()
val bucketName = request.bucketName?.ifBlank { OSSUtils.defaultBucket() } ?: OSSUtils.defaultBucket()
OSSUtils.moveFile(bucketName, request.sourceObjectName, request.targetObjectName)
call.respond(
BaseResponse(
message = "文件已移动",
data = TraceabilityOssFileResponse(
bucketName = bucketName,
objectName = request.targetObjectName,
tempUrl = OSSUtils.getTempUrl(bucketName, request.targetObjectName),
),
),
)
}
post("/delete") {
val request = call.receive<TraceabilityOssDeleteRequest>()
val bucketName = request.bucketName?.ifBlank { OSSUtils.defaultBucket() } ?: OSSUtils.defaultBucket()
OSSUtils.deleteFile(bucketName, request.objectName)
call.respond(BaseResponse(message = "文件已删除", data = true))
}
}
}
}
}
private fun renderTraceabilityPage(detail: TraceabilityPublicDetailResponse): String {
val batch = detail.batch
val publicCards = detail.publicSections.joinToString("") { renderSectionCard(it) }
val timelineCards = detail.businessSections.joinToString("") { renderTimelineCard(it) }
val cover = batch.coverImage.takeIf { it.isNotBlank() }
?: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1400&q=80"
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(batch.batchName)} - 溯源信息</title>
<style>
:root { --bg:#f5f7fb; --panel:rgba(255,255,255,0.92); --line:#e5eaf3; --text:#13213c; --soft:#63708a; --brand:#2458d3; --brand-soft:#edf3ff; --success:#0f8c62; --shadow:0 18px 60px rgba(16,31,67,.08);}
* { box-sizing:border-box; }
body { margin:0; font-family:"PingFang SC","Microsoft YaHei",sans-serif; color:var(--text); background:radial-gradient(circle at top left, rgba(36,88,211,.10), transparent 35%),linear-gradient(180deg, #fbfcfe 0%, var(--bg) 100%); }
a { color:var(--brand); text-decoration:none; }
.page { max-width:1240px; margin:0 auto; padding:28px 18px 56px; }
.hero { position:relative; overflow:hidden; border-radius:32px; background:linear-gradient(135deg, rgba(255,255,255,.96), rgba(244,248,255,.92)); border:1px solid rgba(255,255,255,.7); box-shadow:var(--shadow); display:grid; grid-template-columns:minmax(0,1.4fr) minmax(320px,.9fr); gap:20px; padding:28px; }
.hero::after { content:""; position:absolute; right:-80px; top:-80px; width:220px; height:220px; background:radial-gradient(circle, rgba(36,88,211,.15), transparent 70%); }
.eyebrow { display:inline-flex; gap:10px; align-items:center; padding:8px 14px; background:var(--brand-soft); border-radius:999px; color:var(--brand); font-size:13px; font-weight:600; }
h1 { margin:18px 0 10px; font-size:34px; line-height:1.2; }
.hero p { margin:0; color:var(--soft); line-height:1.75; }
.hero-cover { min-height:260px; border-radius:24px; background:linear-gradient(180deg, rgba(19,33,60,.08), rgba(19,33,60,.26)), url('${escapeHtml(cover)}') center/cover no-repeat; display:flex; align-items:end; padding:20px; color:#fff; }
.stats { margin-top:18px; display:grid; grid-template-columns:repeat(4, minmax(0,1fr)); gap:12px; }
.stat { background:rgba(255,255,255,.86); border:1px solid var(--line); border-radius:18px; padding:16px; }
.stat span { display:block; font-size:12px; color:var(--soft); }
.stat strong { display:block; margin-top:8px; font-size:20px; }
.section { margin-top:22px; background:var(--panel); border:1px solid rgba(255,255,255,.72); border-radius:28px; box-shadow:var(--shadow); padding:24px; backdrop-filter:blur(12px); }
.section-head { display:flex; align-items:end; justify-content:space-between; gap:16px; margin-bottom:16px; flex-wrap:wrap; }
.section-head h2 { margin:0; font-size:24px; }
.section-head p { margin:8px 0 0; color:var(--soft); }
.grid { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:16px; }
.section-card,.feedback { background:#fff; border:1px solid var(--line); border-radius:22px; padding:20px; }
.section-card h3 { margin:0; font-size:18px; }
.muted { margin:8px 0 0; color:var(--soft); line-height:1.7; }
.kv-grid { margin-top:16px; display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:12px; }
.kv { border-radius:16px; background:#f8faff; border:1px solid #e9eef6; padding:14px; min-height:82px; }
.kv span { display:block; font-size:12px; color:var(--soft); }
.kv strong { display:block; margin-top:8px; line-height:1.65; font-size:14px; word-break:break-word; }
.timeline { display:grid; gap:18px; }
.timeline-item { display:grid; grid-template-columns:44px minmax(0,1fr); gap:16px; align-items:start; }
.timeline-rail { display:flex; flex-direction:column; align-items:center; height:100%; }
.dot { width:18px; height:18px; border-radius:50%; background:var(--brand); box-shadow:0 0 0 6px rgba(36,88,211,.12); margin-top:12px; }
.line { width:2px; flex:1; min-height:80px; margin-top:8px; background:linear-gradient(180deg, rgba(36,88,211,.35), rgba(36,88,211,.05)); }
.timeline-card { border-radius:24px; border:1px solid var(--line); background:linear-gradient(180deg, #ffffff 0%, #fbfcff 100%); padding:20px; }
.timeline-meta { display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; }
.tag { padding:6px 10px; background:#eef6f1; border-radius:999px; color:var(--success); font-size:12px; font-weight:600; }
.feedback-grid { display:grid; grid-template-columns:1.1fr .9fr; gap:16px; }
.feedback form { display:grid; gap:12px; }
input,textarea,select,button { font:inherit; }
input,textarea,select { width:100%; border:1px solid #dbe3ef; border-radius:14px; padding:12px 14px; background:#fff; color:var(--text); }
textarea { min-height:120px; resize:vertical; }
button { border:none; border-radius:14px; padding:12px 18px; background:linear-gradient(135deg, #2b63e3, #1f4fd6); color:#fff; cursor:pointer; font-weight:600; }
.link-box { border:1px dashed #d7e1f0; border-radius:18px; padding:16px; background:#fafcff; color:var(--soft); line-height:1.8; }
@media (max-width:960px) { .hero,.feedback-grid,.grid,.stats,.kv-grid { grid-template-columns:1fr; } }
</style>
</head>
<body>
<div class="page">
<section class="hero">
<div>
<span class="eyebrow">可信溯源链 · 实时公开</span>
<h1>${escapeHtml(batch.batchName)}</h1>
<p>${escapeHtml(batch.summary.ifBlank { "该批次已完成关键环节上链归档,消费者可查看从生产、质检到包装发布的完整履历信息。" })}</p>
<div class="stats">
<div class="stat"><span>批次编码</span><strong>${escapeHtml(batch.batchCode)}</strong></div>
<div class="stat"><span>当前状态</span><strong>${escapeHtml(batch.status)}</strong></div>
<div class="stat"><span>累计扫码</span><strong>${batch.scanCount}</strong></div>
<div class="stat"><span>流程节点</span><strong>${batch.steps.size}</strong></div>
</div>
</div>
<div class="hero-cover"><div><strong>${escapeHtml(batch.productName.ifBlank { batch.templateName })}</strong><div style="margin-top:8px;opacity:.88;">更新时间:${escapeHtml(batch.updatedAt)}</div></div></div>
</section>
<section class="section">
<div class="section-head"><div><h2>公开资料</h2><p>企业、地域、资质等面向消费者展示的信息在这里集中呈现。</p></div><div class="link-box">公开访问链接:<a href="${escapeHtml(batch.publicUrl)}">${escapeHtml(batch.publicUrl)}</a></div></div>
<div class="grid">$publicCards</div>
</section>
<section class="section">
<div class="section-head"><div><h2>流程时间轴</h2><p>按业务环节顺序查看本批次的生产过程与关键留痕。</p></div></div>
<div class="timeline">$timelineCards</div>
</section>
<section class="section">
<div class="section-head"><div><h2>投诉与建议</h2><p>如果你发现信息异常、质量问题,或有优化建议,可以直接提交。</p></div></div>
<div class="feedback-grid">
<div class="feedback">
<form id="feedback-form">
<label>反馈类型</label>
<select name="type"><option value="complaint">投诉</option><option value="suggestion">建议</option><option value="consult">咨询</option></select>
<label>联系方式</label>
<input name="contact" placeholder="手机号、邮箱或微信(选填)" />
<label>反馈内容</label>
<textarea name="content" placeholder="请描述你的问题或建议"></textarea>
<label>满意度</label>
<select name="rating"><option value="5">5 分</option><option value="4">4 分</option><option value="3">3 分</option><option value="2">2 分</option><option value="1">1 分</option></select>
<button type="submit">提交反馈</button>
</form>
</div>
<div class="feedback">
<h3 style="margin-top:0;">查看说明</h3>
<p class="muted">1. 当前页面由 Ktor 动态生成,适合扫码后直接打开。</p>
<p class="muted">2. 所有内容都来自模板字段与批次数据,无需单独开发每个行业页面。</p>
<p class="muted">3. 你可以在管理端调整模板结构,在业务端完善每个批次节点。</p>
<div id="feedback-result" class="muted" style="margin-top:16px;"></div>
</div>
</div>
</section>
</div>
<script>
const form = document.getElementById('feedback-form');
const result = document.getElementById('feedback-result');
form?.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(form);
const payload = { batchCode: '${escapeHtml(batch.batchCode)}', source: 'public', type: formData.get('type'), contact: formData.get('contact'), content: formData.get('content'), rating: Number(formData.get('rating') || 5) };
try {
const resp = await fetch('/traceability/public/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
const data = await resp.json();
if (data?.status) { form.reset(); result.textContent = '反馈已提交,感谢你的建议。'; } else { result.textContent = data?.message || '提交失败,请稍后重试。'; }
} catch (error) { result.textContent = '网络异常,请稍后重试。'; }
});
</script>
</body>
</html>
""".trimIndent()
}
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>"""
private fun renderTimelineCard(step: TraceBatchStepResponse): String {
val time = step.completedAt.ifBlank { "待补充" }
return """<div class="timeline-item"><div class="timeline-rail"><span class="dot"></span><span class="line"></span></div><div class="timeline-card"><div class="timeline-meta"><div><h3 style="margin:0;">${escapeHtml(step.name)}</h3><p class="muted">${escapeHtml(step.description)}</p></div><span class="tag">${escapeHtml(step.status)} · ${escapeHtml(time)}</span></div><div class="kv-grid">${renderValueCards(step.values)}</div></div></div>"""
}
private fun renderValueCards(values: JsonObject): String = values.entries.joinToString("") { (key, value) ->
"""<div class="kv"><span>${escapeHtml(key)}</span><strong>${escapeHtml(formatJsonValue(value))}</strong></div>"""
}
private fun formatJsonValue(value: JsonElement): String = when (value) {
is JsonArray -> value.joinToString("") { formatJsonValue(it) }
is JsonObject -> value.entries.joinToString("") { "${it.key}: ${formatJsonValue(it.value)}" }
else -> value.toString().trim('"')
}
private fun escapeHtml(value: String): String =
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
@@ -0,0 +1,72 @@
package ink.snowflake.server.model.database
import org.jetbrains.exposed.v1.core.dao.id.UUIDTable
import org.jetbrains.exposed.v1.datetime.timestamp
object TraceabilityTemplatesTable : UUIDTable("traceability_templates") {
val name = varchar("name", 120)
val description = text("description").default("")
val productName = varchar("product_name", 120).default("")
val industryName = varchar("industry_name", 120).default("")
val coverImage = text("cover_image").default("")
val themeColor = varchar("theme_color", 20).default("#1f4fd6")
val status = varchar("status", 32).default("draft")
val createdAt = timestamp("created_at").nullable()
val updatedAt = timestamp("updated_at").nullable()
}
object TraceabilityTemplateNodesTable : UUIDTable("traceability_template_nodes") {
val templateId = reference("template_id", TraceabilityTemplatesTable)
val sort = integer("sort").default(0)
val category = varchar("category", 32).default("business")
val name = varchar("name", 120)
val description = text("description").default("")
val locked = bool("locked").default(false)
val consumerVisible = bool("consumer_visible").default(true)
val fieldsJson = text("fields_json")
val createdAt = timestamp("created_at").nullable()
val updatedAt = timestamp("updated_at").nullable()
}
object TraceabilityBatchesTable : UUIDTable("traceability_batches") {
val templateId = reference("template_id", TraceabilityTemplatesTable)
val batchName = varchar("batch_name", 150)
val batchCode = varchar("batch_code", 120).uniqueIndex()
val productName = varchar("product_name", 120).default("")
val summary = text("summary").default("")
val coverImage = text("cover_image").default("")
val tagsJson = text("tags_json").default("[]")
val status = varchar("status", 32).default("draft")
val currentStep = integer("current_step").default(0)
val scanCount = integer("scan_count").default(0)
val publishedAt = timestamp("published_at").nullable()
val createdAt = timestamp("created_at").nullable()
val updatedAt = timestamp("updated_at").nullable()
}
object TraceabilityBatchStepsTable : UUIDTable("traceability_batch_steps") {
val batchId = reference("batch_id", TraceabilityBatchesTable)
val templateNodeId = reference("template_node_id", TraceabilityTemplateNodesTable).nullable()
val sort = integer("sort").default(0)
val category = varchar("category", 32).default("business")
val name = varchar("name", 120)
val description = text("description").default("")
val locked = bool("locked").default(false)
val consumerVisible = bool("consumer_visible").default(true)
val status = varchar("status", 32).default("pending")
val operatorName = varchar("operator_name", 80).default("")
val valuesJson = text("values_json").default("{}")
val completedAt = timestamp("completed_at").nullable()
val createdAt = timestamp("created_at").nullable()
val updatedAt = timestamp("updated_at").nullable()
}
object TraceabilityFeedbackTable : UUIDTable("traceability_feedback") {
val batchId = reference("batch_id", TraceabilityBatchesTable)
val type = varchar("type", 32).default("suggestion")
val contact = varchar("contact", 120).default("")
val content = text("content")
val sourceType = varchar("source_type", 32).default("public")
val rating = integer("rating").default(5)
val createdAt = timestamp("created_at").nullable()
}
@@ -0,0 +1,112 @@
package ink.snowflake.server.model.request
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import java.util.UUID
@Serializable
data class TraceFieldDefinitionRequest(
val key: String,
val label: String,
val type: String = "string",
val required: Boolean = false,
val visible: Boolean = true,
val placeholder: String = "",
val defaultValue: JsonElement? = null,
val options: List<String> = emptyList(),
)
@Serializable
data class TraceTemplateNodeRequest(
val id: String? = null,
val category: String = "business",
val name: String,
val description: String = "",
val locked: Boolean = false,
val consumerVisible: Boolean = true,
val fields: List<TraceFieldDefinitionRequest> = emptyList(),
)
@Serializable
data class SaveTraceTemplateRequest(
val name: String,
val description: String = "",
val productName: String = "",
val industryName: String = "",
val coverImage: String = "",
val themeColor: String = "#1f4fd6",
val status: String = "draft",
val nodes: List<TraceTemplateNodeRequest> = emptyList(),
)
@Serializable
data class CreateTraceBatchRequest(
val templateId: String,
val batchName: String,
val batchCode: String,
val productName: String = "",
val summary: String = "",
val coverImage: String = "",
val tags: List<String> = emptyList(),
)
@Serializable
data class UpdateTraceBatchBaseRequest(
val batchName: String,
val batchCode: String,
val productName: String = "",
val summary: String = "",
val coverImage: String = "",
val tags: List<String> = emptyList(),
val currentStep: Int = 0,
)
@Serializable
data class UpdateTraceBatchStepRequest(
val operatorName: String = "",
val status: String = "completed",
val values: JsonObject = JsonObject(emptyMap()),
val completedAt: String? = null,
)
@Serializable
data class SubmitTraceabilityFeedbackRequest(
val batchCode: String? = null,
val batchId: String? = null,
val type: String = "suggestion",
val contact: String = "",
val content: String,
val source: String = "public",
val rating: Int = 5,
)
@Serializable
data class TraceabilityOssPresignRequest(
val bucketName: String? = null,
val objectName: String,
val expiresMinutes: Int = 15,
)
@Serializable
data class TraceabilityOssTempUrlRequest(
val bucketName: String? = null,
val objectName: String? = null,
val objectDir: String? = null,
val expiresSeconds: Int = 3600,
)
@Serializable
data class TraceabilityOssMoveRequest(
val bucketName: String? = null,
val sourceObjectName: String,
val targetObjectName: String,
)
@Serializable
data class TraceabilityOssDeleteRequest(
val bucketName: String? = null,
val objectName: String,
)
fun CreateTraceBatchRequest.templateUuid(): UUID = UUID.fromString(templateId)
@@ -0,0 +1,155 @@
package ink.snowflake.server.model.response
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@Serializable
data class TraceabilityOverviewResponse(
val templateCount: Int,
val batchCount: Int,
val publishedCount: Int,
val feedbackCount: Int,
val totalScans: Int,
)
@Serializable
data class TraceFieldDefinitionResponse(
val key: String,
val label: String,
val type: String = "string",
val required: Boolean = false,
val visible: Boolean = true,
val placeholder: String = "",
val defaultValue: JsonElement? = null,
val options: List<String> = emptyList(),
)
@Serializable
data class TraceTemplateNodeResponse(
val id: String,
val sort: Int,
val category: String,
val name: String,
val description: String,
val locked: Boolean = false,
val consumerVisible: Boolean,
val fields: List<TraceFieldDefinitionResponse>,
)
@Serializable
data class TraceTemplateSummaryResponse(
val id: String,
val name: String,
val description: String,
val productName: String,
val industryName: String,
val coverImage: String,
val themeColor: String,
val status: String,
val nodeCount: Int,
val batchCount: Int,
val updatedAt: String,
)
@Serializable
data class TraceTemplateDetailResponse(
val id: String,
val name: String,
val description: String,
val productName: String,
val industryName: String,
val coverImage: String,
val themeColor: String,
val status: String,
val nodes: List<TraceTemplateNodeResponse>,
val updatedAt: String,
)
@Serializable
data class TraceBatchStepResponse(
val id: String,
val templateNodeId: String? = null,
val sort: Int,
val category: String,
val name: String,
val description: String,
val locked: Boolean = false,
val consumerVisible: Boolean,
val status: String,
val operatorName: String,
val values: JsonObject,
val completedAt: String = "",
val fields: List<TraceFieldDefinitionResponse> = emptyList(),
)
@Serializable
data class TraceBatchSummaryResponse(
val id: String,
val templateId: String,
val templateName: String,
val batchName: String,
val batchCode: String,
val productName: String,
val summary: String,
val coverImage: String,
val tags: List<String>,
val status: String,
val currentStep: Int,
val scanCount: Int,
val publicUrl: String,
val updatedAt: String,
)
@Serializable
data class TraceBatchDetailResponse(
val id: String,
val templateId: String,
val templateName: String,
val batchName: String,
val batchCode: String,
val productName: String,
val summary: String,
val coverImage: String,
val tags: List<String>,
val status: String,
val currentStep: Int,
val scanCount: Int,
val publicUrl: String,
val steps: List<TraceBatchStepResponse>,
val updatedAt: String,
val publishedAt: String = "",
)
@Serializable
data class TraceabilityFeedbackResponse(
val id: String,
val batchId: String,
val batchCode: String,
val batchName: String,
val type: String,
val contact: String,
val content: String,
val source: String,
val rating: Int,
val createdAt: String,
)
@Serializable
data class TraceabilityPublicDetailResponse(
val batch: TraceBatchDetailResponse,
val companySectionTitle: String = "企业公开资料",
val publicSections: List<TraceBatchStepResponse>,
val businessSections: List<TraceBatchStepResponse>,
)
@Serializable
data class TraceabilityOssFileResponse(
val bucketName: String,
val objectName: String,
val uploadUrl: String? = null,
val tempUrl: String? = null,
val contentType: String? = null,
val fileName: String? = null,
val size: Long? = null,
)
@@ -16,4 +16,14 @@ class AppConfig(config: ApplicationConfig) {
val smtpPort: Int = config.property("ktor.mail.smtp.port").getString().toInt()
val smtpUser: String = config.property("ktor.mail.smtp.user").getString()
val smtpPassword: String = config.property("ktor.mail.smtp.password").getString()
val ossEndpoint: String = config.property("ktor.oss.endpoint").getString()
val ossPort: Int = config.property("ktor.oss.port").getString().toInt()
val ossSecure: Boolean = config.property("ktor.oss.secure").getString().toBoolean()
val ossRegion: String = config.property("ktor.oss.region").getString()
val ossAccessKey: String = config.property("ktor.oss.access-key").getString()
val ossSecretKey: String = config.property("ktor.oss.secret-key").getString()
val ossDefaultBucket: String = config.property("ktor.oss.default-bucket").getString()
val ossFallbackBucket: String = config.property("ktor.oss.fallback-bucket").getString()
val ossFallbackObject: String = config.property("ktor.oss.fallback-object").getString()
}
@@ -1,52 +1,162 @@
package ink.snowflake.server.utils
import io.minio.BucketExistsArgs
import io.minio.CopyObjectArgs
import io.minio.CopySource
import io.minio.GetPresignedObjectUrlArgs
import io.minio.MakeBucketArgs
import io.minio.MinioClient
import io.minio.PutObjectArgs
import io.minio.GetPresignedObjectUrlArgs
import io.minio.RemoveObjectArgs
import io.minio.http.Method
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.util.concurrent.TimeUnit
object OSSUtils {
private val client: MinioClient = MinioClient.builder()
.endpoint("ai.ronsunny.cn",9000,true) // 你的MinIO地址
.region("Chengdu")
.credentials("minioadmin", "minioadmin") // 账号密码
private lateinit var appConfig: AppConfig
private val client: MinioClient by lazy {
MinioClient.builder()
.endpoint(appConfig.ossEndpoint, appConfig.ossPort, appConfig.ossSecure)
.region(appConfig.ossRegion)
.credentials(appConfig.ossAccessKey, appConfig.ossSecretKey)
.build()
}
/**
* 上传文件
* @param bucket 桶名
* @param objName 对象名(路径也放这里,例如 "images/test.png"
* @param input 输入流
* @param size 文件大小(字节)
* @param contentType 文件MIME类型,比如 "image/png"
*/
fun uploadFile(bucket: String, objName: String, input: InputStream, size: Long, contentType: String) {
fun init(config: AppConfig) {
appConfig = config
}
fun pushFile(
bucketName: String,
objectName: String,
fileBytes: ByteArray,
contentType: String,
) {
ensureBucketExists(bucketName)
ByteArrayInputStream(fileBytes).use { input ->
uploadFile(bucketName, objectName, input, fileBytes.size.toLong(), contentType)
}
}
fun uploadFile(
bucketName: String,
objectName: String,
input: InputStream,
size: Long,
contentType: String,
) {
ensureBucketExists(bucketName)
client.putObject(
PutObjectArgs.builder()
.bucket(bucket)
.`object`(objName)
.stream(input, size, -1) // -1 表示不限制分片大小,MinIO自己切
.bucket(bucketName)
.`object`(objectName)
.stream(input, size, -1)
.contentType(contentType)
.build()
.build(),
)
}
/**
* 获取临时访问地址
* @param bucket 桶名
* @param objName 对象名
* @param expiryMinutes 过期时间,分钟
*/
fun getPresignedUrl(bucket: String, objName: String, expiryMinutes: Int = 15): String {
fun getUploadToken(
bucketName: String,
objectName: String,
expiryMinutes: Int = 15,
): String {
ensureBucketExists(bucketName)
return client.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket(bucketName)
.`object`(objectName)
.expiry(expiryMinutes, TimeUnit.MINUTES)
.build(),
)
}
fun getPresignedUrl(
bucket: String,
objName: String,
expiryMinutes: Int = 15,
): String = getTempUrl(bucket, objName, expiryMinutes * 60)
fun getTempUrl(
bucketName: String?,
objectName: String?,
seconds: Int = 3600,
): String {
val actualBucket = bucketName?.takeIf { it.isNotBlank() } ?: appConfig.ossFallbackBucket
val actualObject = objectName?.takeIf { it.isNotBlank() } ?: appConfig.ossFallbackObject
ensureBucketExists(actualBucket)
return client.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucket)
.`object`(objName)
.expiry(expiryMinutes, TimeUnit.MINUTES)
.build()
.bucket(actualBucket)
.`object`(actualObject)
.expiry(seconds, TimeUnit.SECONDS)
.build(),
)
}
fun getTempUrlDict(
bucketName: String?,
objectDir: String?,
objectName: String?,
seconds: Int = 3600,
): String {
val actualBucket = bucketName?.takeIf { it.isNotBlank() } ?: appConfig.ossFallbackBucket
val actualDir = objectDir?.takeIf { it.isNotBlank() }
val actualObject = objectName?.takeIf { it.isNotBlank() } ?: appConfig.ossFallbackObject
val fullObjectName = listOfNotNull(actualDir, actualObject).joinToString("/")
return getTempUrl(actualBucket, fullObjectName, seconds)
}
fun moveFile(
bucketName: String,
sourceObjectName: String,
targetObjectName: String,
) {
ensureBucketExists(bucketName)
client.copyObject(
CopyObjectArgs.builder()
.bucket(bucketName)
.`object`(targetObjectName)
.source(
CopySource.builder()
.bucket(bucketName)
.`object`(sourceObjectName)
.build(),
)
.build(),
)
deleteFile(bucketName, sourceObjectName)
}
fun deleteFile(
bucketName: String,
objectName: String,
) {
client.removeObject(
RemoveObjectArgs.builder()
.bucket(bucketName)
.`object`(objectName)
.build(),
)
}
fun defaultBucket(): String = appConfig.ossDefaultBucket
private fun ensureBucketExists(bucketName: String) {
val exists = client.bucketExists(
BucketExistsArgs.builder()
.bucket(bucketName)
.build(),
)
if (!exists) {
client.makeBucket(
MakeBucketArgs.builder()
.bucket(bucketName)
.build(),
)
}
}
}
@@ -0,0 +1,516 @@
package ink.snowflake.server.utils.dao
import ink.snowflake.server.model.database.TraceabilityBatchStepsTable
import ink.snowflake.server.model.database.TraceabilityBatchesTable
import ink.snowflake.server.model.database.TraceabilityFeedbackTable
import ink.snowflake.server.model.database.TraceabilityTemplateNodesTable
import ink.snowflake.server.model.database.TraceabilityTemplatesTable
import ink.snowflake.server.model.request.CreateTraceBatchRequest
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
import ink.snowflake.server.model.request.SubmitTraceabilityFeedbackRequest
import ink.snowflake.server.model.request.TraceFieldDefinitionRequest
import ink.snowflake.server.model.request.UpdateTraceBatchBaseRequest
import ink.snowflake.server.model.request.UpdateTraceBatchStepRequest
import ink.snowflake.server.model.request.templateUuid
import ink.snowflake.server.model.response.TraceBatchDetailResponse
import ink.snowflake.server.model.response.TraceBatchStepResponse
import ink.snowflake.server.model.response.TraceBatchSummaryResponse
import ink.snowflake.server.model.response.TraceFieldDefinitionResponse
import ink.snowflake.server.model.response.TraceTemplateDetailResponse
import ink.snowflake.server.model.response.TraceTemplateNodeResponse
import ink.snowflake.server.model.response.TraceTemplateSummaryResponse
import ink.snowflake.server.model.response.TraceabilityFeedbackResponse
import ink.snowflake.server.model.response.TraceabilityOverviewResponse
import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.eq
import org.jetbrains.exposed.v1.datetime.timestampLiteral
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.deleteWhere
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.util.UUID
object TraceabilityDao {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val publicPreviewBaseUrl =
System.getenv("TRACEABILITY_PUBLIC_PREVIEW_BASE_URL")
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?: "http://127.0.0.1:8081"
fun initSchema() {
transaction {
SchemaUtils.createMissingTablesAndColumns(
TraceabilityTemplatesTable,
TraceabilityTemplateNodesTable,
TraceabilityBatchesTable,
TraceabilityBatchStepsTable,
TraceabilityFeedbackTable,
)
}
}
fun getOverview(): TraceabilityOverviewResponse = transaction {
TraceabilityOverviewResponse(
templateCount = TraceabilityTemplatesTable.selectAll().count().toInt(),
batchCount = TraceabilityBatchesTable.selectAll().count().toInt(),
publishedCount = TraceabilityBatchesTable.selectAll()
.where { TraceabilityBatchesTable.status eq "published" }
.count()
.toInt(),
feedbackCount = TraceabilityFeedbackTable.selectAll().count().toInt(),
totalScans = TraceabilityBatchesTable.selectAll().sumOf { it[TraceabilityBatchesTable.scanCount] },
)
}
private fun nowInstant() = Clock.System.now()
fun listTemplates(): List<TraceTemplateSummaryResponse> = transaction {
val batchCountByTemplate = TraceabilityBatchesTable.selectAll()
.groupBy { it[TraceabilityBatchesTable.templateId].value }
.mapValues { (_, rows) -> rows.size }
val nodeCountByTemplate = TraceabilityTemplateNodesTable.selectAll()
.groupBy { it[TraceabilityTemplateNodesTable.templateId].value }
.mapValues { (_, rows) -> rows.size }
TraceabilityTemplatesTable.selectAll()
.orderBy(TraceabilityTemplatesTable.updatedAt, SortOrder.DESC)
.map {
TraceTemplateSummaryResponse(
id = it[TraceabilityTemplatesTable.id].value.toString(),
name = it[TraceabilityTemplatesTable.name],
description = it[TraceabilityTemplatesTable.description],
productName = it[TraceabilityTemplatesTable.productName],
industryName = it[TraceabilityTemplatesTable.industryName],
coverImage = it[TraceabilityTemplatesTable.coverImage],
themeColor = it[TraceabilityTemplatesTable.themeColor],
status = it[TraceabilityTemplatesTable.status],
nodeCount = nodeCountByTemplate[it[TraceabilityTemplatesTable.id].value] ?: 0,
batchCount = batchCountByTemplate[it[TraceabilityTemplatesTable.id].value] ?: 0,
updatedAt = formatTimestamp(it[TraceabilityTemplatesTable.updatedAt]),
)
}
}
fun getTemplate(templateId: UUID): TraceTemplateDetailResponse? = transaction {
val templateRow = TraceabilityTemplatesTable.selectAll()
.where { TraceabilityTemplatesTable.id eq templateId }
.singleOrNull() ?: return@transaction null
TraceTemplateDetailResponse(
id = templateRow[TraceabilityTemplatesTable.id].value.toString(),
name = templateRow[TraceabilityTemplatesTable.name],
description = templateRow[TraceabilityTemplatesTable.description],
productName = templateRow[TraceabilityTemplatesTable.productName],
industryName = templateRow[TraceabilityTemplatesTable.industryName],
coverImage = templateRow[TraceabilityTemplatesTable.coverImage],
themeColor = templateRow[TraceabilityTemplatesTable.themeColor],
status = templateRow[TraceabilityTemplatesTable.status],
nodes = loadTemplateNodes(templateId),
updatedAt = formatTimestamp(templateRow[TraceabilityTemplatesTable.updatedAt]),
)
}
fun saveTemplate(templateId: UUID?, request: SaveTraceTemplateRequest): TraceTemplateDetailResponse = transaction {
val now = timestampLiteral(nowInstant())
val currentId = templateId ?: TraceabilityTemplatesTable.insertAndGetId {
it[name] = request.name
it[description] = request.description
it[productName] = request.productName
it[industryName] = request.industryName
it[coverImage] = request.coverImage
it[themeColor] = request.themeColor
it[status] = request.status
it[createdAt] = now
it[updatedAt] = now
}.value
if (templateId != null) {
TraceabilityTemplatesTable.update({ TraceabilityTemplatesTable.id eq currentId }) {
it[name] = request.name
it[description] = request.description
it[productName] = request.productName
it[industryName] = request.industryName
it[coverImage] = request.coverImage
it[themeColor] = request.themeColor
it[status] = request.status
it[updatedAt] = now
}
val existingNodeIds = TraceabilityTemplateNodesTable.selectAll()
.where { TraceabilityTemplateNodesTable.templateId eq currentId }
.map { it[TraceabilityTemplateNodesTable.id].value }
if (existingNodeIds.isNotEmpty()) {
existingNodeIds.forEach { nodeId ->
TraceabilityBatchStepsTable.deleteWhere {
TraceabilityBatchStepsTable.templateNodeId eq nodeId
}
}
}
TraceabilityTemplateNodesTable.deleteWhere { TraceabilityTemplateNodesTable.templateId eq currentId }
}
request.nodes.forEachIndexed { index, node ->
TraceabilityTemplateNodesTable.insertAndGetId {
it[this.templateId] = currentId
it[sort] = index
it[category] = node.category
it[name] = node.name
it[description] = node.description
it[locked] = node.locked
it[consumerVisible] = node.consumerVisible
it[fieldsJson] = json.encodeToString(node.fields)
it[createdAt] = now
it[updatedAt] = now
}
}
getTemplate(currentId)!!
}
fun deleteTemplate(templateId: UUID): Boolean = transaction {
val batchIds = TraceabilityBatchesTable.selectAll()
.where { TraceabilityBatchesTable.templateId eq templateId }
.map { it[TraceabilityBatchesTable.id].value }
if (batchIds.isNotEmpty()) {
batchIds.forEach { batchId ->
TraceabilityFeedbackTable.deleteWhere { TraceabilityFeedbackTable.batchId eq batchId }
TraceabilityBatchStepsTable.deleteWhere { TraceabilityBatchStepsTable.batchId eq batchId }
TraceabilityBatchesTable.deleteWhere { TraceabilityBatchesTable.id eq batchId }
}
}
TraceabilityTemplateNodesTable.deleteWhere { TraceabilityTemplateNodesTable.templateId eq templateId }
TraceabilityTemplatesTable.deleteWhere { TraceabilityTemplatesTable.id eq templateId } > 0
}
fun deleteBatch(batchId: UUID): Boolean = transaction {
TraceabilityFeedbackTable.deleteWhere { TraceabilityFeedbackTable.batchId eq batchId }
TraceabilityBatchStepsTable.deleteWhere { TraceabilityBatchStepsTable.batchId eq batchId }
TraceabilityBatchesTable.deleteWhere { TraceabilityBatchesTable.id eq batchId } > 0
}
fun listBatches(): List<TraceBatchSummaryResponse> = transaction {
val templateNames = TraceabilityTemplatesTable.selectAll()
.associate { it[TraceabilityTemplatesTable.id].value to it[TraceabilityTemplatesTable.name] }
TraceabilityBatchesTable.selectAll()
.orderBy(TraceabilityBatchesTable.updatedAt, SortOrder.DESC)
.map {
val code = it[TraceabilityBatchesTable.batchCode]
TraceBatchSummaryResponse(
id = it[TraceabilityBatchesTable.id].value.toString(),
templateId = it[TraceabilityBatchesTable.templateId].value.toString(),
templateName = templateNames[it[TraceabilityBatchesTable.templateId].value] ?: "",
batchName = it[TraceabilityBatchesTable.batchName],
batchCode = code,
productName = it[TraceabilityBatchesTable.productName],
summary = it[TraceabilityBatchesTable.summary],
coverImage = it[TraceabilityBatchesTable.coverImage],
tags = decodeStringList(it[TraceabilityBatchesTable.tagsJson]),
status = it[TraceabilityBatchesTable.status],
currentStep = it[TraceabilityBatchesTable.currentStep],
scanCount = it[TraceabilityBatchesTable.scanCount],
publicUrl = publicUrl(code),
updatedAt = formatTimestamp(it[TraceabilityBatchesTable.updatedAt]),
)
}
}
fun getBatch(batchId: UUID): TraceBatchDetailResponse? = transaction {
val batchRow = TraceabilityBatchesTable.selectAll()
.where { TraceabilityBatchesTable.id eq batchId }
.singleOrNull() ?: return@transaction null
val template = TraceabilityTemplatesTable.selectAll()
.where { TraceabilityTemplatesTable.id eq batchRow[TraceabilityBatchesTable.templateId] }
.single()
val code = batchRow[TraceabilityBatchesTable.batchCode]
TraceBatchDetailResponse(
id = batchRow[TraceabilityBatchesTable.id].value.toString(),
templateId = batchRow[TraceabilityBatchesTable.templateId].value.toString(),
templateName = template[TraceabilityTemplatesTable.name],
batchName = batchRow[TraceabilityBatchesTable.batchName],
batchCode = code,
productName = batchRow[TraceabilityBatchesTable.productName],
summary = batchRow[TraceabilityBatchesTable.summary],
coverImage = batchRow[TraceabilityBatchesTable.coverImage],
tags = decodeStringList(batchRow[TraceabilityBatchesTable.tagsJson]),
status = batchRow[TraceabilityBatchesTable.status],
currentStep = batchRow[TraceabilityBatchesTable.currentStep],
scanCount = batchRow[TraceabilityBatchesTable.scanCount],
publicUrl = publicUrl(code),
steps = loadBatchSteps(batchId),
updatedAt = formatTimestamp(batchRow[TraceabilityBatchesTable.updatedAt]),
publishedAt = formatTimestamp(batchRow[TraceabilityBatchesTable.publishedAt]),
)
}
fun createBatch(request: CreateTraceBatchRequest): TraceBatchDetailResponse = transaction {
val template = getTemplate(request.templateUuid()) ?: error("template not found")
val now = timestampLiteral(nowInstant())
val batchId = TraceabilityBatchesTable.insertAndGetId {
it[this.templateId] = request.templateUuid()
it[batchName] = request.batchName
it[batchCode] = request.batchCode
it[productName] = request.productName
it[summary] = request.summary
it[coverImage] = request.coverImage
it[tagsJson] = json.encodeToString(request.tags)
it[status] = "draft"
it[currentStep] = 0
it[createdAt] = now
it[updatedAt] = now
}.value
template.nodes.forEach { node ->
TraceabilityBatchStepsTable.insertAndGetId {
it[this.batchId] = batchId
it[this.templateNodeId] = UUID.fromString(node.id)
it[sort] = node.sort
it[category] = node.category
it[name] = node.name
it[description] = node.description
it[locked] = node.locked
it[consumerVisible] = node.consumerVisible
it[status] = "pending"
it[operatorName] = ""
it[valuesJson] = buildDefaultValues(node.fields)
it[createdAt] = now
it[updatedAt] = now
}
}
getBatch(batchId)!!
}
fun updateBatchBase(batchId: UUID, request: UpdateTraceBatchBaseRequest): TraceBatchDetailResponse? = transaction {
val now = timestampLiteral(nowInstant())
val updated = TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchId }) {
it[batchName] = request.batchName
it[batchCode] = request.batchCode
it[productName] = request.productName
it[summary] = request.summary
it[coverImage] = request.coverImage
it[tagsJson] = json.encodeToString(request.tags)
it[currentStep] = request.currentStep
it[updatedAt] = now
}
if (updated == 0) return@transaction null
getBatch(batchId)
}
fun updateBatchStep(batchId: UUID, stepId: UUID, request: UpdateTraceBatchStepRequest): TraceBatchDetailResponse? = transaction {
val now = timestampLiteral(nowInstant())
val completedAt = request.completedAt
?.takeIf { it.isNotBlank() }
?.let { timestampLiteral(Instant.parse(it)) }
?: now
val updated = TraceabilityBatchStepsTable.update({
(TraceabilityBatchStepsTable.batchId eq batchId) and (TraceabilityBatchStepsTable.id eq stepId)
}) {
it[operatorName] = request.operatorName
it[status] = request.status
it[valuesJson] = json.encodeToString(request.values)
it[this.completedAt] = completedAt
it[updatedAt] = now
}
if (updated == 0) return@transaction null
val stepRows = TraceabilityBatchStepsTable.selectAll()
.where { TraceabilityBatchStepsTable.batchId eq batchId }
.orderBy(TraceabilityBatchStepsTable.sort, SortOrder.ASC)
.toList()
val currentIndex = stepRows.indexOfFirst { it[TraceabilityBatchStepsTable.status] != "completed" }
.let { if (it == -1) (stepRows.size - 1).coerceAtLeast(0) else it }
TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchId }) {
it[currentStep] = currentIndex
it[updatedAt] = now
}
getBatch(batchId)
}
fun publishBatch(batchId: UUID): TraceBatchDetailResponse? = transaction {
val now = timestampLiteral(nowInstant())
val updated = TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchId }) {
it[status] = "published"
it[publishedAt] = now
it[updatedAt] = now
}
if (updated == 0) return@transaction null
getBatch(batchId)
}
fun getPublicDetailByCode(batchCode: String, increaseScan: Boolean = false): TraceabilityPublicDetailResponse? = transaction {
val batchRow = TraceabilityBatchesTable.selectAll()
.where { TraceabilityBatchesTable.batchCode eq batchCode }
.singleOrNull() ?: return@transaction null
if (increaseScan) {
TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchRow[TraceabilityBatchesTable.id].value }) {
it[scanCount] = batchRow[TraceabilityBatchesTable.scanCount] + 1
it[updatedAt] = timestampLiteral(nowInstant())
}
}
val batch = getBatch(batchRow[TraceabilityBatchesTable.id].value) ?: return@transaction null
TraceabilityPublicDetailResponse(
batch = batch,
publicSections = batch.steps.filter { it.category == "public" && it.consumerVisible },
businessSections = batch.steps.filter { it.category != "public" && it.consumerVisible },
)
}
fun listFeedback(): List<TraceabilityFeedbackResponse> = transaction {
val batchMap = TraceabilityBatchesTable.selectAll()
.associateBy { it[TraceabilityBatchesTable.id].value }
TraceabilityFeedbackTable.selectAll()
.orderBy(TraceabilityFeedbackTable.createdAt, SortOrder.DESC)
.map {
val batch = batchMap[it[TraceabilityFeedbackTable.batchId].value]
TraceabilityFeedbackResponse(
id = it[TraceabilityFeedbackTable.id].value.toString(),
batchId = it[TraceabilityFeedbackTable.batchId].value.toString(),
batchCode = batch?.get(TraceabilityBatchesTable.batchCode) ?: "",
batchName = batch?.get(TraceabilityBatchesTable.batchName) ?: "",
type = it[TraceabilityFeedbackTable.type],
contact = it[TraceabilityFeedbackTable.contact],
content = it[TraceabilityFeedbackTable.content],
source = it[TraceabilityFeedbackTable.sourceType],
rating = it[TraceabilityFeedbackTable.rating],
createdAt = formatTimestamp(it[TraceabilityFeedbackTable.createdAt]),
)
}
}
fun submitFeedback(request: SubmitTraceabilityFeedbackRequest): TraceabilityFeedbackResponse = transaction {
val batchId = when {
!request.batchId.isNullOrBlank() -> UUID.fromString(request.batchId)
!request.batchCode.isNullOrBlank() -> TraceabilityBatchesTable.selectAll()
.where { TraceabilityBatchesTable.batchCode eq request.batchCode }
.single()[TraceabilityBatchesTable.id].value
else -> error("batch not found")
}
val now = timestampLiteral(nowInstant())
val feedbackId = TraceabilityFeedbackTable.insertAndGetId {
it[this.batchId] = batchId
it[type] = request.type
it[contact] = request.contact
it[content] = request.content
it[sourceType] = request.source
it[rating] = request.rating.coerceIn(1, 5)
it[createdAt] = now
}.value
listFeedback().first { it.id == feedbackId.toString() }
}
private fun loadTemplateNodes(templateId: UUID): List<TraceTemplateNodeResponse> {
return TraceabilityTemplateNodesTable.selectAll()
.where { TraceabilityTemplateNodesTable.templateId eq templateId }
.orderBy(TraceabilityTemplateNodesTable.sort, SortOrder.ASC)
.map {
TraceTemplateNodeResponse(
id = it[TraceabilityTemplateNodesTable.id].value.toString(),
sort = it[TraceabilityTemplateNodesTable.sort],
category = it[TraceabilityTemplateNodesTable.category],
name = it[TraceabilityTemplateNodesTable.name],
description = it[TraceabilityTemplateNodesTable.description],
locked = it[TraceabilityTemplateNodesTable.locked],
consumerVisible = it[TraceabilityTemplateNodesTable.consumerVisible],
fields = decodeFields(it[TraceabilityTemplateNodesTable.fieldsJson]),
)
}
}
private fun loadBatchSteps(batchId: UUID): List<TraceBatchStepResponse> {
return TraceabilityBatchStepsTable.selectAll()
.where { TraceabilityBatchStepsTable.batchId eq batchId }
.orderBy(TraceabilityBatchStepsTable.sort, SortOrder.ASC)
.map { row ->
val fields = row[TraceabilityBatchStepsTable.templateNodeId]?.value?.let { nodeId ->
TraceabilityTemplateNodesTable.selectAll()
.where { TraceabilityTemplateNodesTable.id eq nodeId }
.singleOrNull()
?.let { decodeFields(it[TraceabilityTemplateNodesTable.fieldsJson]) }
} ?: emptyList()
TraceBatchStepResponse(
id = row[TraceabilityBatchStepsTable.id].value.toString(),
templateNodeId = row[TraceabilityBatchStepsTable.templateNodeId]?.value?.toString(),
sort = row[TraceabilityBatchStepsTable.sort],
category = row[TraceabilityBatchStepsTable.category],
name = row[TraceabilityBatchStepsTable.name],
description = row[TraceabilityBatchStepsTable.description],
locked = row[TraceabilityBatchStepsTable.locked],
consumerVisible = row[TraceabilityBatchStepsTable.consumerVisible],
status = row[TraceabilityBatchStepsTable.status],
operatorName = row[TraceabilityBatchStepsTable.operatorName],
values = decodeValues(row[TraceabilityBatchStepsTable.valuesJson]),
completedAt = formatTimestamp(row[TraceabilityBatchStepsTable.completedAt]),
fields = fields,
)
}
}
private fun decodeFields(raw: String): List<TraceFieldDefinitionResponse> {
return json.decodeFromString<List<TraceFieldDefinitionRequest>>(raw).map {
TraceFieldDefinitionResponse(
key = it.key,
label = it.label,
type = it.type,
required = it.required,
visible = it.visible,
placeholder = it.placeholder,
defaultValue = it.defaultValue,
options = it.options,
)
}
}
private fun decodeValues(raw: String): JsonObject = try {
json.decodeFromString<JsonObject>(raw)
} catch (_: Exception) {
JsonObject(emptyMap())
}
private fun decodeStringList(raw: String): List<String> = try {
json.decodeFromString<List<String>>(raw)
} catch (_: Exception) {
emptyList()
}
private fun buildDefaultValues(fields: List<TraceFieldDefinitionResponse>): String {
val values = buildJsonObject {
fields.forEach { field ->
put(field.key, field.defaultValue ?: JsonNull)
}
}
return json.encodeToString(values)
}
private fun formatTimestamp(value: Instant?): String =
value?.toString()?.replace('T', ' ')?.replace("Z", "") ?: ""
private fun publicUrl(code: String): String {
return "$publicPreviewBaseUrl/p/$code"
}
}
+11
View File
@@ -29,3 +29,14 @@ ktor:
user: "account@snowflake.ink"
password: "7ZYPc75xCViqSrCg"
oss:
endpoint: "ai.ronsunny.cn"
port: 9000
secure: true
region: "Chengdu"
access-key: "minioadmin"
secret-key: "minioadmin"
default-bucket: "traceability"
fallback-bucket: "system"
fallback-object: "favicon.ico"
+20
View File
@@ -0,0 +1,20 @@
# 灵活溯源系统 MVP
这是一个纯前端的 H5 演示版,直接打开 `index.html` 就能看效果。
## 已包含内容
- 管理员端:节点库、字段编辑、模板创建、公共资料块复用
- 业务员端:基于模板新建批次、逐节点填报、二维码预览
- 消费者端:时间轴溯源展示、企业信息、县域情况、有机证书
## 使用方式
1. 直接双击打开 `index.html`
2. 或者用任意静态文件服务打开 `trace-demo` 目录
## 说明
- 演示数据保存在浏览器 `localStorage`
- 点击“重置演示数据”可以恢复默认内容
- 当前二维码是本地样式模拟,后续可替换成真实二维码
+1016
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>灵活溯源系统 MVP</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="app"></div>
<script src="./app.js"></script>
</body>
</html>
+317
View File
@@ -0,0 +1,317 @@
:root {
--bg: #f4f6f9;
--panel: #ffffff;
--panel-soft: #f9fafc;
--line: #e6ebf2;
--text: #172033;
--text-soft: #5f6b85;
--brand: #1958d6;
--brand-soft: #edf3ff;
--success: #0f8c62;
--danger: #c84242;
--radius-lg: 20px;
--radius-md: 14px;
--radius-sm: 10px;
--shadow: 0 12px 40px rgba(18, 30, 67, 0.08);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; font-family: "Noto Sans SC", sans-serif; background: var(--bg); color: var(--text); }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
a { color: var(--brand); text-decoration: none; }
.system-shell { min-height: 100vh; display: grid; grid-template-columns: 220px minmax(0, 1fr); }
.main-nav {
display: flex;
flex-direction: column;
gap: 10px;
padding: 24px 18px;
background: linear-gradient(180deg, #14213f 0%, #1e305d 100%);
color: #fff;
}
.brand { font-size: 22px; font-weight: 800; margin-bottom: 14px; }
.nav-btn {
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.08);
color: #eef3ff;
border-radius: 14px;
padding: 12px 14px;
text-align: left;
}
.nav-btn.active { background: #fff; color: var(--brand); }
.nav-btn.danger { margin-top: auto; color: #ffd2d2; border-color: rgba(255,255,255,0.1); }
.main-panel { padding: 22px; }
.content-shell { display: grid; gap: 18px; }
.content-shell.two-col, .content-shell.operator-layout { grid-template-columns: 320px minmax(0, 1fr); }
.content-shell.single-col { grid-template-columns: 1fr; }
.left-pane, .right-pane, .editor-card, .stats-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.left-pane, .right-pane { padding: 18px; }
.right-pane { display: grid; gap: 18px; align-content: start; }
.editor-card { padding: 18px; }
.pane-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.pane-head h2, .timeline-head h3 { margin: 0; font-size: 20px; }
.pane-actions, .editor-actions, .template-toolbar, .query-bar, .inline-select, .sub-tabs { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.muted-line { margin: 6px 0 0; color: var(--text-soft); font-size: 13px; }
.scroll-list { display: grid; gap: 12px; max-height: calc(100vh - 150px); overflow: auto; padding-right: 4px; }
.list-card {
width: 100%;
border: 1px solid var(--line);
background: var(--panel-soft);
border-radius: var(--radius-md);
padding: 14px;
display: grid;
gap: 6px;
text-align: left;
color: var(--text);
}
.list-card.active { border-color: var(--brand); background: var(--brand-soft); }
.list-card span { color: var(--text-soft); font-size: 13px; }
.primary-btn, .ghost-btn, .sub-tab {
border-radius: 12px;
padding: 10px 14px;
border: 1px solid transparent;
}
.primary-btn { background: var(--brand); color: #fff; }
.ghost-btn, .sub-tab { background: var(--panel-soft); color: var(--text); border-color: var(--line); }
.danger-btn { color: var(--danger); }
.sub-tab.active { background: var(--brand-soft); color: var(--brand); border-color: #cadeff; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.form-grid.compact { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.field { display: flex; flex-direction: column; gap: 8px; }
.field.full { grid-column: 1 / -1; }
.field label { font-size: 13px; font-weight: 700; color: var(--text-soft); }
.field input, .field select, .field textarea {
width: 100%;
border: 1px solid #d6deea;
border-radius: 12px;
background: #fff;
color: var(--text);
padding: 11px 12px;
min-height: 44px;
}
.field textarea { min-height: 110px; resize: vertical; }
.field input:disabled, .field select:disabled, .field textarea:disabled { background: #f3f5f8; color: #79859e; }
.switch { position: relative; width: 54px; height: 32px; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch span {
position: absolute;
inset: 0;
background: #cdd6e4;
border-radius: 999px;
transition: 0.2s ease;
}
.switch span::before {
content: "";
position: absolute;
width: 22px;
height: 22px;
left: 5px;
top: 5px;
border-radius: 50%;
background: #fff;
transition: 0.2s ease;
box-shadow: 0 4px 12px rgba(23, 32, 51, 0.18);
}
.switch input:checked + span { background: var(--brand); }
.switch input:checked + span::before { transform: translateX(22px); }
.template-toolbar { margin: 16px 0; }
.inline-select select { min-width: 180px; }
.node-strip {
display: flex;
gap: 10px;
flex-wrap: wrap;
padding: 6px 0 2px;
}
.node-pill {
display: grid;
gap: 4px;
min-width: 160px;
padding: 14px;
border: 1px solid var(--line);
background: var(--panel-soft);
border-radius: 16px;
text-align: left;
}
.node-pill.active { border-color: var(--brand); background: var(--brand-soft); }
.pill-order { color: var(--brand); font-size: 12px; font-weight: 800; }
.pill-name { font-weight: 700; }
.pill-tag { color: var(--text-soft); font-size: 12px; }
.readonly-tip {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 12px;
background: #fff6e9;
color: #8d5a16;
border: 1px solid #f2ddbb;
}
.field-list { display: grid; gap: 12px; margin-top: 16px; }
.field-card {
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel-soft);
padding: 14px;
}
.progress-strip {
display: flex;
gap: 12px;
overflow: auto;
padding-bottom: 4px;
}
.progress-step {
min-width: 132px;
border: 1px solid var(--line);
background: var(--panel-soft);
border-radius: 16px;
padding: 12px;
text-align: left;
display: grid;
gap: 6px;
}
.progress-step.active { border-color: var(--brand); background: var(--brand-soft); }
.progress-step.done .step-index { background: var(--success); }
.step-index {
width: 26px;
height: 26px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--brand);
color: #fff;
font-size: 12px;
font-weight: 800;
}
.step-name { font-size: 13px; font-weight: 700; }
.chips { display: flex; flex-wrap: wrap; gap: 10px; }
.chip-check {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 999px;
background: var(--panel-soft);
border: 1px solid var(--line);
}
.consumer-tabs { display: flex; gap: 10px; margin-bottom: 16px; }
.consumer-shell {
background: linear-gradient(180deg, #fefefe 0%, #f5f7fb 100%);
border: 1px solid var(--line);
border-radius: 22px;
padding: 22px;
}
.consumer-topbar { margin-bottom: 18px; }
.consumer-topbar h2 { margin: 0; }
.consumer-topbar p { margin: 8px 0 0; color: var(--text-soft); }
.timeline-v2 { display: grid; gap: 0; }
.timeline-row { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 14px; }
.timeline-rail {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.timeline-dot {
width: 16px;
height: 16px;
border-radius: 50%;
background: #c7d2e7;
border: 4px solid #eef3ff;
z-index: 1;
margin-top: 18px;
}
.timeline-dot.active { background: var(--brand); }
.timeline-line {
width: 2px;
flex: 1;
background: linear-gradient(180deg, #bdd0ff 0%, #e0e8f6 100%);
margin-top: 8px;
}
.timeline-body {
margin-bottom: 18px;
padding: 18px;
border-radius: 18px;
border: 1px solid var(--line);
background: #fff;
}
.timeline-head span { display: block; margin-top: 8px; color: var(--text-soft); font-size: 13px; }
.timeline-grid, .materials-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.kv-card {
padding: 14px;
border-radius: 14px;
background: var(--panel-soft);
border: 1px solid var(--line);
display: grid;
gap: 6px;
}
.kv-card span { color: var(--text-soft); font-size: 12px; }
.kv-card strong { font-size: 14px; word-break: break-word; }
.material-card {
border: 1px solid var(--line);
background: #fff;
border-radius: 18px;
padding: 18px;
}
.stats-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
.stats-card {
padding: 26px;
display: grid;
gap: 14px;
}
.stats-card span { color: var(--text-soft); }
.stats-card strong { font-size: 40px; line-height: 1; }
.empty-panel {
min-height: 180px;
border: 1px dashed #cfd7e4;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-soft);
background: var(--panel-soft);
}
@media (max-width: 1080px) {
.system-shell, .content-shell.two-col, .content-shell.operator-layout { grid-template-columns: 1fr; }
.main-nav { flex-direction: row; flex-wrap: wrap; align-items: center; }
.nav-btn.danger { margin-top: 0; margin-left: auto; }
}
@media (max-width: 760px) {
.main-panel { padding: 14px; }
.form-grid, .form-grid.compact, .timeline-grid, .materials-grid, .stats-grid { grid-template-columns: 1fr; }
.timeline-row { grid-template-columns: 28px minmax(0, 1fr); gap: 10px; }
.node-pill, .progress-step { min-width: 120px; }
}
@@ -0,0 +1,278 @@
import { requestClient } from '#/api/request';
export namespace TraceabilityApi {
export interface Overview {
templateCount: number;
batchCount: number;
publishedCount: number;
feedbackCount: number;
totalScans: number;
}
export interface FieldDefinition {
key: string;
label: string;
type: string;
required: boolean;
visible: boolean;
placeholder?: string;
defaultValue?: any;
options?: string[];
}
export interface TemplateNode {
id?: string;
sort?: number;
category: 'business' | 'public' | string;
name: string;
description: string;
locked?: boolean;
consumerVisible: boolean;
fields: FieldDefinition[];
}
export interface TemplateSummary {
id: string;
name: string;
description: string;
productName: string;
industryName: string;
coverImage: string;
themeColor: string;
status: string;
nodeCount: number;
batchCount: number;
updatedAt: string;
}
export interface TemplateDetail extends TemplateSummary {
nodes: TemplateNode[];
}
export interface BatchStep {
id: string;
templateNodeId?: string;
sort: number;
category: string;
name: string;
description: string;
locked?: boolean;
consumerVisible: boolean;
status: string;
operatorName: string;
values: Record<string, any>;
completedAt: string;
fields: FieldDefinition[];
}
export interface BatchSummary {
id: string;
templateId: string;
templateName: string;
batchName: string;
batchCode: string;
productName: string;
summary: string;
coverImage: string;
tags: string[];
status: string;
currentStep: number;
scanCount: number;
publicUrl: string;
updatedAt: string;
}
export interface BatchDetail extends BatchSummary {
steps: BatchStep[];
publishedAt: string;
}
export interface PublicDetail {
batch: BatchDetail;
companySectionTitle: string;
publicSections: BatchStep[];
businessSections: BatchStep[];
}
export interface FeedbackItem {
id: string;
batchId: string;
batchCode: string;
batchName: string;
type: string;
contact: string;
content: string;
source: string;
rating: number;
createdAt: string;
}
export interface OssFileResult {
bucketName: string;
objectName: string;
uploadUrl?: string;
tempUrl?: string;
contentType?: string;
fileName?: string;
size?: number;
}
}
export function getTraceabilityOverview() {
return requestClient.get<TraceabilityApi.Overview>('/traceability/overview');
}
export function getTraceabilityTemplates() {
return requestClient.get<TraceabilityApi.TemplateSummary[]>(
'/traceability/templates',
);
}
export function getTraceabilityTemplate(id: string) {
return requestClient.get<TraceabilityApi.TemplateDetail>(
`/traceability/templates/${id}`,
);
}
export function createTraceabilityTemplate(
data: Omit<TraceabilityApi.TemplateDetail, 'batchCount' | 'id' | 'nodeCount' | 'updatedAt'>,
) {
return requestClient.post('/traceability/templates', data);
}
export function updateTraceabilityTemplate(
id: string,
data: Omit<TraceabilityApi.TemplateDetail, 'batchCount' | 'id' | 'nodeCount' | 'updatedAt'>,
) {
return requestClient.put(`/traceability/templates/${id}`, data);
}
export function deleteTraceabilityTemplate(id: string) {
return requestClient.delete(`/traceability/templates/${id}`);
}
export function getTraceabilityBatches() {
return requestClient.get<TraceabilityApi.BatchSummary[]>(
'/traceability/batches',
);
}
export function getTraceabilityBatch(id: string) {
return requestClient.get<TraceabilityApi.BatchDetail>(
`/traceability/batches/${id}`,
);
}
export function createTraceabilityBatch(data: {
templateId: string;
batchName: string;
batchCode: string;
productName?: string;
summary?: string;
coverImage?: string;
tags?: string[];
}) {
return requestClient.post('/traceability/batches', data);
}
export function deleteTraceabilityBatch(id: string) {
return requestClient.delete(`/traceability/batches/${id}`);
}
export function updateTraceabilityBatchBase(id: string, data: any) {
return requestClient.put(`/traceability/batches/${id}/base`, data);
}
export function updateTraceabilityBatchStep(
batchId: string,
stepId: string,
data: {
operatorName?: string;
status?: string;
values?: Record<string, any>;
completedAt?: string;
},
) {
return requestClient.put(`/traceability/batches/${batchId}/steps/${stepId}`, data);
}
export function publishTraceabilityBatch(id: string) {
return requestClient.post(`/traceability/batches/${id}/publish`);
}
export function getTraceabilityPublicDetail(code: string) {
return requestClient.get<TraceabilityApi.PublicDetail>(
`/traceability/public/by-code/${code}`,
);
}
export function getTraceabilityFeedbackList() {
return requestClient.get<TraceabilityApi.FeedbackItem[]>(
'/traceability/feedback',
);
}
export function submitTraceabilityFeedback(data: {
batchCode?: string;
batchId?: string;
type: string;
contact?: string;
content: string;
source?: string;
rating?: number;
}) {
return requestClient.post('/traceability/public/feedback', data);
}
export function uploadTraceabilityImage(data: FormData) {
return requestClient.post<TraceabilityApi.OssFileResult>(
'/traceability/files/upload-image',
data,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
}
export function getTraceabilityUploadToken(data: {
bucketName?: string;
objectName: string;
expiresMinutes?: number;
}) {
return requestClient.post<TraceabilityApi.OssFileResult>(
'/traceability/files/presigned-put',
data,
);
}
export function getTraceabilityTempUrl(data: {
bucketName?: string;
objectName?: string;
objectDir?: string;
expiresSeconds?: number;
}) {
return requestClient.post<TraceabilityApi.OssFileResult>(
'/traceability/files/temp-url',
data,
);
}
export function moveTraceabilityFile(data: {
bucketName?: string;
sourceObjectName: string;
targetObjectName: string;
}) {
return requestClient.post<TraceabilityApi.OssFileResult>(
'/traceability/files/move',
data,
);
}
export function deleteTraceabilityFile(data: {
bucketName?: string;
objectName: string;
}) {
return requestClient.post<boolean>('/traceability/files/delete', data);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,554 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import { Page } from '@vben/common-ui';
import { Button, Card, Col, Empty, Input, message, Row, Tag } from 'ant-design-vue';
import { getTraceabilityBatches, getTraceabilityPublicDetail } from '#/api';
import type { TraceabilityApi } from '#/api';
import { formatFieldValue } from './shared';
const loading = ref(false);
const batches = ref<TraceabilityApi.BatchSummary[]>([]);
const queryCode = ref('');
const detail = ref<null | TraceabilityApi.PublicDetail>(null);
const publicLink = computed(() => detail.value?.batch.publicUrl ?? '');
const qrCode = useQRCode(publicLink, {
errorCorrectionLevel: 'M',
margin: 2,
width: 220,
});
async function loadBatches() {
batches.value = await getTraceabilityBatches();
if (!queryCode.value && batches.value[0]) {
queryCode.value = batches.value[0].batchCode;
await search();
}
}
async function search() {
if (!queryCode.value.trim()) {
message.warning('请输入批次编码');
return;
}
loading.value = true;
try {
detail.value = await getTraceabilityPublicDetail(queryCode.value.trim());
} finally {
loading.value = false;
}
}
function getStatusLabel(status: string) {
if (status === 'completed') return '已完成';
if (status === 'pending') return '进行中';
if (status === 'published') return '已发布';
if (status === 'draft') return '未发布';
return status || '进行中';
}
function getFieldLabel(
fields: TraceabilityApi.FieldDefinition[],
key: string,
) {
return fields.find((field) => field.key === key)?.label || key;
}
function getDisplayEntries(step: TraceabilityApi.BatchStep) {
return Object.entries(step.values).map(([key, value]) => ({
key,
label: getFieldLabel(step.fields, key),
type: step.fields.find((field) => field.key === key)?.type || 'string',
value,
}));
}
onMounted(loadBatches);
</script>
<template>
<Page auto-content-height>
<div class="trace-consumer">
<Card class="panel-card search-panel">
<div class="search-panel__meta">
<div>
<span class="panel-kicker">消费者页预览</span>
<h2>溯源信息预览</h2>
</div>
<div class="search-box">
<Input
v-model:value="queryCode"
placeholder="请输入批次编码,如:TR-2026-000001"
@press-enter="search"
/>
<Button type="primary" :loading="loading" @click="search">
查询
</Button>
</div>
</div>
</Card>
<template v-if="detail">
<Row :gutter="[16, 16]" class="metrics-row">
<Col :lg="6" :sm="12" :xs="24">
<Card class="panel-card stat-panel">
<span>批次名称</span>
<strong>{{ detail.batch.batchName }}</strong>
<small>{{ detail.batch.batchCode }}</small>
</Card>
</Col>
<Col :lg="6" :sm="12" :xs="24">
<Card class="panel-card stat-panel">
<span>当前状态</span>
<strong>{{ detail.batch.status }}</strong>
<small>{{ detail.batch.productName || '未设置产品名称' }}</small>
</Card>
</Col>
<Col :lg="6" :sm="12" :xs="24">
<Card class="panel-card stat-panel">
<span>扫码次数</span>
<strong>{{ detail.batch.scanCount }}</strong>
<small>{{ detail.batch.templateName }}</small>
</Card>
</Col>
<Col :lg="6" :sm="12" :xs="24">
<Card class="panel-card stat-panel">
<span>公开节点数</span>
<strong>{{ detail.publicSections.length }}</strong>
<small>业务节点 {{ detail.businessSections.length }} </small>
</Card>
</Col>
</Row>
<Row :gutter="[16, 16]" class="feature-row">
<Col :lg="8" :xs="24">
<Card class="panel-card qr-panel" title="二维码">
<div class="qr-wrap">
<img :src="qrCode" alt="溯源二维码" class="qr-image" />
<div class="qr-meta">
<strong>扫码查看溯源页</strong>
<p>{{ publicLink }}</p>
</div>
</div>
</Card>
</Col>
<Col :lg="16" :xs="24">
<Card class="panel-card access-panel" title="访问信息">
<div class="access-layout">
<div class="access-main">
<div class="access-main__head">
<span>消费者访问地址</span>
<strong>{{ publicLink }}</strong>
</div>
<p>{{ detail.batch.summary || '该批次已完成发布,可直接用于消费者扫码访问。' }}</p>
</div>
<div class="access-meta">
<div class="access-card">
<span>发布时间</span>
<strong>{{ detail.batch.publishedAt || '未发布' }}</strong>
</div>
<div class="access-card">
<span>产品名称</span>
<strong>{{ detail.batch.productName || '未设置产品名称' }}</strong>
</div>
<div class="access-card">
<span>所属模板</span>
<strong>{{ detail.batch.templateName }}</strong>
</div>
<div class="access-card">
<span>标签</span>
<strong>{{ detail.batch.tags.join('、') || '暂无标签' }}</strong>
</div>
</div>
</div>
</Card>
</Col>
</Row>
<Row :gutter="[16, 16]">
<Col :lg="9" :xs="24">
<Card class="panel-card" title="公开资料区">
<div class="section-stack">
<div
v-for="item in detail.publicSections"
:key="item.id"
class="section-card"
>
<div class="section-card__head">
<strong>{{ item.name }}</strong>
<Tag color="blue">公开</Tag>
</div>
<p>{{ item.description || '企业与资质信息展示区' }}</p>
<div class="kv-grid">
<div
v-for="entry in getDisplayEntries(item)"
:key="entry.key"
class="kv-card"
>
<span>{{ entry.label }}</span>
<img
v-if="entry.type === 'image' && entry.value"
:src="String(entry.value)"
:alt="entry.label"
class="consumer-image"
/>
<strong v-else>{{ formatFieldValue(entry.value) }}</strong>
</div>
</div>
</div>
</div>
</Card>
</Col>
<Col :lg="15" :xs="24">
<Card class="panel-card" title="溯源时间轴">
<div class="timeline">
<div
v-for="(item, index) in detail.businessSections"
:key="item.id"
class="timeline-item"
>
<div class="timeline-rail">
<span class="dot"></span>
<span
v-if="index !== detail.businessSections.length - 1"
class="line"
></span>
</div>
<div class="timeline-card">
<div class="timeline-card__head">
<div>
<h3>{{ item.name }}</h3>
<p>{{ item.description || '流程记录' }}</p>
</div>
<Tag>{{ getStatusLabel(item.status) }}</Tag>
</div>
<div class="kv-grid">
<div
v-for="entry in getDisplayEntries(item)"
:key="entry.key"
class="kv-card"
>
<span>{{ entry.label }}</span>
<img
v-if="entry.type === 'image' && entry.value"
:src="String(entry.value)"
:alt="entry.label"
class="consumer-image"
/>
<strong v-else>{{ formatFieldValue(entry.value) }}</strong>
</div>
</div>
</div>
</div>
</div>
</Card>
</Col>
</Row>
</template>
<Card v-else class="panel-card empty-wrap">
<Empty description="输入批次编码后查看消费者端预览" />
</Card>
</div>
</Page>
</template>
<style scoped>
.trace-consumer {
display: grid;
gap: 16px;
padding: 4px;
}
.panel-card {
border-radius: 22px;
}
.search-panel {
background: linear-gradient(135deg, #ffffff, #f8fbff);
}
.search-panel__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.panel-kicker {
display: inline-flex;
padding: 6px 12px;
border-radius: 999px;
background: #edf3ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 600;
}
.search-panel h2 {
margin: 12px 0 0;
font-size: 28px;
}
.search-box {
display: grid;
grid-template-columns: minmax(0, 360px) auto;
gap: 12px;
width: min(100%, 520px);
}
.stat-panel span,
.kv-card span,
.access-card span {
color: #7d8899;
font-size: 12px;
}
.stat-panel strong,
.access-card strong {
display: block;
margin-top: 8px;
font-size: 18px;
word-break: break-word;
}
.stat-panel small {
display: block;
margin-top: 8px;
color: #98a2b3;
}
.metrics-row :deep(.ant-col),
.feature-row :deep(.ant-col) {
display: flex;
}
.metrics-row .panel-card,
.feature-row .panel-card {
width: 100%;
height: 100%;
}
.stat-panel :deep(.ant-card-body),
.access-panel :deep(.ant-card-body),
.qr-panel :deep(.ant-card-body) {
height: 100%;
}
.stat-panel {
min-height: 138px;
}
.qr-wrap {
display: grid;
align-content: center;
justify-items: center;
gap: 16px;
min-height: 100%;
}
.qr-image {
width: 220px;
height: 220px;
border-radius: 18px;
border: 1px solid #edf1f7;
background: #fff;
padding: 12px;
}
.qr-meta {
text-align: center;
}
.qr-meta strong {
display: block;
font-size: 18px;
}
.qr-meta p {
margin: 10px 0 0;
color: #667085;
word-break: break-all;
}
.access-layout {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.access-main {
border: 1px solid #dfe9fb;
border-radius: 20px;
background: linear-gradient(135deg, #f7faff, #eef4ff);
padding: 18px;
min-width: 0;
}
.access-main__head {
display: grid;
gap: 8px;
}
.access-main span {
color: #7d8899;
font-size: 12px;
}
.access-main strong {
display: block;
color: #1d4ed8;
font-size: 18px;
line-height: 1.6;
word-break: break-all;
}
.access-main p {
margin: 14px 0 0;
color: #5f6b7c;
line-height: 1.7;
word-break: break-word;
}
.access-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
min-width: 0;
}
.access-card {
border: 1px solid #edf1f7;
border-radius: 18px;
background: #fafcff;
padding: 16px;
min-height: 108px;
min-width: 0;
}
.access-card strong {
line-height: 1.6;
word-break: break-word;
}
.section-stack {
display: grid;
gap: 12px;
}
.section-card,
.timeline-card {
border: 1px solid #edf1f7;
border-radius: 18px;
background: #fff;
padding: 16px;
}
.section-card__head,
.timeline-card__head {
display: flex;
justify-content: space-between;
align-items: start;
gap: 12px;
}
.section-card p,
.timeline-card p {
margin: 8px 0 0;
color: #6b7280;
}
.kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.kv-card {
border: 1px solid #eef2f7;
border-radius: 14px;
background: #fafcff;
padding: 12px;
}
.kv-card strong {
display: block;
margin-top: 8px;
white-space: pre-wrap;
word-break: break-word;
}
.consumer-image {
display: block;
width: 100%;
max-height: 220px;
margin-top: 10px;
border: 1px solid #e2e8f0;
border-radius: 14px;
object-fit: cover;
background: #fff;
}
.timeline {
display: grid;
gap: 16px;
}
.timeline-item {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 16px;
}
.timeline-rail {
display: flex;
flex-direction: column;
align-items: center;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #1d4ed8;
box-shadow: 0 0 0 5px rgba(29, 78, 216, 0.1);
}
.line {
width: 2px;
flex: 1;
min-height: 60px;
margin-top: 8px;
background: linear-gradient(180deg, #c9d8ff, transparent);
}
.timeline-card h3 {
margin: 0;
}
.section-card__head strong,
.timeline-card__head h3 {
line-height: 1.4;
}
.empty-wrap {
padding: 48px 12px;
}
@media (max-width: 992px) {
.search-box,
.access-layout,
.access-meta,
.kv-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,868 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
Col,
DatePicker,
Empty,
Input,
message,
Modal,
Row,
Select,
Space,
Steps,
Tag,
} from 'ant-design-vue';
import {
createTraceabilityBatch,
deleteTraceabilityBatch,
getTraceabilityBatch,
getTraceabilityBatches,
getTraceabilityTemplates,
publishTraceabilityBatch,
uploadTraceabilityImage,
updateTraceabilityBatchStep,
} from '#/api';
import type { TraceabilityApi } from '#/api';
import { formatFieldValue, getFieldTypeLabel, normalizeFieldInput } from './shared';
const loading = ref(false);
const selectedBatchId = ref('');
const templates = ref<TraceabilityApi.TemplateSummary[]>([]);
const batches = ref<TraceabilityApi.BatchSummary[]>([]);
const batchDetail = ref<null | TraceabilityApi.BatchDetail>(null);
const stepIndex = ref(0);
const editableStartIndex = ref(0);
const createBatchVisible = ref(false);
const formState = reactive({
batchCode: '',
batchName: '',
coverImage: '',
productName: '',
summary: '',
tagsText: '',
templateId: '',
});
const saving = ref(false);
const uploadingFieldKey = ref('');
const publishedTemplates = computed(() =>
templates.value.filter((item) => item.status === 'active'),
);
const currentStep = computed(
() => batchDetail.value?.steps?.[stepIndex.value] ?? null,
);
const isPublished = computed(() => batchDetail.value?.status === 'published');
const isLockedStep = computed(() => !!currentStep.value?.locked);
const actualCurrentStepIndex = computed(() =>
isPublished.value ? (batchDetail.value?.currentStep ?? 0) : editableStartIndex.value,
);
const isCurrentEditableStep = computed(() => !!batchDetail.value && !isPublished.value);
const isLastStep = computed(() => {
if (!batchDetail.value?.steps?.length) return false;
return actualCurrentStepIndex.value >= batchDetail.value.steps.length - 1;
});
const allStepsCompleted = computed(() =>
(batchDetail.value?.steps ?? []).every((item) => item.status === 'completed'),
);
const stepActionText = computed(() =>
isLastStep.value ? '保存并发布' : '保存并切换至下一节点',
);
async function loadLists() {
loading.value = true;
try {
templates.value = await getTraceabilityTemplates();
batches.value = await getTraceabilityBatches();
if (!selectedBatchId.value && batches.value[0]) {
await selectBatch(batches.value[0].id);
}
} finally {
loading.value = false;
}
}
function applyBatch(detail: TraceabilityApi.BatchDetail) {
batchDetail.value = structuredClone(detail);
formState.batchCode = detail.batchCode;
formState.batchName = detail.batchName;
formState.coverImage = detail.coverImage;
formState.productName = detail.productName;
formState.summary = detail.summary;
formState.tagsText = detail.tags.join(',');
formState.templateId = detail.templateId;
stepIndex.value = detail.currentStep ?? 0;
editableStartIndex.value = detail.currentStep ?? 0;
}
async function selectBatch(id: string) {
selectedBatchId.value = id;
const detail = await getTraceabilityBatch(id);
applyBatch(detail);
}
function openCreateBatchModal() {
if (publishedTemplates.value.length === 0) {
message.warning('请先在管理员页发布模板后,再新建批次');
return;
}
selectedBatchId.value = '';
batchDetail.value = null;
formState.batchCode = `TR-${new Date().getFullYear()}-${String(Date.now()).slice(-6)}`;
formState.batchName = '新建批次';
formState.coverImage = '';
formState.productName = '';
formState.summary = '';
formState.tagsText = '';
formState.templateId = publishedTemplates.value[0]?.id ?? '';
stepIndex.value = 0;
createBatchVisible.value = true;
}
async function createBatch() {
if (!formState.templateId) {
message.warning('请先选择模板');
return;
}
saving.value = true;
try {
const detail = await createTraceabilityBatch({
batchCode: formState.batchCode,
batchName: formState.batchName,
coverImage: formState.coverImage,
productName: formState.productName,
summary: formState.summary,
tags: formState.tagsText
.split(',')
.map((item) => item.trim())
.filter(Boolean),
templateId: formState.templateId,
});
message.success('批次已创建');
createBatchVisible.value = false;
await loadLists();
if (detail?.id) {
await selectBatch(detail.id);
} else if (batches.value[0]) {
await selectBatch(batches.value[0].id);
}
} finally {
saving.value = false;
}
}
function getBatchStatusText(status: string) {
if (status === 'published') return '已发布';
if (status === 'draft') return '未发布';
return status || '进行中';
}
function getStepStatusText(index: number) {
if (allStepsCompleted.value) {
return '已完成';
}
return index < actualCurrentStepIndex.value ? '已完成' : '进行中';
}
function updateFieldValue(field: TraceabilityApi.FieldDefinition, value: any) {
if (!currentStep.value) return;
currentStep.value.values[field.key] = normalizeFieldInput(field, value);
}
function sanitizeIntegerInput(value: string) {
const cleaned = value.replaceAll(/[^\d-]/g, '');
const hasLeadingMinus = cleaned.startsWith('-');
const unsigned = hasLeadingMinus ? cleaned.slice(1).replaceAll('-', '') : cleaned.replaceAll('-', '');
return hasLeadingMinus ? `-${unsigned}` : unsigned;
}
function sanitizeDecimalInput(value: string) {
const cleaned = value.replaceAll(/[^\d.-]/g, '');
const firstDot = cleaned.indexOf('.');
const normalizedDot =
firstDot === -1
? cleaned
: `${cleaned.slice(0, firstDot + 1)}${cleaned
.slice(firstDot + 1)
.replaceAll('.', '')}`;
const firstMinus = normalizedDot.indexOf('-');
return firstMinus <= 0
? normalizedDot
: `-${normalizedDot.replaceAll('-', '')}`;
}
function getFieldUploadKey(field: TraceabilityApi.FieldDefinition) {
return `${currentStep.value?.id ?? 'step'}:${field.key}`;
}
function getFieldInputId(field: TraceabilityApi.FieldDefinition) {
return `traceability-upload-${getFieldUploadKey(field)}`;
}
function triggerImageSelect(field: TraceabilityApi.FieldDefinition) {
const input = document.getElementById(getFieldInputId(field));
input?.click();
}
function clearImageValue(field: TraceabilityApi.FieldDefinition) {
updateFieldValue(field, '');
}
async function handleImageUpload(field: TraceabilityApi.FieldDefinition, event: Event) {
const files = (event.target as HTMLInputElement).files;
const file = files?.[0];
if (!file || !currentStep.value || !selectedBatchId.value) return;
const uploadKey = getFieldUploadKey(field);
uploadingFieldKey.value = uploadKey;
try {
const formData = new FormData();
formData.append('file', file);
formData.append(
'objectDir',
`traceability/${selectedBatchId.value}/${currentStep.value.id}/${field.key}`,
);
const result = await uploadTraceabilityImage(formData);
updateFieldValue(field, result.tempUrl || result.objectName);
message.success('图片上传成功');
} catch {
message.error('图片上传失败');
} finally {
uploadingFieldKey.value = '';
(event.target as HTMLInputElement).value = '';
}
}
function removeBatch(id: string) {
Modal.confirm({
title: '删除批次',
content: '删除后该批次的填报记录和发布信息都会一起清除,是否继续?',
async onOk() {
await deleteTraceabilityBatch(id);
message.success('批次已删除');
if (selectedBatchId.value === id) {
selectedBatchId.value = '';
batchDetail.value = null;
}
await loadLists();
},
});
}
async function saveStep() {
if (!selectedBatchId.value || !currentStep.value) return;
if (!isCurrentEditableStep.value || isPublished.value) {
message.warning('请先完成当前进行中的节点');
return;
}
saving.value = true;
try {
const detail = await updateTraceabilityBatchStep(
selectedBatchId.value,
currentStep.value.id,
{
completedAt: new Date().toISOString(),
operatorName: currentStep.value.operatorName,
status: 'completed',
values: currentStep.value.values,
},
);
applyBatch(detail);
if (detail.steps.every((item) => item.status === 'completed')) {
const published = await publishTraceabilityBatch(selectedBatchId.value);
applyBatch(published);
message.success('最后一个节点已保存并发布');
} else {
const nextIndex = detail.currentStep ?? 0;
editableStartIndex.value = nextIndex;
stepIndex.value = nextIndex;
message.success('当前节点已保存,已切换至下一节点');
}
await loadLists();
} finally {
saving.value = false;
}
}
onMounted(async () => {
await loadLists();
});
</script>
<template>
<Page auto-content-height>
<div class="trace-operator">
<Row :gutter="[16, 16]">
<Col :lg="7" :md="8" :sm="24" :xs="24">
<Card :loading="loading" class="panel-card batch-panel-card" title="批次流程">
<template #extra>
<Button type="primary" @click="openCreateBatchModal">新建批次</Button>
</template>
<div class="batch-list">
<button
v-for="item in batches"
:key="item.id"
class="batch-card"
:class="{ active: item.id === selectedBatchId }"
@click="selectBatch(item.id)"
>
<div class="batch-card__header">
<strong>{{ item.batchName }}</strong>
<div class="batch-card__actions">
<Tag>{{ getBatchStatusText(item.status) }}</Tag>
<Button danger size="small" type="link" @click.stop="removeBatch(item.id)">
删除
</Button>
</div>
</div>
<p>{{ item.batchCode }}</p>
<div class="batch-card__meta">
<span>{{ item.templateName }}</span>
<span>{{ item.productName || '未设置产品名称' }}</span>
<span>扫码 {{ item.scanCount }} </span>
</div>
<small>{{ item.summary || '暂无批次概述' }}</small>
</button>
</div>
</Card>
</Col>
<Col :lg="17" :md="16" :sm="24" :xs="24">
<Space direction="vertical" size="middle" style="width: 100%">
<Card v-if="batchDetail?.publishedAt" class="panel-card" title="发布信息">
<div class="publish-panel">
<div>
<span>溯源码</span>
<strong>{{ batchDetail.batchCode }}</strong>
</div>
<div>
<span>消费者访问地址</span>
<strong>{{ batchDetail.publicUrl }}</strong>
</div>
<div>
<span>发布时间</span>
<strong>{{ batchDetail.publishedAt || '未发布' }}</strong>
</div>
<div>
<span>当前状态</span>
<strong>{{ getBatchStatusText(batchDetail.status) }}</strong>
</div>
</div>
</Card>
<Card class="panel-card" title="节点填报">
<template #extra>
<Button
v-if="!isPublished"
type="primary"
:disabled="!currentStep || !isCurrentEditableStep"
:loading="saving"
@click="saveStep"
>
{{ stepActionText }}
</Button>
</template>
<template v-if="batchDetail">
<Steps
class="step-strip"
:class="{ 'step-strip--published': isPublished }"
:current="stepIndex"
size="small"
@change="
(value) => {
stepIndex = value;
if (!isPublished) {
editableStartIndex = value;
}
}
"
>
<Steps.Step
v-for="(item, index) in batchDetail.steps"
:key="item.id"
:title="item.name"
:description="getStepStatusText(index)"
/>
</Steps>
<div
v-if="currentStep"
class="step-editor"
:class="{ 'step-editor--published': isPublished }"
>
<div class="step-header">
<div>
<h3>{{ currentStep.name }}</h3>
<p>{{ currentStep.description || '请填报此环节的过程记录。' }}</p>
<small class="step-hint">
{{
isPublished
? '当前批次已发布,溯源链已锁定为只读。'
: isLockedStep
? '当前节点来自节点库,字段和值固定,不可修改。'
: isCurrentEditableStep
? '当前节点可填写并继续流转。'
: '当前查看的是非进行中节点,仅供浏览。'
}}
</small>
</div>
<Tag color="blue">
{{ currentStep.category === 'public' ? '公开节点' : '业务节点' }}
</Tag>
</div>
<Row :gutter="[16, 16]">
<Col :md="12" :xs="24">
<label class="field-label">操作人</label>
<Input v-model:value="currentStep.operatorName" :disabled="!isCurrentEditableStep" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">节点状态</label>
<div class="readonly-box">{{ getStepStatusText(stepIndex) }}</div>
</Col>
</Row>
<Row :gutter="[16, 16]" class="dynamic-fields">
<Col
v-for="field in currentStep.fields"
:key="field.key"
:md="12"
:xs="24"
>
<div class="field-entry">
<div class="field-entry__head">
<label class="field-label">{{ field.label }}</label>
<span class="field-type-tag">{{ getFieldTypeLabel(field.type) }}</span>
</div>
<div class="field-entry__body">
<Select
v-if="field.type === 'select'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
:value="currentStep.values[field.key]"
style="width: 100%"
@update:value="(value) => updateFieldValue(field, value)"
/>
<Select
v-else-if="field.type === 'multi_select'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
:value="currentStep.values[field.key]"
mode="multiple"
style="width: 100%"
@update:value="(value) => updateFieldValue(field, value)"
/>
<Input
v-else-if="field.type === 'integer'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:placeholder="field.placeholder || '请输入整数'"
:value="String(currentStep.values[field.key] ?? '')"
style="width: 100%"
@update:value="
(value) =>
updateFieldValue(field, sanitizeIntegerInput(String(value ?? '')))
"
/>
<Input
v-else-if="field.type === 'decimal'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:placeholder="field.placeholder || '请输入小数'"
:value="String(currentStep.values[field.key] ?? '')"
style="width: 100%"
@update:value="
(value) =>
updateFieldValue(field, sanitizeDecimalInput(String(value ?? '')))
"
/>
<DatePicker
v-else-if="field.type === 'date'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:value="currentStep.values[field.key]"
style="width: 100%"
value-format="YYYY-MM-DD"
@update:value="(value) => updateFieldValue(field, value)"
/>
<DatePicker
v-else-if="field.type === 'datetime'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:value="currentStep.values[field.key]"
format="YYYY-MM-DD HH:mm:ss"
show-time
style="width: 100%"
value-format="YYYY-MM-DD HH:mm:ss"
@update:value="(value) => updateFieldValue(field, value)"
/>
<Input
v-else-if="field.type === 'link'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:placeholder="field.placeholder || '请输入链接地址'"
:value="currentStep.values[field.key]"
@update:value="(value) => updateFieldValue(field, value)"
/>
<Input
v-else-if="field.type === 'string'"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:placeholder="field.placeholder || '请输入内容'"
:value="currentStep.values[field.key]"
@update:value="(value) => updateFieldValue(field, value)"
/>
<div
v-else-if="field.type === 'image'"
class="placeholder-uploader"
>
<strong>上传节点图片</strong>
<p>支持上传后直接回填图片地址消费者端和预览页都可直接查看</p>
<div
v-if="currentStep.values[field.key]"
class="image-preview-wrap"
>
<img
:src="String(currentStep.values[field.key])"
alt="节点图片"
class="image-preview"
/>
</div>
<div class="upload-trigger">
<input
:id="getFieldInputId(field)"
accept="image/*"
class="upload-input"
type="file"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
@change="(event) => handleImageUpload(field, event)"
/>
<Button
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:loading="uploadingFieldKey === getFieldUploadKey(field)"
size="small"
type="primary"
@click="triggerImageSelect(field)"
>
{{ currentStep.values[field.key] ? '重新上传' : '选择图片' }}
</Button>
<Button
v-if="currentStep.values[field.key]"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
size="small"
@click="clearImageValue(field)"
>
移除图片
</Button>
</div>
</div>
<div
v-else-if="field.type === 'video_url'"
class="placeholder-uploader"
>
<strong>视频控件模板</strong>
<p>这里先预留视频控件位置后续你可以补充视频上传或选择逻辑</p>
</div>
<Input.TextArea
v-else
:auto-size="{ minRows: 3, maxRows: 5 }"
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
:placeholder="field.placeholder || '请输入内容'"
:value="currentStep.values[field.key]"
@update:value="(value) => updateFieldValue(field, value)"
/>
<div class="field-preview">
当前值{{ formatFieldValue(currentStep.values[field.key]) }}
</div>
</div>
</div>
</Col>
</Row>
</div>
</template>
<Empty v-else description="先新建批次或从左侧选择批次" />
</Card>
</Space>
</Col>
</Row>
</div>
<Modal
v-model:open="createBatchVisible"
title="新建批次"
ok-text="创建批次"
cancel-text="取消"
:confirm-loading="saving"
@ok="createBatch"
>
<Row :gutter="[16, 16]">
<Col :span="24">
<label class="field-label">模板</label>
<Select
v-model:value="formState.templateId"
:options="publishedTemplates.map((item) => ({ label: item.name, value: item.id }))"
style="width: 100%"
/>
</Col>
<Col :md="12" :xs="24">
<label class="field-label">批次名称</label>
<Input v-model:value="formState.batchName" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">批次编码</label>
<Input v-model:value="formState.batchCode" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">产品名称</label>
<Input v-model:value="formState.productName" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">封面图</label>
<Input v-model:value="formState.coverImage" />
</Col>
<Col :md="12" :xs="24">
<label class="field-label">标签</label>
<Input v-model:value="formState.tagsText" placeholder="用逗号分隔" />
</Col>
<Col :span="24">
<label class="field-label">批次概述</label>
<Input.TextArea
v-model:value="formState.summary"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</Col>
</Row>
</Modal>
</Page>
</template>
<style scoped>
.trace-operator {
padding: 4px;
}
.panel-card {
border-radius: 18px;
}
.batch-panel-card {
height: 100%;
}
.batch-panel-card :deep(.ant-card-body) {
height: calc(100% - 57px);
}
.batch-list {
display: grid;
gap: 12px;
}
.batch-card {
width: 100%;
border: 1px solid #edf1f7;
background: #fff;
border-radius: 16px;
padding: 16px;
text-align: left;
}
.batch-card.active {
border-color: #adc4ff;
background: #f5f8ff;
}
.batch-card__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.batch-card__actions {
display: flex;
align-items: center;
gap: 6px;
}
.batch-card p {
margin: 10px 0 8px;
font-weight: 600;
}
.batch-card__meta {
display: grid;
gap: 4px;
margin-bottom: 8px;
}
.batch-card span,
.batch-card small {
color: #8b96a8;
font-size: 12px;
}
.field-label {
display: block;
margin-bottom: 8px;
color: #556070;
font-size: 13px;
font-weight: 600;
}
.step-strip {
margin-bottom: 20px;
overflow: auto;
}
.step-editor {
border-top: 1px solid #f0f2f5;
padding-top: 20px;
}
.step-strip--published,
.step-editor--published {
opacity: 0.58;
}
.step-header {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
}
.step-header h3 {
margin: 0 0 8px;
}
.step-header p {
margin: 0;
color: #6b7280;
}
.step-hint {
display: block;
margin-top: 8px;
color: #1d4ed8;
}
.dynamic-fields {
margin-top: 8px;
}
.publish-panel {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.publish-panel div,
.field-entry__body,
.readonly-box,
.placeholder-uploader {
border: 1px solid #edf1f7;
border-radius: 16px;
padding: 16px;
background: #fafcff;
}
.publish-panel span,
.field-entry__body small,
.field-modal__meta {
color: #7d8899;
font-size: 12px;
}
.publish-panel strong {
display: block;
margin-top: 8px;
font-size: 16px;
word-break: break-all;
}
.readonly-box {
min-height: 54px;
display: flex;
align-items: center;
color: #556070;
}
.field-entry {
display: grid;
gap: 8px;
}
.field-entry__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.field-entry__body {
min-height: 120px;
display: grid;
gap: 10px;
}
.field-entry__body strong {
display: block;
margin: 8px 0;
color: #1f2937;
white-space: pre-wrap;
word-break: break-word;
}
.field-type-tag {
display: inline-flex;
padding: 4px 10px;
border-radius: 999px;
background: #edf3ff;
color: #1d4ed8;
font-size: 12px;
}
.placeholder-uploader strong {
display: block;
margin-bottom: 8px;
}
.upload-trigger {
display: inline-flex;
margin-top: 4px;
}
.upload-input {
display: none;
}
.image-preview-wrap {
margin: 4px 0 2px;
}
.image-preview {
display: block;
max-width: 100%;
max-height: 220px;
border: 1px solid #dbe3f0;
border-radius: 14px;
object-fit: cover;
background: #fff;
}
.field-preview {
color: #7d8899;
font-size: 12px;
}
.placeholder-uploader p {
margin: 0;
color: #6b7280;
line-height: 1.7;
}
</style>
@@ -0,0 +1,225 @@
import type { TraceabilityApi } from '#/api';
export interface TraceabilityNodeLibraryItem {
id: string;
category: 'business' | 'public';
name: string;
description: string;
consumerVisible: boolean;
fields: TraceabilityApi.FieldDefinition[];
}
export 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' },
];
export const nodeLibraryPresets: TraceabilityNodeLibraryItem[] = [
{
id: 'business-production',
category: 'business',
name: '生产加工节点',
description: '记录原料、工艺、加工批次等业务过程信息。',
consumerVisible: true,
fields: [
createField('process_name', '工艺名称'),
createField('operator', '负责人'),
createField('production_date', '生产日期', 'date'),
createField('remark', '备注'),
],
},
{
id: 'business-quality',
category: 'business',
name: '质检检验节点',
description: '记录质检结果、检验员和检验时间。',
consumerVisible: true,
fields: [
createField('inspector', '检验员'),
createField('inspection_date', '检验日期', 'date'),
createField('inspection_result', '检验结果', 'select', {
options: ['合格', '不合格', '复检中'],
}),
createField('inspection_note', '检验说明'),
],
},
{
id: 'public-company',
category: 'public',
name: '企业信息节点',
description: '面向消费者展示企业名称、产地和联系方式等信息。',
consumerVisible: true,
fields: [
createField('company_name', '企业名称'),
createField('origin', '产地'),
createField('contact_phone', '联系电话'),
createField('company_intro', '企业简介'),
],
},
{
id: 'public-certification',
category: 'public',
name: '资质证书节点',
description: '展示认证证书、证书编号和有效期。',
consumerVisible: true,
fields: [
createField('certificate_name', '证书名称'),
createField('certificate_no', '证书编号'),
createField('valid_until', '有效期', 'date'),
createField('certificate_image', '证书图片', 'image'),
],
},
];
export function createField(
key: string,
label: string,
type: string = 'string',
extra: Partial<TraceabilityApi.FieldDefinition> = {},
): TraceabilityApi.FieldDefinition {
return {
key,
label,
type,
required: false,
visible: true,
placeholder: '',
defaultValue: '',
options: [],
...extra,
};
}
export function createEmptyField(): TraceabilityApi.FieldDefinition {
return {
key: `field_${Date.now()}`,
label: '新字段',
type: 'string',
required: false,
visible: true,
placeholder: '',
defaultValue: '',
options: [],
};
}
export function createEmptyNode(
category: TraceabilityApi.TemplateNode['category'] = 'business',
): TraceabilityApi.TemplateNode {
return {
category,
name: category === 'public' ? '公开资料节点' : '业务流程节点',
description: '',
consumerVisible: true,
fields: [createEmptyField()],
};
}
export function cloneNodeFromLibrary(
preset: TraceabilityNodeLibraryItem,
): TraceabilityApi.TemplateNode {
return {
category: preset.category,
name: preset.name,
description: preset.description,
locked: true,
consumerVisible: preset.consumerVisible,
fields: preset.fields.map((field) => ({
...field,
options: [...(field.options ?? [])],
})),
};
}
export function cloneTemplateForSave(
template: Partial<TraceabilityApi.TemplateDetail>,
) {
return {
name: template.name ?? '',
description: template.description ?? '',
productName: template.productName ?? '',
industryName: template.industryName ?? '',
coverImage: template.coverImage ?? '',
themeColor: template.themeColor ?? '#1f4fd6',
status: template.status ?? 'draft',
nodes: (template.nodes ?? []).map((node) => ({
category: node.category ?? 'business',
name: node.name ?? '',
description: node.description ?? '',
locked: node.locked ?? false,
consumerVisible: node.consumerVisible ?? true,
fields: (node.fields ?? []).map((field) => ({
key: field.key,
label: field.label,
type: field.type ?? 'string',
required: field.required ?? false,
visible: field.visible ?? true,
placeholder: field.placeholder ?? '',
defaultValue: field.defaultValue ?? '',
options: field.options ?? [],
})),
})),
};
}
export function formatFieldValue(value: any) {
if (value === null || value === undefined || value === '') {
return '未填写';
}
if (Array.isArray(value)) {
return value.join('、');
}
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
return String(value);
}
export function normalizeFieldInput(
field: TraceabilityApi.FieldDefinition,
value: any,
) {
if (field.type === 'integer') {
if (value === '' || value === null || value === undefined) {
return null;
}
return Number.isInteger(value) ? value : Math.trunc(Number(value));
}
if (field.type === 'decimal') {
if (value === '' || value === null || value === undefined) {
return null;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
if (field.type === 'multi_select') {
return Array.isArray(value) ? value : [];
}
return value;
}
export function getFieldTypeLabel(type: string) {
return fieldTypeOptions.find((item) => item.value === type)?.label ?? type;
}
export const groupedNodeLibrary = {
business: nodeLibraryPresets
.filter((item) => item.category === 'business')
.map((item) => item),
public: nodeLibraryPresets
.filter((item) => item.category === 'public')
.map((item) => item),
};