溯源系统初版
This commit is contained in:
@@ -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/
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
kotlin.code.style=official
|
||||
kotlin_version=2.3.0
|
||||
ktor_version=3.4.2
|
||||
logback_version=1.4.14
|
||||
BIN
Binary file not shown.
@@ -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
@@ -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" "$@"
|
||||
Vendored
+93
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
rootProject.name = "f10"
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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('/'),
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 灵活溯源系统 MVP
|
||||
|
||||
这是一个纯前端的 H5 演示版,直接打开 `index.html` 就能看效果。
|
||||
|
||||
## 已包含内容
|
||||
|
||||
- 管理员端:节点库、字段编辑、模板创建、公共资料块复用
|
||||
- 业务员端:基于模板新建批次、逐节点填报、二维码预览
|
||||
- 消费者端:时间轴溯源展示、企业信息、县域情况、有机证书
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 直接双击打开 `index.html`
|
||||
2. 或者用任意静态文件服务打开 `trace-demo` 目录
|
||||
|
||||
## 说明
|
||||
|
||||
- 演示数据保存在浏览器 `localStorage`
|
||||
- 点击“重置演示数据”可以恢复默认内容
|
||||
- 当前二维码是本地样式模拟,后续可替换成真实二维码
|
||||
+1016
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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),
|
||||
};
|
||||
Reference in New Issue
Block a user