From 0a43f5e4b959b9720eea30d84d10f11c38145926 Mon Sep 17 00:00:00 2001 From: BBIT-Kai <2911862937@qq.com> Date: Fri, 10 Apr 2026 18:51:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=BA=AF=E6=BA=90=E7=B3=BB=E7=BB=9F=E5=88=9D?= =?UTF-8?q?=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- f10/.gitignore | 36 + f10/build.gradle.kts | 41 + f10/gradle.properties | 4 + f10/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes f10/gradle/wrapper/gradle-wrapper.properties | 7 + f10/gradlew | 248 +++ f10/gradlew.bat | 93 + f10/settings.gradle.kts | 7 + f10/src/main/kotlin/Application.kt | 24 + f10/src/main/kotlin/HTTP.kt | 27 + f10/src/main/kotlin/Routing.kt | 104 ++ f10/src/main/kotlin/Serialization.kt | 18 + f10/src/main/kotlin/Templating.kt | 12 + f10/src/main/kotlin/TraceabilityClient.kt | 75 + f10/src/main/kotlin/TraceabilityModels.kt | 130 ++ f10/src/main/kotlin/TraceabilityService.kt | 111 ++ f10/src/main/resources/application.yaml | 10 + f10/src/main/resources/logback.xml | 12 + .../main/resources/static/traceability.css | 376 ++++ f10/src/main/resources/templates/error.ftl | 22 + .../main/resources/templates/traceability.ftl | 203 +++ f10/src/test/kotlin/ApplicationTest.kt | 21 + .../ink/snowflake/server/Application.kt | 5 + .../server/controller/Traceability.kt | 475 +++++ .../model/database/TraceabilityTables.kt | 72 + .../model/request/TraceabilityRequest.kt | 112 ++ .../model/response/TraceabilityResponse.kt | 155 ++ .../ink/snowflake/server/utils/AppConfig.kt | 10 + .../ink/snowflake/server/utils/OSSUtils.kt | 170 +- .../server/utils/dao/TraceabilityDao.kt | 516 ++++++ ktor/src/main/resources/application.yaml | 11 + trace-demo/README.md | 20 + trace-demo/app.js | 1016 +++++++++++ trace-demo/index.html | 16 + trace-demo/styles.css | 317 ++++ .../web-antd/src/api/traceability/index.ts | 278 +++ .../web-antd/src/views/traceability/admin.vue | 1539 +++++++++++++++++ .../src/views/traceability/consumer.vue | 554 ++++++ .../src/views/traceability/operator.vue | 868 ++++++++++ .../web-antd/src/views/traceability/shared.ts | 225 +++ 40 files changed, 7910 insertions(+), 30 deletions(-) create mode 100644 f10/.gitignore create mode 100644 f10/build.gradle.kts create mode 100644 f10/gradle.properties create mode 100644 f10/gradle/wrapper/gradle-wrapper.jar create mode 100644 f10/gradle/wrapper/gradle-wrapper.properties create mode 100644 f10/gradlew create mode 100644 f10/gradlew.bat create mode 100644 f10/settings.gradle.kts create mode 100644 f10/src/main/kotlin/Application.kt create mode 100644 f10/src/main/kotlin/HTTP.kt create mode 100644 f10/src/main/kotlin/Routing.kt create mode 100644 f10/src/main/kotlin/Serialization.kt create mode 100644 f10/src/main/kotlin/Templating.kt create mode 100644 f10/src/main/kotlin/TraceabilityClient.kt create mode 100644 f10/src/main/kotlin/TraceabilityModels.kt create mode 100644 f10/src/main/kotlin/TraceabilityService.kt create mode 100644 f10/src/main/resources/application.yaml create mode 100644 f10/src/main/resources/logback.xml create mode 100644 f10/src/main/resources/static/traceability.css create mode 100644 f10/src/main/resources/templates/error.ftl create mode 100644 f10/src/main/resources/templates/traceability.ftl create mode 100644 f10/src/test/kotlin/ApplicationTest.kt create mode 100644 ktor/src/main/kotlin/ink/snowflake/server/controller/Traceability.kt create mode 100644 ktor/src/main/kotlin/ink/snowflake/server/model/database/TraceabilityTables.kt create mode 100644 ktor/src/main/kotlin/ink/snowflake/server/model/request/TraceabilityRequest.kt create mode 100644 ktor/src/main/kotlin/ink/snowflake/server/model/response/TraceabilityResponse.kt create mode 100644 ktor/src/main/kotlin/ink/snowflake/server/utils/dao/TraceabilityDao.kt create mode 100644 trace-demo/README.md create mode 100644 trace-demo/app.js create mode 100644 trace-demo/index.html create mode 100644 trace-demo/styles.css create mode 100644 vue2/apps/web-antd/src/api/traceability/index.ts create mode 100644 vue2/apps/web-antd/src/views/traceability/admin.vue create mode 100644 vue2/apps/web-antd/src/views/traceability/consumer.vue create mode 100644 vue2/apps/web-antd/src/views/traceability/operator.vue create mode 100644 vue2/apps/web-antd/src/views/traceability/shared.ts diff --git a/f10/.gitignore b/f10/.gitignore new file mode 100644 index 0000000..c426c32 --- /dev/null +++ b/f10/.gitignore @@ -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/ \ No newline at end of file diff --git a/f10/build.gradle.kts b/f10/build.gradle.kts new file mode 100644 index 0000000..325fc0d --- /dev/null +++ b/f10/build.gradle.kts @@ -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") +} diff --git a/f10/gradle.properties b/f10/gradle.properties new file mode 100644 index 0000000..fe0d9b4 --- /dev/null +++ b/f10/gradle.properties @@ -0,0 +1,4 @@ +kotlin.code.style=official +kotlin_version=2.3.0 +ktor_version=3.4.2 +logback_version=1.4.14 diff --git a/f10/gradle/wrapper/gradle-wrapper.jar b/f10/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/f10/gradle/wrapper/gradle-wrapper.properties b/f10/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4312465 --- /dev/null +++ b/f10/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/f10/gradlew b/f10/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/f10/gradlew @@ -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" "$@" diff --git a/f10/gradlew.bat b/f10/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/f10/gradlew.bat @@ -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 diff --git a/f10/settings.gradle.kts b/f10/settings.gradle.kts new file mode 100644 index 0000000..16f87a4 --- /dev/null +++ b/f10/settings.gradle.kts @@ -0,0 +1,7 @@ +rootProject.name = "f10" + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} diff --git a/f10/src/main/kotlin/Application.kt b/f10/src/main/kotlin/Application.kt new file mode 100644 index 0000000..ce74558 --- /dev/null +++ b/f10/src/main/kotlin/Application.kt @@ -0,0 +1,24 @@ +package com.bbitcn + +import io.ktor.server.application.* + +fun main(args: Array) { + 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() +} diff --git a/f10/src/main/kotlin/HTTP.kt b/f10/src/main/kotlin/HTTP.kt new file mode 100644 index 0000000..b91e72a --- /dev/null +++ b/f10/src/main/kotlin/HTTP.kt @@ -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) +} diff --git a/f10/src/main/kotlin/Routing.kt b/f10/src/main/kotlin/Routing.kt new file mode 100644 index 0000000..6468841 --- /dev/null +++ b/f10/src/main/kotlin/Routing.kt @@ -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("traceability.service") +} + +fun Application.configureRouting() { + install(StatusPages) { + exception { 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] +} diff --git a/f10/src/main/kotlin/Serialization.kt b/f10/src/main/kotlin/Serialization.kt new file mode 100644 index 0000000..7340dc4 --- /dev/null +++ b/f10/src/main/kotlin/Serialization.kt @@ -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 + }, + ) + } +} diff --git a/f10/src/main/kotlin/Templating.kt b/f10/src/main/kotlin/Templating.kt new file mode 100644 index 0000000..077274d --- /dev/null +++ b/f10/src/main/kotlin/Templating.kt @@ -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") + } +} diff --git a/f10/src/main/kotlin/TraceabilityClient.kt b/f10/src/main/kotlin/TraceabilityClient.kt new file mode 100644 index 0000000..ef4f21d --- /dev/null +++ b/f10/src/main/kotlin/TraceabilityClient.kt @@ -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>() + return payload.data + } + + suspend fun submitFeedback(request: SubmitTraceabilityFeedbackRequest): ApiResponse { + 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() + } +} diff --git a/f10/src/main/kotlin/TraceabilityModels.kt b/f10/src/main/kotlin/TraceabilityModels.kt new file mode 100644 index 0000000..aa5b7ad --- /dev/null +++ b/f10/src/main/kotlin/TraceabilityModels.kt @@ -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( + 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 = 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 = 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, + val status: String, + val currentStep: Int, + val scanCount: Int, + val publicUrl: String, + val steps: List, + val updatedAt: String, + val publishedAt: String = "", +) + +@Serializable +data class TraceabilityPublicDetailResponse( + val batch: TraceBatchDetailResponse, + val companySectionTitle: String = "企业公开资料", + val publicSections: List, + val businessSections: List, +) + +@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, +) + +data class TimelineSectionView( + val id: String, + val name: String, + val description: String, + val status: String, + val completedAt: String, + val entries: List, +) + +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, + val businessSections: List, +) diff --git a/f10/src/main/kotlin/TraceabilityService.kt b/f10/src/main/kotlin/TraceabilityService.kt new file mode 100644 index 0000000..2832b46 --- /dev/null +++ b/f10/src/main/kotlin/TraceabilityService.kt @@ -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 { + 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 { + 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('/'), + ) +} diff --git a/f10/src/main/resources/application.yaml b/f10/src/main/resources/application.yaml new file mode 100644 index 0000000..bc132ee --- /dev/null +++ b/f10/src/main/resources/application.yaml @@ -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" diff --git a/f10/src/main/resources/logback.xml b/f10/src/main/resources/logback.xml new file mode 100644 index 0000000..aadef5d --- /dev/null +++ b/f10/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + \ No newline at end of file diff --git a/f10/src/main/resources/static/traceability.css b/f10/src/main/resources/static/traceability.css new file mode 100644 index 0000000..8955ce0 --- /dev/null +++ b/f10/src/main/resources/static/traceability.css @@ -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%; + } +} diff --git a/f10/src/main/resources/templates/error.ftl b/f10/src/main/resources/templates/error.ftl new file mode 100644 index 0000000..82b0cfe --- /dev/null +++ b/f10/src/main/resources/templates/error.ftl @@ -0,0 +1,22 @@ + + + + + + 未找到溯源信息 + + + +

