溯源系统初版

This commit is contained in:
BBIT-Kai
2026-04-10 18:51:00 +08:00
parent 5971791038
commit 0a43f5e4b9
40 changed files with 7910 additions and 30 deletions
+24
View File
@@ -0,0 +1,24 @@
package com.bbitcn
import io.ktor.server.application.*
fun main(args: Array<String>) {
io.ktor.server.netty.EngineMain.main(args)
}
fun Application.module() {
val traceabilityConfig = environment.config.toTraceabilityPublicConfig()
val traceabilityClient = TraceabilityClient(traceabilityConfig.coreBaseUrl)
val traceabilityService = TraceabilityService(traceabilityConfig, traceabilityClient)
monitor.subscribe(ApplicationStopped) {
traceabilityClient.close()
}
attributes.put(TraceabilityAttributes.ServiceKey, traceabilityService)
configureHTTP()
configureSerialization()
configureTemplating()
configureRouting()
}
+27
View File
@@ -0,0 +1,27 @@
package com.bbitcn
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.cachingheaders.CachingHeaders
import io.ktor.server.plugins.compression.Compression
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.plugins.defaultheaders.DefaultHeaders
fun Application.configureHTTP() {
install(CORS) {
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowHeader(HttpHeaders.ContentType)
anyHost()
}
install(DefaultHeaders) {
header("X-Service", "traceability-public")
}
install(Compression)
install(CachingHeaders)
}
+104
View File
@@ -0,0 +1,104 @@
package com.bbitcn
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.application.call
import io.ktor.server.freemarker.FreeMarkerContent
import io.ktor.server.http.content.staticResources
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.request.receiveParameters
import io.ktor.server.response.respond
import io.ktor.server.response.respondRedirect
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import io.ktor.util.AttributeKey
object TraceabilityAttributes {
val ServiceKey = AttributeKey<TraceabilityService>("traceability.service")
}
fun Application.configureRouting() {
install(StatusPages) {
exception<Throwable> { call, cause ->
this@configureRouting.environment.log.error("Public page error", cause)
call.respondText("服务异常,请稍后重试", status = HttpStatusCode.InternalServerError)
}
}
routing {
get("/") {
call.respondText("traceability public server ok")
}
get("/health") {
call.respond(mapOf("status" to "ok"))
}
get("/p/{code}") {
val code = call.parameters["code"]?.trim().orEmpty()
if (code.isBlank()) {
call.respondText("批次编码不能为空", status = HttpStatusCode.BadRequest)
return@get
}
val page = call.traceabilityService().loadPage(code)
if (page == null) {
call.respond(
HttpStatusCode.NotFound,
FreeMarkerContent(
"error.ftl",
mapOf("message" to "未找到对应的溯源批次,请确认二维码或编码是否正确。"),
),
)
return@get
}
val result = call.request.queryParameters["result"].orEmpty()
val message = when (result) {
"success" -> "反馈已提交,感谢你的建议。"
"failed" -> "提交失败,请稍后再试。"
else -> ""
}
call.respond(
FreeMarkerContent(
"traceability.ftl",
mapOf(
"page" to page,
"feedbackMessage" to message,
),
),
)
}
post("/feedback") {
val params = call.receiveParameters()
val code = params["batchCode"]?.trim().orEmpty()
val content = params["content"]?.trim().orEmpty()
if (code.isBlank() || content.isBlank()) {
call.respondText("批次编码和反馈内容不能为空", status = HttpStatusCode.BadRequest)
return@post
}
val response = call.traceabilityService().submitFeedback(
code = code,
type = params["type"].orEmpty(),
contact = params["contact"].orEmpty(),
content = content,
rating = params["rating"]?.toIntOrNull() ?: 5,
)
val result = if (response.status) "success" else "failed"
call.respondRedirect("/p/$code?result=$result")
}
staticResources("/static", "static")
}
}
private fun ApplicationCall.traceabilityService(): TraceabilityService {
return application.attributes[TraceabilityAttributes.ServiceKey]
}
+18
View File
@@ -0,0 +1,18 @@
package com.bbitcn
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import kotlinx.serialization.json.Json
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
encodeDefaults = true
},
)
}
}
+12
View File
@@ -0,0 +1,12 @@
package com.bbitcn
import freemarker.cache.ClassTemplateLoader
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.freemarker.FreeMarker
fun Application.configureTemplating() {
install(FreeMarker) {
templateLoader = ClassTemplateLoader(this::class.java.classLoader, "templates")
}
}
+75
View File
@@ -0,0 +1,75 @@
package com.bbitcn
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.isSuccess
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
class TraceabilityClient(
private val coreBaseUrl: String,
) {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(json)
}
install(Logging) {
level = LogLevel.INFO
}
}
suspend fun fetchPublicDetail(
code: String,
increaseScan: Boolean,
): TraceabilityPublicDetailResponse? {
val response = client.get {
url("$coreBaseUrl/traceability/public/by-code/$code")
parameter("increaseScan", increaseScan)
accept(ContentType.Application.Json)
}
if (!response.status.isSuccess()) {
return null
}
val payload = response.body<ApiResponse<TraceabilityPublicDetailResponse>>()
return payload.data
}
suspend fun submitFeedback(request: SubmitTraceabilityFeedbackRequest): ApiResponse<TraceabilityFeedbackResponse> {
val response = client.post {
url("$coreBaseUrl/traceability/public/feedback")
contentType(ContentType.Application.Json)
setBody(request)
accept(ContentType.Application.Json)
}
if (!response.status.isSuccess()) {
return ApiResponse(
status = false,
message = response.bodyAsText(),
data = null,
)
}
return response.body()
}
fun close() {
client.close()
}
}
+130
View File
@@ -0,0 +1,130 @@
package com.bbitcn
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@Serializable
data class ApiResponse<T>(
val status: Boolean = true,
val message: String = "",
val data: T? = null,
)
@Serializable
data class TraceFieldDefinitionResponse(
val key: String,
val label: String,
val type: String = "string",
val required: Boolean = false,
val visible: Boolean = true,
val placeholder: String = "",
val defaultValue: JsonElement? = null,
val options: List<String> = emptyList(),
)
@Serializable
data class TraceBatchStepResponse(
val id: String,
val templateNodeId: String? = null,
val sort: Int,
val category: String,
val name: String,
val description: String,
val consumerVisible: Boolean,
val status: String,
val operatorName: String,
val values: JsonObject,
val completedAt: String = "",
val fields: List<TraceFieldDefinitionResponse> = emptyList(),
)
@Serializable
data class TraceBatchDetailResponse(
val id: String,
val templateId: String,
val templateName: String,
val batchName: String,
val batchCode: String,
val productName: String,
val summary: String,
val coverImage: String,
val tags: List<String>,
val status: String,
val currentStep: Int,
val scanCount: Int,
val publicUrl: String,
val steps: List<TraceBatchStepResponse>,
val updatedAt: String,
val publishedAt: String = "",
)
@Serializable
data class TraceabilityPublicDetailResponse(
val batch: TraceBatchDetailResponse,
val companySectionTitle: String = "企业公开资料",
val publicSections: List<TraceBatchStepResponse>,
val businessSections: List<TraceBatchStepResponse>,
)
@Serializable
data class SubmitTraceabilityFeedbackRequest(
val batchCode: String? = null,
val batchId: String? = null,
val type: String = "suggestion",
val contact: String = "",
val content: String,
val source: String = "public",
val rating: Int = 5,
)
@Serializable
data class TraceabilityFeedbackResponse(
val id: String,
val batchId: String,
val batchCode: String,
val batchName: String,
val type: String,
val contact: String,
val content: String,
val source: String,
val rating: Int,
val createdAt: String,
)
data class DisplayEntry(
val label: String,
val value: String,
val type: String = "string",
)
data class PublicSectionView(
val id: String,
val name: String,
val description: String,
val entries: List<DisplayEntry>,
)
data class TimelineSectionView(
val id: String,
val name: String,
val description: String,
val status: String,
val completedAt: String,
val entries: List<DisplayEntry>,
)
data class PageViewModel(
val code: String,
val pageUrl: String,
val batchName: String,
val productName: String,
val templateName: String,
val summary: String,
val coverImage: String,
val scanCount: Int,
val publishedAt: String,
val tagsText: String,
val publicSections: List<PublicSectionView>,
val businessSections: List<TimelineSectionView>,
)
+111
View File
@@ -0,0 +1,111 @@
package com.bbitcn
import io.ktor.server.config.ApplicationConfig
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
data class TraceabilityPublicConfig(
val coreBaseUrl: String,
val publicBaseUrl: String,
)
class TraceabilityService(
private val config: TraceabilityPublicConfig,
private val client: TraceabilityClient,
) {
suspend fun loadPage(code: String): PageViewModel? {
val detail = client.fetchPublicDetail(code, increaseScan = true) ?: return null
val batch = detail.batch
return PageViewModel(
code = batch.batchCode,
pageUrl = "${config.publicBaseUrl.trimEnd('/')}/p/${batch.batchCode}",
batchName = batch.batchName,
productName = batch.productName.ifBlank { batch.templateName },
templateName = batch.templateName,
summary = batch.summary.ifBlank { "该批次已完成关键环节留痕,可查看公开资料与业务流程。" },
coverImage = batch.coverImage,
scanCount = batch.scanCount,
publishedAt = formatDateOnly(batch.publishedAt),
tagsText = batch.tags.joinToString("").ifBlank { "暂无标签" },
publicSections = detail.publicSections.map(::toPublicSectionView),
businessSections = detail.businessSections.map(::toTimelineSectionView),
)
}
suspend fun submitFeedback(
code: String,
type: String,
contact: String,
content: String,
rating: Int,
): ApiResponse<TraceabilityFeedbackResponse> {
val normalizedType = when (type) {
"complaint", "consult", "suggestion" -> type
else -> "suggestion"
}
return client.submitFeedback(
SubmitTraceabilityFeedbackRequest(
batchCode = code,
type = normalizedType,
contact = contact.trim(),
content = content.trim(),
rating = rating.coerceIn(1, 5),
source = "public",
),
)
}
private fun toPublicSectionView(step: TraceBatchStepResponse): PublicSectionView {
return PublicSectionView(
id = step.id,
name = step.name,
description = step.description.ifBlank { "公开展示资料" },
entries = toDisplayEntries(step),
)
}
private fun toTimelineSectionView(step: TraceBatchStepResponse): TimelineSectionView {
return TimelineSectionView(
id = step.id,
name = step.name,
description = step.description.ifBlank { "流程记录" },
status = step.status,
completedAt = formatDateOnly(step.completedAt),
entries = toDisplayEntries(step),
)
}
private fun toDisplayEntries(step: TraceBatchStepResponse): List<DisplayEntry> {
return step.values.entries.map { (key, value) ->
val field = step.fields.find { it.key == key }
DisplayEntry(
label = field?.label ?: key,
value = formatJsonValue(value),
type = field?.type ?: "string",
)
}
}
private fun formatJsonValue(value: JsonElement): String = when (value) {
is JsonArray -> value.joinToString("") { formatJsonValue(it) }
is JsonObject -> value.entries.joinToString("") { "${it.key}: ${formatJsonValue(it.value)}" }
else -> value.toString().trim('"').ifBlank { "未填写" }
}
private fun formatDateOnly(value: String): String {
val text = value.trim()
if (text.isBlank()) {
return "未发布"
}
return text.substringBefore(" ").substringBefore("T")
}
}
fun ApplicationConfig.toTraceabilityPublicConfig(): TraceabilityPublicConfig {
return TraceabilityPublicConfig(
coreBaseUrl = property("traceability.core-base-url").getString().trimEnd('/'),
publicBaseUrl = property("traceability.public-base-url").getString().trimEnd('/'),
)
}
+10
View File
@@ -0,0 +1,10 @@
ktor:
application:
modules:
- com.bbitcn.ApplicationKt.module
deployment:
port: 8081
traceability:
core-base-url: "http://127.0.0.1:8089"
public-base-url: "http://127.0.0.1:8081"
+12
View File
@@ -0,0 +1,12 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>
@@ -0,0 +1,376 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
color: #182235;
background:
radial-gradient(circle at top left, rgba(23, 92, 230, 0.12), transparent 32%),
linear-gradient(180deg, #f9fbff 0%, #f3f6fb 100%);
}
a {
color: #1d4ed8;
text-decoration: none;
}
.page-shell {
max-width: 1240px;
margin: 0 auto;
padding: 28px 16px 48px;
}
.hero,
.panel {
border: 1px solid rgba(228, 234, 245, 0.9);
border-radius: 28px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 16px 48px rgba(16, 24, 40, 0.08);
}
.hero {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
padding: 26px;
}
.hero--with-cover {
grid-template-columns: minmax(0, 1.2fr) 320px;
align-items: stretch;
}
.hero h1,
.panel h2,
.info-card h3,
.timeline-item__body h3 {
margin: 0;
}
.hero h1 {
margin-top: 16px;
font-size: 34px;
line-height: 1.2;
}
.hero p,
.panel__head p,
.info-card__desc,
.timeline-item__body p {
color: #667085;
line-height: 1.75;
}
.hero p {
margin: 14px 0 0;
}
.hero__stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.hero__cover {
overflow: hidden;
border: 1px solid #e8eef7;
border-radius: 22px;
background: #fff;
min-height: 240px;
}
.hero__cover img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.stat-card,
.summary-card,
.kv-card,
.info-card,
.timeline-item__body,
.form-item input,
.form-item select,
.form-item textarea {
border: 1px solid #e8eef7;
border-radius: 18px;
background: #fff;
}
.stat-card,
.summary-card {
padding: 14px 16px;
}
.summary-card {
min-height: 104px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.stat-card span,
.summary-card span,
.kv-card span,
.form-item span {
display: block;
color: #7d8899;
font-size: 12px;
}
.stat-card strong,
.summary-card strong,
.kv-card strong {
display: block;
margin-top: 8px;
line-height: 1.6;
word-break: break-word;
}
.hero__aside {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.panel {
margin-top: 18px;
padding: 24px;
}
.tabs-panel {
padding-top: 18px;
}
.tabs-nav {
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
padding: 8px;
border: 1px solid #e8eef7;
border-radius: 999px;
background: #f7faff;
margin-bottom: 18px;
}
.tab-btn {
min-width: 112px;
min-height: 42px;
padding: 0 18px;
border: none;
border-radius: 999px;
background: transparent;
color: #667085;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.tab-btn.active {
background: linear-gradient(135deg, #2b63e3, #1f4fd6);
color: #fff;
box-shadow: 0 10px 24px rgba(29, 78, 216, 0.2);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.panel__head {
margin-bottom: 16px;
}
.empty-state {
border: 1px dashed #d7e1f0;
border-radius: 18px;
background: #fafcff;
color: #7d8899;
padding: 24px 18px;
}
.notice {
margin-top: 18px;
border-radius: 18px;
background: #ecfdf3;
border: 1px solid #ccebd9;
color: #0b7a4b;
padding: 14px 16px;
}
.public-grid {
display: grid;
gap: 16px;
}
.info-card {
padding: 18px;
}
.info-card__desc {
margin: 10px 0 0;
}
.kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.kv-card {
padding: 12px 14px;
background: #fafcff;
}
.timeline {
display: grid;
gap: 18px;
}
.timeline-item {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
gap: 16px;
}
.timeline-item__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #1d4ed8;
box-shadow: 0 0 0 5px rgba(29, 78, 216, 0.12);
}
.line {
width: 2px;
flex: 1;
min-height: 70px;
margin-top: 8px;
background: linear-gradient(180deg, rgba(29, 78, 216, 0.32), rgba(29, 78, 216, 0.04));
}
.timeline-item:last-child .line {
display: none;
}
.timeline-item__body {
padding: 18px;
background: linear-gradient(180deg, #fff 0%, #fbfcff 100%);
}
.timeline-item__head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.kv-image {
display: block;
width: 100%;
max-height: 280px;
object-fit: cover;
border-radius: 14px;
margin-top: 10px;
border: 1px solid #e6edf8;
background: #fff;
}
.feedback-form {
display: grid;
gap: 14px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.form-item {
display: grid;
gap: 8px;
}
.form-item--full {
margin-top: 2px;
}
.form-item input,
.form-item select,
.form-item textarea {
width: 100%;
padding: 12px 14px;
font: inherit;
color: #182235;
}
.form-item textarea {
min-height: 140px;
resize: vertical;
}
.submit-btn,
.back-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 46px;
padding: 0 18px;
border: none;
border-radius: 14px;
background: linear-gradient(135deg, #2b63e3, #1f4fd6);
color: #fff;
font: inherit;
cursor: pointer;
}
.back-link {
margin-top: 8px;
width: fit-content;
}
.error-panel {
margin-top: 64px;
}
@media (max-width: 992px) {
.hero,
.form-grid,
.hero__aside,
.kv-grid {
grid-template-columns: 1fr;
}
.hero__stats {
grid-template-columns: 1fr;
}
.timeline-item__head {
flex-direction: column;
}
.tabs-nav {
display: grid;
grid-template-columns: 1fr;
border-radius: 20px;
}
.tab-btn {
width: 100%;
}
}
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>未找到溯源信息</title>
<link rel="stylesheet" href="/static/traceability.css" />
</head>
<body>
<div class="page-shell">
<section class="panel error-panel">
<div class="panel__head">
<div>
<h2>未找到溯源信息</h2>
<p>${message}</p>
</div>
</div>
<a class="back-link" href="/">返回服务首页</a>
</section>
</div>
</body>
</html>
@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${page.batchName} - 溯源信息</title>
<link rel="stylesheet" href="/static/traceability.css" />
</head>
<body>
<div class="page-shell">
<section class="hero<#if page.coverImage?has_content> hero--with-cover</#if>">
<div class="hero__content">
<h1>${page.batchName}</h1>
<p>${page.summary}</p>
<div class="hero__stats">
<div class="stat-card">
<span>批次编码</span>
<strong>${page.code}</strong>
</div>
<div class="stat-card">
<span>产品名称</span>
<strong>${page.productName}</strong>
</div>
<div class="stat-card">
<span>所属模板</span>
<strong>${page.templateName}</strong>
</div>
<div class="stat-card">
<span>累计访问</span>
<strong>${page.scanCount}</strong>
</div>
</div>
</div>
<#if page.coverImage?has_content>
<div class="hero__cover">
<img src="${page.coverImage}" alt="${page.productName}" />
</div>
</#if>
<div class="hero__aside">
<div class="summary-card">
<span>发布时间</span>
<strong>${page.publishedAt}</strong>
</div>
<div class="summary-card">
<span>标签</span>
<strong>${page.tagsText}</strong>
</div>
</div>
</section>
<#if feedbackMessage?has_content>
<section class="notice">${feedbackMessage}</section>
</#if>
<section class="panel tabs-panel">
<div class="tabs-nav" role="tablist" aria-label="溯源页面内容切换">
<button class="tab-btn active" data-tab-target="timeline-panel" type="button">溯源链</button>
<button class="tab-btn" data-tab-target="public-panel" type="button">公开资料</button>
<button class="tab-btn" data-tab-target="feedback-panel" type="button">反馈与投诉</button>
</div>
<div id="timeline-panel" class="tab-panel active">
<div class="panel__head">
<div>
<h2>溯源链</h2>
<p>按业务流程顺序查看本批次的处理过程与留痕信息。</p>
</div>
</div>
<#if page.businessSections?size gt 0>
<div class="timeline">
<#list page.businessSections as section>
<article class="timeline-item">
<div class="timeline-item__rail">
<span class="dot"></span>
<span class="line"></span>
</div>
<div class="timeline-item__body">
<div class="timeline-item__head">
<div>
<h3>${section.name}</h3>
<p>${section.description}</p>
</div>
</div>
<div class="kv-grid">
<#list section.entries as entry>
<div class="kv-card">
<span>${entry.label}</span>
<#if entry.type == "image" && entry.value != "未填写">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
<#else>
<strong>${entry.value}</strong>
</#if>
</div>
</#list>
</div>
</div>
</article>
</#list>
</div>
<#else>
<div class="empty-state">当前批次还没有可展示的业务流程节点。</div>
</#if>
</div>
<div id="public-panel" class="tab-panel">
<div class="panel__head">
<div>
<h2>公开资料</h2>
<p>面向消费者展示的企业资料、资质证明及其他公开信息。</p>
</div>
</div>
<#if page.publicSections?size gt 0>
<div class="public-grid">
<#list page.publicSections as section>
<article class="info-card">
<div class="info-card__head">
<h3>${section.name}</h3>
</div>
<p class="info-card__desc">${section.description}</p>
<div class="kv-grid">
<#list section.entries as entry>
<div class="kv-card">
<span>${entry.label}</span>
<#if entry.type == "image" && entry.value != "未填写">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
<#else>
<strong>${entry.value}</strong>
</#if>
</div>
</#list>
</div>
</article>
</#list>
</div>
<#else>
<div class="empty-state">当前批次还没有可展示的公开资料。</div>
</#if>
</div>
<div id="feedback-panel" class="tab-panel">
<div class="panel__head">
<div>
<h2>反馈与投诉</h2>
<p>如发现信息异常、商品质量问题,或有建议,可直接提交。</p>
</div>
</div>
<form class="feedback-form" method="post" action="/feedback">
<input type="hidden" name="batchCode" value="${page.code}" />
<div class="form-grid">
<label class="form-item">
<span>反馈类型</span>
<select name="type">
<option value="complaint">投诉</option>
<option value="suggestion">建议</option>
<option value="consult">咨询</option>
</select>
</label>
<label class="form-item">
<span>满意度</span>
<select name="rating">
<option value="5">5 分</option>
<option value="4">4 分</option>
<option value="3">3 分</option>
<option value="2">2 分</option>
<option value="1">1 分</option>
</select>
</label>
<label class="form-item">
<span>联系方式</span>
<input name="contact" placeholder="手机号 / 邮箱 / 微信" />
</label>
</div>
<label class="form-item form-item--full">
<span>反馈内容</span>
<textarea name="content" placeholder="请填写你要反馈的问题或建议" required></textarea>
</label>
<button type="submit" class="submit-btn">提交反馈</button>
</form>
</div>
</section>
</div>
<script>
const tabButtons = document.querySelectorAll('.tab-btn');
const tabPanels = document.querySelectorAll('.tab-panel');
tabButtons.forEach((button) => {
button.addEventListener('click', () => {
const targetId = button.dataset.tabTarget;
tabButtons.forEach((item) => item.classList.remove('active'));
tabPanels.forEach((panel) => panel.classList.remove('active'));
button.classList.add('active');
document.getElementById(targetId)?.classList.add('active');
});
});
if (window.location.search.includes('result=')) {
const nextUrl = window.location.pathname + window.location.hash;
window.history.replaceState({}, '', nextUrl);
}
</script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
package com.bbitcn
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.Test
import kotlin.test.assertEquals
class ApplicationTest {
@Test
fun testRoot() = testApplication {
application {
module()
}
client.get("/").apply {
assertEquals(HttpStatusCode.OK, status)
}
}
}