完善各种需求

This commit is contained in:
BBIT-Kai
2026-04-14 16:52:30 +08:00
parent 1c68762421
commit 44181bcf5a
19 changed files with 1848 additions and 282 deletions
-1
View File
@@ -14,7 +14,6 @@ fun Application.module() {
monitor.subscribe(ApplicationStopped) {
traceabilityClient.close()
}
attributes.put(TraceabilityAttributes.ServiceKey, traceabilityService)
configureHTTP()
@@ -59,6 +59,7 @@ data class TraceBatchDetailResponse(
val summary: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val themeColor: String = "#1f4fd6",
val tags: List<String>,
val status: String,
val currentStep: Int,
@@ -108,6 +109,8 @@ data class DisplayEntry(
val type: String = "string",
val bold: Boolean = false,
val color: String = "",
val mapUrl: String = "",
val mapEmbedUrl: String = "",
)
data class PublicSectionView(
@@ -133,6 +136,7 @@ data class PageViewModel(
val templateName: String,
val summary: String,
val coverImage: String,
val themeColor: String,
val scanCount: Int,
val publishedAt: String,
val tagsText: String,
+72 -27
View File
@@ -4,6 +4,9 @@ import io.ktor.server.config.ApplicationConfig
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonPrimitive
data class TraceabilityPublicConfig(
val coreBaseUrl: String,
@@ -15,38 +18,21 @@ class TraceabilityService(
) {
suspend fun loadPage(code: String): PageViewModel? {
val detail = client.fetchPublicDetail(code, increaseScan = true) ?: return null
val batch = detail.batch
return PageViewModel(
code = batch.batchCode,
batchName = batch.batchName,
productName = batch.productName.ifBlank { batch.templateName },
templateName = batch.templateName,
summary = batch.summary.ifBlank { "该批次已完成关键环节留痕,可查看公开资料与业务流程。" },
coverImage = batch.coverImagePreviewUrl.ifBlank { batch.coverImage },
scanCount = batch.scanCount,
publishedAt = formatDateOnly(batch.publishedAt),
tagsText = batch.tags.joinToString("").ifBlank { "暂无标签" },
publicSections = detail.publicSections.map(::toPublicSectionView),
businessSections = detail.businessSections.map(::toTimelineSectionView),
return buildPageViewModel(
detail = detail,
summary = detail.batch.summary.ifBlank { "暂无说明" },
templateName = detail.batch.templateName,
publishedAt = detail.batch.publishedAt,
)
}
suspend fun loadPreviewPage(code: String): PageViewModel? {
val detail = client.fetchPreviewDetail(code) ?: return null
val batch = detail.batch
return PageViewModel(
code = batch.batchCode,
batchName = batch.batchName,
productName = batch.productName.ifBlank { batch.templateName },
return buildPageViewModel(
detail = detail,
summary = detail.batch.summary.ifBlank { "暂无说明" },
templateName = "预演页",
summary = batch.summary.ifBlank { "当前为预演页内容,可持续调整字段和值供客户确认。" },
coverImage = batch.coverImagePreviewUrl.ifBlank { batch.coverImage },
scanCount = batch.scanCount,
publishedAt = formatDateOnly(batch.updatedAt),
tagsText = batch.tags.joinToString("").ifBlank { "暂无标签" },
publicSections = detail.publicSections.map(::toPublicSectionView),
businessSections = detail.businessSections.map(::toTimelineSectionView),
publishedAt = detail.batch.updatedAt,
)
}
@@ -97,19 +83,33 @@ class TraceabilityService(
return step.values.entries.map { (key, value) ->
val field = step.fields.find { it.key == key }
val imageUrl = step.valuePreviewUrls[key].orEmpty()
val mapUrl = if ((field?.type ?: "string") == "coordinate") buildCoordinateMapUrl(value) else ""
val mapEmbedUrl = if ((field?.type ?: "string") == "coordinate") buildCoordinateEmbedUrl(value) else ""
DisplayEntry(
label = field?.label ?: key,
value = if ((field?.type ?: "string") == "image" && imageUrl.isNotBlank()) imageUrl else formatJsonValue(value),
type = field?.type ?: "string",
bold = field?.fieldStyle?.bold ?: false,
color = field?.fieldStyle?.color.orEmpty(),
mapUrl = mapUrl,
mapEmbedUrl = mapEmbedUrl,
)
}
}
private fun formatJsonValue(value: JsonElement): String = when (value) {
is JsonArray -> value.joinToString("") { formatJsonValue(it) }
is JsonObject -> value.entries.joinToString("") { "${it.key}: ${formatJsonValue(it.value)}" }
is JsonObject -> {
val lng = value["lng"]?.jsonPrimitive?.doubleOrNull
val lat = value["lat"]?.jsonPrimitive?.doubleOrNull
if (lng != null || lat != null) {
listOfNotNull(
if (lng != null && lat != null) "$lng, $lat" else null,
).joinToString(" | ").ifBlank { "未填写" }
} else {
value.entries.joinToString("") { "${it.key}: ${formatJsonValue(it.value)}" }
}
}
else -> value.toString().trim('"').ifBlank { "未填写" }
}
@@ -120,6 +120,51 @@ class TraceabilityService(
}
return text.substringBefore(" ").substringBefore("T")
}
private fun buildCoordinateMapUrl(value: JsonElement): String {
val coordinate = value as? JsonObject ?: return ""
val lng = coordinate["lng"]?.jsonPrimitive?.doubleOrNull
val lat = coordinate["lat"]?.jsonPrimitive?.doubleOrNull
return when {
lng != null && lat != null -> "https://www.openstreetmap.org/?mlat=$lat&mlon=$lng#map=15/$lat/$lng"
else -> ""
}
}
private fun buildCoordinateEmbedUrl(value: JsonElement): String {
val coordinate = value as? JsonObject ?: return ""
val lng = coordinate["lng"]?.jsonPrimitive?.doubleOrNull ?: return ""
val lat = coordinate["lat"]?.jsonPrimitive?.doubleOrNull ?: return ""
return "https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.01}%2C${lat - 0.01}%2C${lng + 0.01}%2C${lat + 0.01}&layer=mapnik&marker=$lat%2C$lng"
}
private fun buildPageViewModel(
detail: TraceabilityPublicDetailResponse,
summary: String,
templateName: String,
publishedAt: String,
): PageViewModel {
val batch = detail.batch
return PageViewModel(
code = batch.batchCode,
batchName = batch.batchName,
productName = batch.productName.ifBlank { batch.templateName },
templateName = templateName,
summary = summary,
coverImage = batch.coverImagePreviewUrl.ifBlank { batch.coverImage },
themeColor = normalizeThemeColor(batch.themeColor),
scanCount = batch.scanCount,
publishedAt = formatDateOnly(publishedAt),
tagsText = batch.tags.joinToString("").ifBlank { "暂无标签" },
publicSections = detail.publicSections.map(::toPublicSectionView),
businessSections = detail.businessSections.map(::toTimelineSectionView),
)
}
private fun normalizeThemeColor(value: String): String {
val text = value.trim()
return if (text.matches(Regex("^#([0-9a-fA-F]{6})$"))) text else "#1f4fd6"
}
}
fun ApplicationConfig.toTraceabilityPublicConfig(): TraceabilityPublicConfig {
+2 -2
View File
@@ -7,5 +7,5 @@ ktor:
traceability:
# 访问主服务的地址
# core-base-url: "http://127.0.0.1:8089" # 开发
core-base-url: "https://ai.ronsunny.cn:8090/api" # 生产
core-base-url: "http://127.0.0.1:8089" # 开发
# core-base-url: "https://ai.ronsunny.cn:8090/api" # 生产
+152 -49
View File
@@ -7,19 +7,27 @@ body {
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%),
radial-gradient(circle at top left, var(--traceability-primary-glow), transparent 32%),
linear-gradient(180deg, #f9fbff 0%, #f3f6fb 100%);
--traceability-primary: #1d4ed8;
--traceability-primary-strong: #163fae;
--traceability-primary-soft: rgba(29, 78, 216, 0.12);
--traceability-primary-border: rgba(29, 78, 216, 0.18);
--traceability-primary-glow: rgba(29, 78, 216, 0.08);
--traceability-primary-tint: #edf3ff;
--traceability-primary-tint-strong: #e5eefc;
--traceability-primary-tint-deep: #d7e6fa;
}
a {
color: #1d4ed8;
color: var(--traceability-primary);
text-decoration: none;
}
.page-shell {
max-width: 1440px;
max-width: 1520px;
margin: 0 auto;
padding: 36px 40px 56px;
padding: 20px 14px 44px;
}
.hero,
@@ -51,9 +59,9 @@ a {
.hero {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
padding: 26px;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.6fr);
gap: 16px;
padding: 22px;
}
.hero h1,
@@ -64,8 +72,8 @@ a {
}
.hero h1 {
margin-top: 16px;
font-size: 34px;
margin-top: 8px;
font-size: 30px;
line-height: 1.2;
}
@@ -78,14 +86,14 @@ a {
}
.hero p {
margin: 14px 0 0;
margin: 12px 0 0;
}
.hero__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
gap: 10px;
margin-top: 14px;
}
.stat-card,
@@ -107,8 +115,8 @@ a {
}
.summary-card {
min-height: 104px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
min-height: 88px;
background: linear-gradient(180deg, #ffffff 0%, var(--traceability-primary-tint) 100%);
}
.stat-card span,
@@ -131,29 +139,29 @@ a {
.hero__aside {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: 1fr;
gap: 12px;
align-content: start;
}
.panel {
margin-top: 18px;
padding: 24px;
margin-top: 14px;
padding: 18px;
}
.tabs-panel {
padding-top: 18px;
padding-top: 14px;
}
.tabs-nav {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 8px;
padding: 6px;
border: 1px solid #e8eef7;
border-radius: 999px;
background: #f7faff;
margin-bottom: 18px;
background: var(--traceability-primary-tint);
margin-bottom: 14px;
}
.tab-btn {
@@ -176,9 +184,9 @@ a {
}
.tab-btn.active {
background: linear-gradient(135deg, #2b63e3, #1f4fd6);
background: linear-gradient(135deg, var(--traceability-primary), var(--traceability-primary-strong));
color: #fff;
box-shadow: 0 10px 24px rgba(29, 78, 216, 0.2);
box-shadow: 0 10px 24px var(--traceability-primary-soft);
}
.tab-panel {
@@ -194,29 +202,29 @@ a {
}
.empty-state {
border: 1px dashed #d7e1f0;
border: 1px dashed var(--traceability-primary-border);
border-radius: 18px;
background: #fafcff;
color: #7d8899;
background: linear-gradient(180deg, #fafcff 0%, var(--traceability-primary-tint) 100%);
color: #60708a;
padding: 24px 18px;
}
.notice {
margin-top: 18px;
border-radius: 18px;
background: #ecfdf3;
border: 1px solid #ccebd9;
color: #0b7a4b;
background: linear-gradient(180deg, var(--traceability-primary-tint-strong) 0%, var(--traceability-primary-tint) 100%);
border: 1px solid var(--traceability-primary-border);
color: var(--traceability-primary-strong);
padding: 14px 16px;
}
.public-grid {
display: grid;
gap: 16px;
gap: 12px;
}
.info-card {
padding: 18px;
padding: 14px;
}
.info-card__desc {
@@ -226,24 +234,24 @@ a {
.kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
gap: 10px;
margin-top: 12px;
}
.kv-card {
padding: 12px 14px;
background: #fafcff;
padding: 11px 12px;
background: linear-gradient(180deg, #fafcff 0%, var(--traceability-primary-tint) 100%);
}
.timeline {
display: grid;
gap: 18px;
gap: 12px;
}
.timeline-item {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
gap: 16px;
grid-template-columns: 24px minmax(0, 1fr);
gap: 12px;
}
.timeline-item__rail {
@@ -256,16 +264,16 @@ a {
width: 14px;
height: 14px;
border-radius: 50%;
background: #1d4ed8;
box-shadow: 0 0 0 5px rgba(29, 78, 216, 0.12);
background: var(--traceability-primary);
box-shadow: 0 0 0 5px var(--traceability-primary-soft);
}
.line {
width: 2px;
flex: 1;
min-height: 70px;
min-height: 52px;
margin-top: 8px;
background: linear-gradient(180deg, rgba(29, 78, 216, 0.32), rgba(29, 78, 216, 0.04));
background: linear-gradient(180deg, var(--traceability-primary-border), rgba(29, 78, 216, 0.04));
}
.timeline-item:last-child .line {
@@ -273,8 +281,8 @@ a {
}
.timeline-item__body {
padding: 18px;
background: linear-gradient(180deg, #fff 0%, #fbfcff 100%);
padding: 14px;
background: linear-gradient(180deg, #fff 0%, var(--traceability-primary-tint) 100%);
}
.timeline-item__head {
@@ -287,14 +295,52 @@ a {
.kv-image {
display: block;
width: 100%;
max-height: 280px;
max-height: 240px;
object-fit: cover;
border-radius: 14px;
margin-top: 10px;
border: 1px solid #e6edf8;
border: 1px solid var(--traceability-primary-border);
background: #fff;
}
.kv-image-button {
display: grid;
gap: 8px;
width: 100%;
padding: 0;
border: none;
background: transparent;
text-align: left;
cursor: zoom-in;
}
.kv-image-button span {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 4px 8px;
border-radius: 999px;
background: var(--traceability-primary-soft);
color: var(--traceability-primary);
font-size: 12px;
}
.kv-map {
display: block;
width: 100%;
min-height: 220px;
margin-top: 10px;
border: 1px solid var(--traceability-primary-border);
border-radius: 14px;
background: #fff;
}
.kv-link {
display: inline-flex;
margin-top: 10px;
font-size: 12px;
}
.feedback-form {
display: grid;
gap: 14px;
@@ -338,7 +384,7 @@ a {
padding: 0 18px;
border: none;
border-radius: 14px;
background: linear-gradient(135deg, #2b63e3, #1f4fd6);
background: linear-gradient(135deg, var(--traceability-primary), var(--traceability-primary-strong));
color: #fff;
font: inherit;
cursor: pointer;
@@ -354,13 +400,66 @@ a {
}
.page-footer {
padding: 24px 4px 8px;
padding: 18px 4px 8px;
text-align: center;
color: #7d8899;
font-size: 13px;
line-height: 1.8;
}
.image-viewer {
position: fixed;
inset: 0;
display: none;
z-index: 1000;
}
.image-viewer.is-open {
display: grid;
place-items: center;
}
.image-viewer__mask {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.78);
backdrop-filter: blur(4px);
}
.image-viewer__content {
position: relative;
z-index: 1;
width: min(92vw, 1080px);
max-height: 90vh;
margin: 0;
padding: 18px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32);
}
.image-viewer__content img {
display: block;
width: 100%;
max-height: 84vh;
object-fit: contain;
}
.image-viewer__close {
position: absolute;
top: 10px;
right: 12px;
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: var(--traceability-primary-tint);
color: var(--traceability-primary-strong);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
@media (max-width: 992px) {
.hero,
.form-grid,
@@ -380,7 +479,7 @@ a {
@media (max-width: 768px) {
.page-shell {
padding: 24px 18px 44px;
padding: 16px 12px 32px;
}
.tabs-nav {
@@ -393,4 +492,8 @@ a {
padding: 0 10px;
font-size: 13px;
}
.hero h1 {
font-size: 26px;
}
}
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
@@ -6,7 +6,7 @@
<title>${page.batchName} - 溯源信息</title>
<link rel="stylesheet" href="/static/traceability.css" />
</head>
<body>
<body style="--traceability-primary:${page.themeColor};">
<div class="page-shell">
<#if page.coverImage?has_content>
<section class="cover-panel">
@@ -37,10 +37,6 @@
</div>
<div class="hero__aside">
<div class="summary-card">
<span>发布时间</span>
<strong>${page.publishedAt}</strong>
</div>
<#if page.tagsText != "暂无标签">
<div class="summary-card">
<span>标签</span>
@@ -78,7 +74,14 @@
<div class="kv-card">
<span>${entry.label}</span>
<#if entry.type == "image" && entry.value?has_content && entry.value != "未填写">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
<button class="kv-image-button" type="button" data-image-src="${entry.value}" data-image-alt="${entry.label}">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
</button>
<#elseif entry.type == "coordinate" && entry.value?has_content && entry.value != "未填写">
<#if entry.mapEmbedUrl?has_content>
<iframe class="kv-map" src="${entry.mapEmbedUrl}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" title="${entry.label}"></iframe>
</#if>
<strong<#if entry.bold || entry.color?has_content> style="<#if entry.bold>font-weight:700;</#if><#if entry.color?has_content>color:${entry.color};</#if>"</#if>>${entry.value}</strong>
<#else>
<strong<#if entry.bold || entry.color?has_content> style="<#if entry.bold>font-weight:700;</#if><#if entry.color?has_content>color:${entry.color};</#if>"</#if>>${entry.value}</strong>
</#if>
@@ -108,7 +111,14 @@
<div class="kv-card">
<span>${entry.label}</span>
<#if entry.type == "image" && entry.value?has_content && entry.value != "未填写">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
<button class="kv-image-button" type="button" data-image-src="${entry.value}" data-image-alt="${entry.label}">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
</button>
<#elseif entry.type == "coordinate" && entry.value?has_content && entry.value != "未填写">
<#if entry.mapEmbedUrl?has_content>
<iframe class="kv-map" src="${entry.mapEmbedUrl}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" title="${entry.label}"></iframe>
</#if>
<strong<#if entry.bold || entry.color?has_content> style="<#if entry.bold>font-weight:700;</#if><#if entry.color?has_content>color:${entry.color};</#if>"</#if>>${entry.value}</strong>
<#else>
<strong<#if entry.bold || entry.color?has_content> style="<#if entry.bold>font-weight:700;</#if><#if entry.color?has_content>color:${entry.color};</#if>"</#if>>${entry.value}</strong>
</#if>
@@ -180,8 +190,64 @@
</div>
<script>
const themeColor = '${page.themeColor}';
function hexToRgb(hex) {
const normalized = (hex || '').replace('#', '').trim();
if (!/^[0-9a-fA-F]{6}$/.test(normalized)) {
return null;
}
return {
r: parseInt(normalized.slice(0, 2), 16),
g: parseInt(normalized.slice(2, 4), 16),
b: parseInt(normalized.slice(4, 6), 16),
};
}
function rgbToHex(r, g, b) {
return '#' + [r, g, b]
.map((value) => Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, '0'))
.join('');
}
function mixColor(baseHex, targetHex, ratio) {
const base = hexToRgb(baseHex);
const target = hexToRgb(targetHex);
if (!base || !target) {
return baseHex;
}
const mix = (from, to) => from + (to - from) * ratio;
return rgbToHex(mix(base.r, target.r), mix(base.g, target.g), mix(base.b, target.b));
}
function hexToRgba(hex, alpha) {
const rgb = hexToRgb(hex);
if (!rgb) {
return 'rgba(29, 78, 216, ' + alpha + ')';
}
return 'rgba(' + rgb.r + ', ' + rgb.g + ', ' + rgb.b + ', ' + alpha + ')';
}
const primary = hexToRgb(themeColor) ? themeColor : '#1f4fd6';
const strong = mixColor(primary, '#000000', 0.18);
const soft = hexToRgba(primary, 0.12);
const border = hexToRgba(primary, 0.18);
const glow = hexToRgba(primary, 0.08);
const tint = mixColor(primary, '#ffffff', 0.92);
const tintStrong = mixColor(primary, '#ffffff', 0.86);
const tintDeep = mixColor(primary, '#ffffff', 0.78);
const body = document.body;
body.style.setProperty('--traceability-primary', primary);
body.style.setProperty('--traceability-primary-strong', strong);
body.style.setProperty('--traceability-primary-soft', soft);
body.style.setProperty('--traceability-primary-border', border);
body.style.setProperty('--traceability-primary-glow', glow);
body.style.setProperty('--traceability-primary-tint', tint);
body.style.setProperty('--traceability-primary-tint-strong', tintStrong);
body.style.setProperty('--traceability-primary-tint-deep', tintDeep);
const tabButtons = document.querySelectorAll('.tab-btn');
const tabPanels = document.querySelectorAll('.tab-panel');
const imageButtons = document.querySelectorAll('.kv-image-button');
tabButtons.forEach((button) => {
button.addEventListener('click', () => {
@@ -193,6 +259,34 @@
});
});
const viewer = document.createElement('div');
viewer.className = 'image-viewer';
viewer.innerHTML = '<div class="image-viewer__mask"></div><figure class="image-viewer__content"><button class="image-viewer__close" type="button" aria-label="关闭放大图">×</button><img alt=""></figure>';
document.body.appendChild(viewer);
const viewerImage = viewer.querySelector('.image-viewer__content img');
const closeViewer = () => viewer.classList.remove('is-open');
viewer.addEventListener('click', (event) => {
const target = event.target;
if (target === viewer || target.classList.contains('image-viewer__mask') || target.classList.contains('image-viewer__close')) {
closeViewer();
}
});
imageButtons.forEach((button) => {
button.addEventListener('click', () => {
const src = button.dataset.imageSrc || '';
const alt = button.dataset.imageAlt || '';
if (!src) {
return;
}
viewerImage.src = src;
viewerImage.alt = alt;
viewer.classList.add('is-open');
});
});
if (window.location.search.includes('result=')) {
const nextUrl = window.location.pathname + window.location.hash;
window.history.replaceState({}, '', nextUrl);
@@ -200,3 +294,4 @@
</script>
</body>
</html>