+
+
+
+

未找到溯源信息

+

${message}

+
+
+ 返回服务首页 +
+
+ + diff --git a/f10/src/main/resources/templates/traceability.ftl b/f10/src/main/resources/templates/traceability.ftl new file mode 100644 index 0000000..8cd1a4b --- /dev/null +++ b/f10/src/main/resources/templates/traceability.ftl @@ -0,0 +1,203 @@ + + + + + + ${page.batchName} - 溯源信息 + + + +
+
+
+

${page.batchName}

+

${page.summary}

+
+
+ 批次编码 + ${page.code} +
+
+ 产品名称 + ${page.productName} +
+
+ 所属模板 + ${page.templateName} +
+
+ 累计访问 + ${page.scanCount} +
+
+
+ <#if page.coverImage?has_content> +
+ ${page.productName} +
+ +
+
+ 发布时间 + ${page.publishedAt} +
+
+ 标签 + ${page.tagsText} +
+
+
+ + <#if feedbackMessage?has_content> +
${feedbackMessage}
+ + +
+
+ + + +
+ +
+
+
+

溯源链

+

按业务流程顺序查看本批次的处理过程与留痕信息。

+
+
+ <#if page.businessSections?size gt 0> +
+ <#list page.businessSections as section> +
+
+ + +
+
+
+
+

${section.name}

+

${section.description}

+
+
+
+ <#list section.entries as entry> +
+ ${entry.label} + <#if entry.type == "image" && entry.value != "未填写"> + ${entry.label} + <#else> + ${entry.value} + +
+ +
+
+
+ +
+ <#else> +
当前批次还没有可展示的业务流程节点。
+ +
+ +
+
+
+

公开资料

+

面向消费者展示的企业资料、资质证明及其他公开信息。

+
+
+ <#if page.publicSections?size gt 0> +
+ <#list page.publicSections as section> +
+
+

${section.name}

+
+

${section.description}

+
+ <#list section.entries as entry> +
+ ${entry.label} + <#if entry.type == "image" && entry.value != "未填写"> + ${entry.label} + <#else> + ${entry.value} + +
+ +
+
+ +
+ <#else> +
当前批次还没有可展示的公开资料。
+ +
+ +
+
+
+

反馈与投诉

+

如发现信息异常、商品质量问题,或有建议,可直接提交。

