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 0000000..8bdaf60 Binary files /dev/null and b/f10/gradle/wrapper/gradle-wrapper.jar differ 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), +};