+
+
+ +
+
+
+ + + + diff --git a/f10/src/test/kotlin/ApplicationTest.kt b/f10/src/test/kotlin/ApplicationTest.kt new file mode 100644 index 0000000..a4f2823 --- /dev/null +++ b/f10/src/test/kotlin/ApplicationTest.kt @@ -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) + } + } + +} diff --git a/ktor/src/main/kotlin/ink/snowflake/server/Application.kt b/ktor/src/main/kotlin/ink/snowflake/server/Application.kt index 1d6d267..2953636 100644 --- a/ktor/src/main/kotlin/ink/snowflake/server/Application.kt +++ b/ktor/src/main/kotlin/ink/snowflake/server/Application.kt @@ -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() } diff --git a/ktor/src/main/kotlin/ink/snowflake/server/controller/Traceability.kt b/ktor/src/main/kotlin/ink/snowflake/server/controller/Traceability.kt new file mode 100644 index 0000000..43e3af5 --- /dev/null +++ b/ktor/src/main/kotlin/ink/snowflake/server/controller/Traceability.kt @@ -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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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 """ + + + + + + ${escapeHtml(batch.batchName)} - 溯源信息 + + + +
+
+
+ 可信溯源链 · 实时公开 +

${escapeHtml(batch.batchName)}

+

${escapeHtml(batch.summary.ifBlank { "该批次已完成关键环节上链归档,消费者可查看从生产、质检到包装发布的完整履历信息。" })}

+
+
批次编码${escapeHtml(batch.batchCode)}
+
当前状态${escapeHtml(batch.status)}
+
累计扫码${batch.scanCount}
+
流程节点${batch.steps.size}
+
+
+
${escapeHtml(batch.productName.ifBlank { batch.templateName })}
更新时间:${escapeHtml(batch.updatedAt)}
+
+
+

公开资料

企业、地域、资质等面向消费者展示的信息在这里集中呈现。

+
$publicCards
+
+
+

流程时间轴

按业务环节顺序查看本批次的生产过程与关键留痕。

+
$timelineCards
+
+
+

投诉与建议

如果你发现信息异常、质量问题,或有优化建议,可以直接提交。

+ +
+
+ + + +""".trimIndent() +} + +private fun renderSectionCard(step: TraceBatchStepResponse): String = + """

${escapeHtml(step.name)}

${escapeHtml(step.description)}

${renderValueCards(step.values)}
""" + +private fun renderTimelineCard(step: TraceBatchStepResponse): String { + val time = step.completedAt.ifBlank { "待补充" } + return """

${escapeHtml(step.name)}

${escapeHtml(step.description)}

${escapeHtml(step.status)} · ${escapeHtml(time)}
${renderValueCards(step.values)}
""" +} + +private fun renderValueCards(values: JsonObject): String = values.entries.joinToString("") { (key, value) -> + """
${escapeHtml(key)}${escapeHtml(formatJsonValue(value))}
""" +} + +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("'", "'") diff --git a/ktor/src/main/kotlin/ink/snowflake/server/model/database/TraceabilityTables.kt b/ktor/src/main/kotlin/ink/snowflake/server/model/database/TraceabilityTables.kt new file mode 100644 index 0000000..4b67010 --- /dev/null +++ b/ktor/src/main/kotlin/ink/snowflake/server/model/database/TraceabilityTables.kt @@ -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() +} diff --git a/ktor/src/main/kotlin/ink/snowflake/server/model/request/TraceabilityRequest.kt b/ktor/src/main/kotlin/ink/snowflake/server/model/request/TraceabilityRequest.kt new file mode 100644 index 0000000..73a3264 --- /dev/null +++ b/ktor/src/main/kotlin/ink/snowflake/server/model/request/TraceabilityRequest.kt @@ -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 = 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 = 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 = 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 = emptyList(), +) + +@Serializable +data class UpdateTraceBatchBaseRequest( + val batchName: String, + val batchCode: String, + val productName: String = "", + val summary: String = "", + val coverImage: String = "", + val tags: List = 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) diff --git a/ktor/src/main/kotlin/ink/snowflake/server/model/response/TraceabilityResponse.kt b/ktor/src/main/kotlin/ink/snowflake/server/model/response/TraceabilityResponse.kt new file mode 100644 index 0000000..b06fb58 --- /dev/null +++ b/ktor/src/main/kotlin/ink/snowflake/server/model/response/TraceabilityResponse.kt @@ -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 = 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, +) + +@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, + 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 = 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, + 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, + val status: String, + val currentStep: Int, + val scanCount: Int, + val publicUrl: String, + val steps: List, + 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, + val businessSections: List, +) + +@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, +) diff --git a/ktor/src/main/kotlin/ink/snowflake/server/utils/AppConfig.kt b/ktor/src/main/kotlin/ink/snowflake/server/utils/AppConfig.kt index a67b33a..aacade1 100644 --- a/ktor/src/main/kotlin/ink/snowflake/server/utils/AppConfig.kt +++ b/ktor/src/main/kotlin/ink/snowflake/server/utils/AppConfig.kt @@ -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() } diff --git a/ktor/src/main/kotlin/ink/snowflake/server/utils/OSSUtils.kt b/ktor/src/main/kotlin/ink/snowflake/server/utils/OSSUtils.kt index a26d881..b9d3e20 100644 --- a/ktor/src/main/kotlin/ink/snowflake/server/utils/OSSUtils.kt +++ b/ktor/src/main/kotlin/ink/snowflake/server/utils/OSSUtils.kt @@ -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 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() + } - private val client: MinioClient = MinioClient.builder() - .endpoint("ai.ronsunny.cn",9000,true) // 你的MinIO地址 - .region("Chengdu") - .credentials("minioadmin", "minioadmin") // 账号密码 - .build() + fun init(config: AppConfig) { + appConfig = config + } - /** - * 上传文件 - * @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 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(), + ) + } + } } diff --git a/ktor/src/main/kotlin/ink/snowflake/server/utils/dao/TraceabilityDao.kt b/ktor/src/main/kotlin/ink/snowflake/server/utils/dao/TraceabilityDao.kt new file mode 100644 index 0000000..165a0da --- /dev/null +++ b/ktor/src/main/kotlin/ink/snowflake/server/utils/dao/TraceabilityDao.kt @@ -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 = 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 = 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 = 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 { + 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 { + 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 { + return json.decodeFromString>(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(raw) + } catch (_: Exception) { + JsonObject(emptyMap()) + } + + private fun decodeStringList(raw: String): List = try { + json.decodeFromString>(raw) + } catch (_: Exception) { + emptyList() + } + + private fun buildDefaultValues(fields: List): 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" + } +} diff --git a/ktor/src/main/resources/application.yaml b/ktor/src/main/resources/application.yaml index 5d0bc8e..e29055f 100644 --- a/ktor/src/main/resources/application.yaml +++ b/ktor/src/main/resources/application.yaml @@ -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" + diff --git a/trace-demo/README.md b/trace-demo/README.md new file mode 100644 index 0000000..041815c --- /dev/null +++ b/trace-demo/README.md @@ -0,0 +1,20 @@ +# 灵活溯源系统 MVP + +这是一个纯前端的 H5 演示版,直接打开 `index.html` 就能看效果。 + +## 已包含内容 + +- 管理员端:节点库、字段编辑、模板创建、公共资料块复用 +- 业务员端:基于模板新建批次、逐节点填报、二维码预览 +- 消费者端:时间轴溯源展示、企业信息、县域情况、有机证书 + +## 使用方式 + +1. 直接双击打开 `index.html` +2. 或者用任意静态文件服务打开 `trace-demo` 目录 + +## 说明 + +- 演示数据保存在浏览器 `localStorage` +- 点击“重置演示数据”可以恢复默认内容 +- 当前二维码是本地样式模拟,后续可替换成真实二维码 diff --git a/trace-demo/app.js b/trace-demo/app.js new file mode 100644 index 0000000..a894415 --- /dev/null +++ b/trace-demo/app.js @@ -0,0 +1,1016 @@ +const STORAGE_KEY = "traceability-mvp-data-v3"; +const FIELD_TYPES = ["string", "integer", "char", "datetime", "json", "image", "video_url", "link", "select", "multi_select"]; + +const seedState = { + templates: [ + { + id: "tpl-silk-v1", + name: "蚕丝被标准模板 V1", + description: "适用于蚕丝被产品的一批一码溯源模板。", + nodes: [ + { id: "tn-1", source: "library", nodeId: "biz-order" }, + { id: "tn-2", source: "library", nodeId: "biz-farmer" }, + { id: "tn-3", source: "library", nodeId: "biz-grow" }, + { id: "tn-4", source: "library", nodeId: "biz-buy" }, + { id: "tn-5", source: "library", nodeId: "biz-silk" }, + { id: "tn-6", source: "library", nodeId: "biz-manufacture" }, + { id: "tn-7", source: "library", nodeId: "biz-inspection" }, + { id: "tn-8", source: "library", nodeId: "biz-package" }, + { id: "tn-9", source: "library", nodeId: "pub-company" }, + { id: "tn-10", source: "library", nodeId: "pub-county" }, + { id: "tn-11", source: "library", nodeId: "pub-organic" } + ] + } + ], + nodeLibrary: [ + { + id: "biz-order", + category: "business", + name: "订种信息", + description: "记录订种单、所属乡镇、蚕品种等基础信息。", + consumerVisible: true, + fields: [ + { key: "town", label: "所属乡镇", type: "string", required: true, visible: true, defaultValue: "河西乡" }, + { key: "silkwormBreed", label: "蚕品种", type: "string", required: true, visible: true, defaultValue: "桂蚕 8 号" }, + { key: "orderNo", label: "订种单号", type: "string", required: true, visible: true, defaultValue: "DZ-2026-001" }, + { key: "orderDate", label: "订种日期", type: "datetime", required: true, visible: true, defaultValue: "2026-03-05T09:00" } + ] + }, + { + id: "biz-farmer", + category: "business", + name: "农户信息", + description: "记录蚕农、合作社、所在村镇等信息。", + consumerVisible: true, + fields: [ + { key: "farmerName", label: "蚕农姓名", type: "string", required: true, visible: true, defaultValue: "周志远" }, + { key: "coopName", label: "合作社名称", type: "string", required: true, visible: true, defaultValue: "锦绣桑蚕合作社" }, + { key: "village", label: "所在村镇", type: "string", required: false, visible: true, defaultValue: "新桥村" }, + { key: "contact", label: "联系电话", type: "char", required: false, visible: false, defaultValue: "13800001234" } + ] + }, + { + id: "biz-grow", + category: "business", + name: "共育情况", + description: "记录共育基地、规模、环境与照片。", + consumerVisible: true, + fields: [ + { key: "baseName", label: "基地名称", type: "string", required: true, visible: true, defaultValue: "春晖共育基地" }, + { key: "scale", label: "规模", type: "integer", required: true, visible: true, defaultValue: "320" }, + { key: "environment", label: "环境说明", type: "string", required: false, visible: true, defaultValue: "恒温恒湿,独立消杀管理" }, + { key: "baseImage", label: "基地照片", type: "image", required: false, visible: true, defaultValue: "https://images.unsplash.com/photo-1501004318641-b39e6451bec6?auto=format&fit=crop&w=900&q=80" } + ] + }, + { + id: "biz-buy", + category: "business", + name: "收购信息", + description: "记录收购单、质量等级、采收时间和现场图片。", + consumerVisible: true, + fields: [ + { key: "purchaseNo", label: "收购单号", type: "string", required: true, visible: true, defaultValue: "SG-2026-0331" }, + { key: "grade", label: "质量等级", type: "select", required: true, visible: true, defaultValue: "A", options: ["A", "B", "C"] }, + { key: "harvestTime", label: "采收时间", type: "datetime", required: true, visible: true, defaultValue: "2026-03-31T11:20" }, + { key: "purchaseImage", label: "收购照片", type: "image", required: false, visible: true, defaultValue: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=900&q=80" } + ] + }, + { + id: "biz-silk", + category: "business", + name: "制丝/家纺", + description: "记录原料领用、操作人员、批次和庄口。", + consumerVisible: true, + fields: [ + { key: "materialTime", label: "原料领用时间", type: "datetime", required: true, visible: true, defaultValue: "2026-04-02T08:40" }, + { key: "operator", label: "操作人员", type: "string", required: true, visible: true, defaultValue: "韩海燕" }, + { key: "lotNo", label: "批次", type: "string", required: true, visible: true, defaultValue: "ZS-2026-010" }, + { key: "station", label: "庄口", type: "string", required: false, visible: true, defaultValue: "一庄口" } + ] + }, + { + id: "biz-manufacture", + category: "business", + name: "制造信息", + description: "记录缝制、填充量、规格和工艺。", + consumerVisible: true, + fields: [ + { key: "sewInfo", label: "缝制信息", type: "string", required: true, visible: true, defaultValue: "双针锁边,分区定格" }, + { key: "fillWeight", label: "蚕丝被填充量(g)", type: "integer", required: true, visible: true, defaultValue: "3000" }, + { key: "craft", label: "工艺", type: "string", required: false, visible: true, defaultValue: "手工拉网 + 定位绗缝" }, + { key: "size", label: "规格", type: "string", required: false, visible: true, defaultValue: "220cm × 240cm" } + ] + }, + { + id: "biz-inspection", + category: "business", + name: "质检信息", + description: "记录检测项目、报告、合格证明和质检时间。", + consumerVisible: true, + fields: [ + { key: "testItems", label: "检测项目", type: "multi_select", required: true, visible: true, defaultValue: ["纤维含量", "含水率"], options: ["纤维含量", "含水率", "外观", "清洁度"] }, + { key: "report", label: "检测报告", type: "link", required: false, visible: true, defaultValue: "https://example.com/report/2026-silk-01" }, + { key: "certificateImage", label: "合格证明", type: "image", required: false, visible: true, defaultValue: "https://images.unsplash.com/photo-1455390582262-044cdead277a?auto=format&fit=crop&w=900&q=80" }, + { key: "testDate", label: "质检日期", type: "datetime", required: true, visible: true, defaultValue: "2026-04-05T14:10" } + ] + }, + { + id: "biz-package", + category: "business", + name: "包装信息", + description: "记录包装时间、包装方式和成品图片。", + consumerVisible: true, + fields: [ + { key: "packageTime", label: "包装时间", type: "datetime", required: true, visible: true, defaultValue: "2026-04-06T16:00" }, + { key: "packageStyle", label: "包装方式", type: "string", required: false, visible: true, defaultValue: "礼盒包装 + 防潮袋" }, + { key: "productImage", label: "成品照片", type: "image", required: false, visible: true, defaultValue: "https://images.unsplash.com/photo-1616627547584-bf28cee262db?auto=format&fit=crop&w=900&q=80" } + ] + }, + { + id: "pub-company", + category: "public", + name: "企业信息", + description: "固定展示品牌主体、工厂信息和联系方式。", + consumerVisible: true, + fields: [ + { key: "factoryName", label: "加工厂名称", type: "string", required: true, visible: true, defaultValue: "广西锦绣桑蚕实业有限公司" }, + { key: "address", label: "地址", type: "string", required: true, visible: true, defaultValue: "广西象州县工业园桑蚕家纺产业区 8 号" }, + { key: "qualification", label: "资质", type: "string", required: false, visible: true, defaultValue: "ISO9001 / 农产品加工示范企业" }, + { key: "contact", label: "联系方式", type: "string", required: false, visible: true, defaultValue: "0772-8888666" } + ] + }, + { + id: "pub-county", + category: "public", + name: "县域情况", + description: "展示县域产业特色和区域优势。", + consumerVisible: true, + fields: [ + { key: "countyName", label: "县域名称", type: "string", required: true, visible: true, defaultValue: "象州县" }, + { key: "countyIntro", label: "县域介绍", type: "string", required: true, visible: true, defaultValue: "桑蚕养殖基础扎实,形成从蚕种、养殖、制丝到家纺的完整链条。" }, + { key: "countyTag", label: "特色标签", type: "string", required: false, visible: true, defaultValue: "国家现代农业示范区" } + ] + }, + { + id: "pub-organic", + category: "public", + name: "有机证书", + description: "展示证书编号、发证机构和有效期。", + consumerVisible: true, + fields: [ + { key: "certName", label: "证书名称", type: "string", required: true, visible: true, defaultValue: "有机产品认证证书" }, + { key: "certNo", label: "证书编号", type: "string", required: true, visible: true, defaultValue: "ORG-2026-99881" }, + { key: "issuer", label: "发证机构", type: "string", required: false, visible: true, defaultValue: "中国质量认证中心" }, + { key: "validUntil", label: "有效期至", type: "datetime", required: false, visible: true, defaultValue: "2027-12-31T00:00" } + ] + } + ], + traces: [ + { + id: "trace-2026-silk-001", + templateId: "tpl-silk-v1", + name: "2026 春季蚕丝被 001 批", + code: "TR-2026-001", + status: "已发布", + currentIndex: 10, + scans: 286, + nodeData: {} + } + ] +}; + +let state = loadState(); +seedState.traces[0].nodeData = createNodeData(seedState.templates[0], seedState.nodeLibrary); +state = normalizeState(state); + +let ui = { + page: "templates", + libraryTab: "business", + consumerTab: "trace", + templateId: state.templates[0]?.id || "", + traceId: state.traces[0]?.id || "", + templateNodeId: state.templates[0]?.nodes[0]?.id || "", + libraryNodeId: state.nodeLibrary.find((item) => item.category === "business")?.id || "", + consumerQuery: state.traces[0]?.code || "", + dragTemplateNodeId: "" +}; + +function loadState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : null; + if (parsed?.templates && parsed?.nodeLibrary && parsed?.traces) return parsed; + } catch (error) { + console.warn("load failed", error); + } + return structuredClone(seedState); +} + +function normalizeState(input) { + const cloned = structuredClone(input); + cloned.templates.forEach((template) => { + template.nodes.forEach((item) => { + if (!item.id) item.id = `tn-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + }); + }); + cloned.traces.forEach((trace) => { + const template = cloned.templates.find((item) => item.id === trace.templateId); + if (!trace.nodeData || Object.keys(trace.nodeData).length === 0) { + trace.nodeData = createNodeData(template, cloned.nodeLibrary); + } + }); + return cloned; +} + +function saveState() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); +} + +function resetDemo() { + localStorage.removeItem(STORAGE_KEY); + state = normalizeState(structuredClone(seedState)); + ui = { + page: "templates", + libraryTab: "business", + consumerTab: "trace", + templateId: state.templates[0]?.id || "", + traceId: state.traces[0]?.id || "", + templateNodeId: state.templates[0]?.nodes[0]?.id || "", + libraryNodeId: state.nodeLibrary.find((item) => item.category === "business")?.id || "", + consumerQuery: state.traces[0]?.code || "", + dragTemplateNodeId: "" + }; + saveState(); + render(); +} + +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); + +function getTemplate(id) { + return state.templates.find((item) => item.id === id); +} + +function getTrace(id) { + return state.traces.find((item) => item.id === id); +} + +function getLibraryNode(id) { + return state.nodeLibrary.find((item) => item.id === id); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatValue(value, field) { + if (value === undefined || value === null || value === "") return "未填写"; + if (Array.isArray(value)) return value.join("、"); + if (field?.type === "datetime") return String(value).replace("T", " "); + return String(value); +} + +function totalScans() { + return state.traces.reduce((sum, item) => sum + (item.scans || 0), 0); +} + +function nodeRefToModel(nodeRef) { + if (!nodeRef) return null; + return nodeRef.source === "library" ? getLibraryNode(nodeRef.nodeId) : nodeRef.node; +} + +function isPublicNode(nodeRef) { + return nodeRefToModel(nodeRef)?.category === "public"; +} + +function createNodeData(template, library = state.nodeLibrary) { + const data = {}; + if (!template) return data; + template.nodes.forEach((nodeRef) => { + const node = nodeRef.source === "library" + ? library.find((item) => item.id === nodeRef.nodeId) + : nodeRef.node; + if (!node) return; + data[nodeRef.id] = {}; + node.fields.forEach((field) => { + data[nodeRef.id][field.key] = Array.isArray(field.defaultValue) ? [...field.defaultValue] : (field.defaultValue ?? ""); + }); + }); + return data; +} + +function createEmptyNode(category = "business") { + return { + category, + name: category === "public" ? "新公共资料块" : "新业务节点", + description: "请完善节点说明与字段。", + consumerVisible: true, + fields: [ + { key: "field1", label: "字段一", type: "string", required: true, visible: true, defaultValue: "" } + ] + }; +} + +function cloneNode(node) { + return structuredClone(node); +} + +function templateNodeLabel(nodeRef) { + const node = nodeRefToModel(nodeRef); + return node?.name || "未命名节点"; +} + +function templateNodeTypeLabel(nodeRef) { + if (!nodeRef) return "未选择节点"; + if (nodeRef.source === "custom") return "临时节点"; + return isPublicNode(nodeRef) ? "公共资料块" : "业务节点"; +} + +function nextId(prefix) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; +} + +function createTemplate() { + const template = { + id: nextId("tpl"), + name: "新模板", + description: "请完善模板说明并编排节点。", + nodes: [] + }; + state.templates.unshift(template); + ui.templateId = template.id; + ui.templateNodeId = ""; + saveState(); + render(); +} + +function createTrace() { + const template = getTemplate(ui.templateId); + if (!template) return; + const code = `TR-${new Date().getFullYear()}-${String(Date.now()).slice(-6)}`; + const trace = { + id: nextId("trace"), + templateId: template.id, + name: `${template.name} 新批次`, + code, + status: "进行中", + currentIndex: 0, + scans: 0, + nodeData: createNodeData(template) + }; + state.traces.unshift(trace); + ui.traceId = trace.id; + ui.consumerQuery = trace.code; + saveState(); + render(); +} + +function addLibraryNodeToTemplate(category) { + const template = getTemplate(ui.templateId); + const selectedId = $(`#template-add-${category}`)?.value; + if (!template || !selectedId) return; + template.nodes.push({ id: nextId("tn"), source: "library", nodeId: selectedId }); + ui.templateNodeId = template.nodes[template.nodes.length - 1].id; + state.traces.filter((trace) => trace.templateId === template.id).forEach((trace) => { + trace.nodeData[ui.templateNodeId] = {}; + const node = getLibraryNode(selectedId); + node.fields.forEach((field) => { + trace.nodeData[ui.templateNodeId][field.key] = Array.isArray(field.defaultValue) ? [...field.defaultValue] : (field.defaultValue ?? ""); + }); + }); + saveState(); + render(); +} + +function addCustomTemplateNode() { + const template = getTemplate(ui.templateId); + if (!template) return; + const nodeRef = { id: nextId("tn"), source: "custom", node: createEmptyNode("business") }; + template.nodes.push(nodeRef); + ui.templateNodeId = nodeRef.id; + state.traces.filter((trace) => trace.templateId === template.id).forEach((trace) => { + trace.nodeData[nodeRef.id] = { field1: "" }; + }); + saveState(); + render(); +} + +function addLibraryNode(category) { + const node = { id: nextId(category === "public" ? "pub" : "biz"), ...createEmptyNode(category) }; + state.nodeLibrary.unshift(node); + ui.libraryNodeId = node.id; + ui.libraryTab = category; + saveState(); + render(); +} + +function addFieldToNode(target) { + const node = target(); + if (!node) return; + const index = node.fields.length + 1; + node.fields.push({ key: `field${index}`, label: `字段 ${index}`, type: "string", required: false, visible: true, defaultValue: "" }); + saveState(); + render(); +} + +function removeTemplateNode() { + const template = getTemplate(ui.templateId); + if (!template || !ui.templateNodeId) return; + template.nodes = template.nodes.filter((item) => item.id !== ui.templateNodeId); + ui.templateNodeId = template.nodes[0]?.id || ""; + saveState(); + render(); +} + +function saveTemplateMeta() { + const template = getTemplate(ui.templateId); + if (!template) return; + template.name = ($("#template-name")?.value || "").trim() || template.name; + template.description = ($("#template-description")?.value || "").trim(); + saveState(); + render(); +} + +function currentTemplateNodeRef() { + const template = getTemplate(ui.templateId); + return template?.nodes.find((item) => item.id === ui.templateNodeId) || null; +} + +function currentLibraryNode() { + return getLibraryNode(ui.libraryNodeId); +} + +function saveNodeMeta(target) { + const node = target(); + if (!node) return; + node.name = ($("#node-name")?.value || "").trim() || node.name; + node.description = ($("#node-description")?.value || "").trim(); + node.consumerVisible = Boolean($("#node-visible")?.checked); + saveState(); + render(); +} + +function saveTraceBase() { + const trace = getTrace(ui.traceId); + if (!trace) return; + trace.name = ($("#trace-name")?.value || "").trim() || trace.name; + trace.code = ($("#trace-code")?.value || "").trim() || trace.code; + ui.consumerQuery = trace.code; + saveState(); + render(); +} + +function saveCurrentTraceNode() { + const trace = getTrace(ui.traceId); + const template = trace ? getTemplate(trace.templateId) : null; + const nodeRef = template?.nodes[trace.currentIndex]; + const node = nodeRefToModel(nodeRef); + if (!trace || !nodeRef || !node) return; + const values = trace.nodeData[nodeRef.id] || {}; + let valid = true; + node.fields.forEach((field) => { + const nextValue = field.type === "multi_select" + ? $$(`[data-field-key="${field.key}"]:checked`).map((item) => item.value) + : ($(`[data-field-key="${field.key}"]`)?.value ?? ""); + if (field.required && (!nextValue || (Array.isArray(nextValue) && nextValue.length === 0))) valid = false; + values[field.key] = nextValue; + }); + if (!valid) return alert("请先补全当前节点的必填字段。"); + trace.nodeData[nodeRef.id] = values; + if (trace.currentIndex < template.nodes.length - 1) trace.currentIndex += 1; + trace.status = trace.currentIndex >= template.nodes.length - 1 ? "待发布" : "进行中"; + saveState(); + render(); +} + +function publishTrace() { + const trace = getTrace(ui.traceId); + if (!trace) return; + trace.status = "已发布"; + trace.scans += 1; + saveState(); + render(); +} + +function moveTemplateNode(fromId, toId) { + const template = getTemplate(ui.templateId); + if (!template || fromId === toId) return; + const fromIndex = template.nodes.findIndex((item) => item.id === fromId); + const toIndex = template.nodes.findIndex((item) => item.id === toId); + if (fromIndex < 0 || toIndex < 0) return; + const [moved] = template.nodes.splice(fromIndex, 1); + template.nodes.splice(toIndex, 0, moved); + saveState(); + render(); +} + +function updateField(target, index, patch) { + const node = target(); + if (!node?.fields[index]) return; + node.fields[index] = { ...node.fields[index], ...patch }; + if (["select", "multi_select"].includes(node.fields[index].type) && !node.fields[index].options) { + node.fields[index].options = ["选项一", "选项二"]; + } + saveState(); +} + +function renderFieldEditor(node, editable) { + return node.fields.map((field, index) => ` +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ ${["select", "multi_select"].includes(field.type) ? ` +
+ + +
+ ` : ""} +
+
+ `).join(""); +} + +function renderNodeMetaForm(node, editable, actionsHtml = "") { + return ` +
+
+
+ + +
+
+ + +
+
+ + +
+
+
${actionsHtml}
+
${renderFieldEditor(node, editable)}
+
+ `; +} + +function renderTemplatePage() { + const template = getTemplate(ui.templateId); + const currentRef = currentTemplateNodeRef(); + const currentNode = nodeRefToModel(currentRef); + const businessNodes = state.nodeLibrary.filter((item) => item.category === "business"); + const publicNodes = state.nodeLibrary.filter((item) => item.category === "public"); + + return ` +
+ +
+ ${!template ? `
请选择模板。
` : ` +
+
+

模板编排

+ +
+
+
+
+
+
+
+
+ +
+
+ ${template.nodes.map((nodeRef, index) => ` + + `).join("")} +
+
+
+
+

节点信息

+
+ ${currentRef ? `` : ""} +
+
+ ${!currentRef || !currentNode + ? `
点击上方节点查看详情,支持拖动切换先后顺序。
` + : currentRef.source === "library" + ? `
当前节点来自节点库,只能查看,不能在模板内直接修改。
${renderNodeMetaForm(currentNode, false)}` + : renderNodeMetaForm(currentNode, true, ``)} +
+ `} +
+
+ `; +} + +function renderLibraryPage() { + const currentTabNodes = state.nodeLibrary.filter((item) => item.category === ui.libraryTab); + const currentNode = currentLibraryNode(); + return ` +
+ +
+
+
+

${ui.libraryTab === "business" ? "业务节点编辑器" : "公共资料块编辑器"}

+
+ + +
+
+ ${currentNode ? renderNodeMetaForm(currentNode, true) : `
请选择左侧节点开始编辑。
`} +
+
+
+ `; +} + +function renderTraceField(field, value) { + if (field.type === "select") { + return `
`; + } + if (field.type === "multi_select") { + const selected = Array.isArray(value) ? value : []; + return `
${(field.options || []).map((option) => ``).join("")}
`; + } + const type = field.type === "datetime" ? "datetime-local" : field.type === "integer" ? "number" : "text"; + return `
${field.type === "json" ? `` : ``}
`; +} + +function renderOperatorPage() { + const trace = getTrace(ui.traceId) || state.traces[0]; + const template = trace ? getTemplate(trace.templateId) : null; + const currentNodeRef = template?.nodes[trace?.currentIndex || 0]; + const currentNode = nodeRefToModel(currentNodeRef); + const currentValues = currentNodeRef ? trace.nodeData[currentNodeRef.id] || {} : {}; + return ` +
+ +
+ ${!trace || !template ? `
请先新建一个批次。
` : ` +
+
+

批次信息

+
+ + +
+
+
+
+
+
+
+ ${template.nodes.map((nodeRef, index) => ` + + `).join("")} +
+
+
+
+
+

当前节点

+

${escapeHtml(currentNode?.name || "")} · ${escapeHtml(templateNodeTypeLabel(currentNodeRef))}

+
+ +
+ ${currentNode ? `
${currentNode.fields.map((field) => renderTraceField(field, currentValues[field.key])).join("")}
` : `
暂无节点。
`} +
+ `} +
+
+ `; +} + +function renderConsumerTimeline(trace, template) { + const refs = template.nodes.filter((item) => !isPublicNode(item)); + return ` +
+ ${refs.map((nodeRef, index) => { + const node = nodeRefToModel(nodeRef); + const values = trace.nodeData[nodeRef.id] || {}; + return ` +
+
+ + ${index < refs.length - 1 ? `` : ""} +
+
+
+

${escapeHtml(node.name)}

+ ${escapeHtml(node.description)} +
+
+ ${node.fields.filter((field) => field.visible).map((field) => { + const value = values[field.key]; + const rendered = field.type === "image" && value + ? `查看图片` + : (field.type === "link" || field.type === "video_url") && value + ? `打开链接` + : escapeHtml(formatValue(value, field)); + return `
${escapeHtml(field.label)}${rendered}
`; + }).join("")} +
+
+
+ `; + }).join("")} +
+ `; +} + +function renderConsumerMaterials(trace, template) { + const refs = template.nodes.filter((item) => isPublicNode(item)); + return ` +
+ ${refs.map((nodeRef) => { + const node = nodeRefToModel(nodeRef); + const values = trace.nodeData[nodeRef.id] || {}; + return ` +
+
+

${escapeHtml(node.name)}

+ ${escapeHtml(node.description)} +
+
+ ${node.fields.filter((field) => field.visible).map((field) => `
${escapeHtml(field.label)}${escapeHtml(formatValue(values[field.key], field))}
`).join("")} +
+
+ `; + }).join("")} +
+ `; +} + +function renderConsumerPage() { + const trace = state.traces.find((item) => item.code === ui.consumerQuery) || getTrace(ui.traceId) || state.traces[0]; + const template = trace ? getTemplate(trace.templateId) : null; + return ` +
+
+
+

消费者端

+
+ + +
+
+ ${!trace || !template ? `
没有查到对应批次。
` : ` +
+ + +
+
+
+
+

${escapeHtml(trace.name)}

+

${escapeHtml(trace.code)} · ${escapeHtml(trace.status)} · 累计扫码 ${trace.scans} 次

+
+
+ ${ui.consumerTab === "trace" ? renderConsumerTimeline(trace, template) : renderConsumerMaterials(trace, template)} +
+ `} +
+
+ `; +} + +function renderStatsPage() { + return ` +
+
+
模板数量${state.templates.length}
+
节点定义${state.nodeLibrary.length}
+
累计扫码${totalScans()}
+
+
+ `; +} + +function render() { + const pageHtml = { + templates: renderTemplatePage(), + library: renderLibraryPage(), + operator: renderOperatorPage(), + consumer: renderConsumerPage(), + stats: renderStatsPage() + }[ui.page]; + + $("#app").innerHTML = ` +
+ +
${pageHtml}
+
+ `; + bindEvents(); +} + +function bindEvents() { + $$("[data-page]").forEach((item) => { + item.onclick = () => { + ui.page = item.dataset.page; + render(); + }; + }); + + $("#btn-reset-demo")?.addEventListener("click", resetDemo); + $("#btn-new-template")?.addEventListener("click", createTemplate); + $("#btn-save-template-meta")?.addEventListener("click", saveTemplateMeta); + $("#btn-add-business-node")?.addEventListener("click", () => addLibraryNodeToTemplate("business")); + $("#btn-add-public-node")?.addEventListener("click", () => addLibraryNodeToTemplate("public")); + $("#btn-add-custom-node")?.addEventListener("click", addCustomTemplateNode); + $("#btn-remove-template-node")?.addEventListener("click", removeTemplateNode); + $("#btn-save-custom-node")?.addEventListener("click", () => saveNodeMeta(() => currentTemplateNodeRef()?.node)); + $("#btn-add-custom-field")?.addEventListener("click", () => addFieldToNode(() => currentTemplateNodeRef()?.node)); + + $("#btn-new-library-node")?.addEventListener("click", () => addLibraryNode(ui.libraryTab)); + $("#btn-save-library-node")?.addEventListener("click", () => saveNodeMeta(currentLibraryNode)); + $("#btn-add-library-field")?.addEventListener("click", () => addFieldToNode(currentLibraryNode)); + + $("#btn-new-trace")?.addEventListener("click", createTrace); + $("#btn-save-trace-base")?.addEventListener("click", saveTraceBase); + $("#btn-save-trace-node")?.addEventListener("click", saveCurrentTraceNode); + $("#btn-publish-trace")?.addEventListener("click", publishTrace); + $("#btn-search-consumer")?.addEventListener("click", () => { + ui.consumerQuery = ($("#consumer-query")?.value || "").trim(); + const trace = state.traces.find((item) => item.code === ui.consumerQuery); + if (trace) { + ui.traceId = trace.id; + trace.scans += 1; + saveState(); + } + render(); + }); + + $$("[data-template-id]").forEach((item) => { + item.onclick = () => { + ui.templateId = item.dataset.templateId; + ui.templateNodeId = getTemplate(ui.templateId)?.nodes[0]?.id || ""; + render(); + }; + }); + + $$("[data-template-node-id]").forEach((item) => { + item.onclick = () => { + ui.templateNodeId = item.dataset.templateNodeId; + render(); + }; + item.addEventListener("dragstart", () => { + ui.dragTemplateNodeId = item.dataset.templateNodeId; + }); + item.addEventListener("dragover", (event) => event.preventDefault()); + item.addEventListener("drop", (event) => { + event.preventDefault(); + moveTemplateNode(ui.dragTemplateNodeId, item.dataset.templateNodeId); + }); + }); + + $$("[data-library-tab]").forEach((item) => { + item.onclick = () => { + ui.libraryTab = item.dataset.libraryTab; + ui.libraryNodeId = state.nodeLibrary.find((node) => node.category === ui.libraryTab)?.id || ""; + render(); + }; + }); + + $$("[data-library-node-id]").forEach((item) => { + item.onclick = () => { + ui.libraryNodeId = item.dataset.libraryNodeId; + render(); + }; + }); + + $$("[data-trace-id]").forEach((item) => { + item.onclick = () => { + ui.traceId = item.dataset.traceId; + ui.consumerQuery = getTrace(ui.traceId)?.code || ui.consumerQuery; + render(); + }; + }); + + $$("[data-progress-index]").forEach((item) => { + item.onclick = () => { + const trace = getTrace(ui.traceId); + if (!trace) return; + trace.currentIndex = Number(item.dataset.progressIndex); + saveState(); + render(); + }; + }); + + $$("[data-consumer-tab]").forEach((item) => { + item.onclick = () => { + ui.consumerTab = item.dataset.consumerTab; + render(); + }; + }); + + $$("[data-field-index]").forEach((item) => { + const eventName = item.type === "checkbox" ? "change" : "input"; + item.addEventListener(eventName, () => { + const index = Number(item.dataset.fieldIndex); + const prop = item.dataset.fieldProp; + let value = item.type === "checkbox" ? item.checked : item.value; + const target = ui.page === "templates" + ? () => currentTemplateNodeRef()?.node + : currentLibraryNode; + if (prop === "defaultValue") value = value; + if (prop === "options") value = value.split("、").map((part) => part.trim()).filter(Boolean); + if (prop === "defaultValue" && target()?.fields[index]?.type === "multi_select") { + value = value.split("、").map((part) => part.trim()).filter(Boolean); + } + updateField(target, index, { [prop]: value }); + if (prop === "type" || prop === "options") render(); + }); + }); +} + +render(); diff --git a/trace-demo/index.html b/trace-demo/index.html new file mode 100644 index 0000000..8621310 --- /dev/null +++ b/trace-demo/index.html @@ -0,0 +1,16 @@ + + + + + + 灵活溯源系统 MVP + + + + + + +
+ + + diff --git a/trace-demo/styles.css b/trace-demo/styles.css new file mode 100644 index 0000000..33ee271 --- /dev/null +++ b/trace-demo/styles.css @@ -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; } +} diff --git a/vue2/apps/web-antd/src/api/traceability/index.ts b/vue2/apps/web-antd/src/api/traceability/index.ts new file mode 100644 index 0000000..b215094 --- /dev/null +++ b/vue2/apps/web-antd/src/api/traceability/index.ts @@ -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; + 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('/traceability/overview'); +} + +export function getTraceabilityTemplates() { + return requestClient.get( + '/traceability/templates', + ); +} + +export function getTraceabilityTemplate(id: string) { + return requestClient.get( + `/traceability/templates/${id}`, + ); +} + +export function createTraceabilityTemplate( + data: Omit, +) { + return requestClient.post('/traceability/templates', data); +} + +export function updateTraceabilityTemplate( + id: string, + data: Omit, +) { + return requestClient.put(`/traceability/templates/${id}`, data); +} + +export function deleteTraceabilityTemplate(id: string) { + return requestClient.delete(`/traceability/templates/${id}`); +} + +export function getTraceabilityBatches() { + return requestClient.get( + '/traceability/batches', + ); +} + +export function getTraceabilityBatch(id: string) { + return requestClient.get( + `/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; + 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( + `/traceability/public/by-code/${code}`, + ); +} + +export function getTraceabilityFeedbackList() { + return requestClient.get( + '/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( + '/traceability/files/upload-image', + data, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }, + ); +} + +export function getTraceabilityUploadToken(data: { + bucketName?: string; + objectName: string; + expiresMinutes?: number; +}) { + return requestClient.post( + '/traceability/files/presigned-put', + data, + ); +} + +export function getTraceabilityTempUrl(data: { + bucketName?: string; + objectName?: string; + objectDir?: string; + expiresSeconds?: number; +}) { + return requestClient.post( + '/traceability/files/temp-url', + data, + ); +} + +export function moveTraceabilityFile(data: { + bucketName?: string; + sourceObjectName: string; + targetObjectName: string; +}) { + return requestClient.post( + '/traceability/files/move', + data, + ); +} + +export function deleteTraceabilityFile(data: { + bucketName?: string; + objectName: string; +}) { + return requestClient.post('/traceability/files/delete', data); +} diff --git a/vue2/apps/web-antd/src/views/traceability/admin.vue b/vue2/apps/web-antd/src/views/traceability/admin.vue new file mode 100644 index 0000000..41fb186 --- /dev/null +++ b/vue2/apps/web-antd/src/views/traceability/admin.vue @@ -0,0 +1,1539 @@ + + + + + diff --git a/vue2/apps/web-antd/src/views/traceability/consumer.vue b/vue2/apps/web-antd/src/views/traceability/consumer.vue new file mode 100644 index 0000000..ad2ff62 --- /dev/null +++ b/vue2/apps/web-antd/src/views/traceability/consumer.vue @@ -0,0 +1,554 @@ + + + + + diff --git a/vue2/apps/web-antd/src/views/traceability/operator.vue b/vue2/apps/web-antd/src/views/traceability/operator.vue new file mode 100644 index 0000000..26010c9 --- /dev/null +++ b/vue2/apps/web-antd/src/views/traceability/operator.vue @@ -0,0 +1,868 @@ + + + + + diff --git a/vue2/apps/web-antd/src/views/traceability/shared.ts b/vue2/apps/web-antd/src/views/traceability/shared.ts new file mode 100644 index 0000000..2a34f3d --- /dev/null +++ b/vue2/apps/web-antd/src/views/traceability/shared.ts @@ -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 { + 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, +) { + 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), +};