From f32df8cde0b12f5bcac4a9f623690b8d0aa11929 Mon Sep 17 00:00:00 2001 From: BBIT-Kai <2911862937@qq.com> Date: Mon, 25 May 2026 14:52:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=E4=BB=A3=E7=A0=81=EF=BC=9B?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=BF=9C=E7=A8=8B=E5=85=B1=E8=82=B2=E5=AE=A4?= =?UTF-8?q?=E5=85=AC=E5=BC=80=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ink/snowflake/server/Application.kt | 4 + .../ink/snowflake/server/controller/Public.kt | 37 + .../server/controller/RemoteDebug.kt | 113 +- readme.md | 2 +- trace-demo/README.md | 20 - trace-demo/app.js | 1016 ----------------- trace-demo/index.html | 16 - trace-demo/styles.css | 317 ----- 8 files changed, 99 insertions(+), 1426 deletions(-) create mode 100644 ktor/src/main/kotlin/ink/snowflake/server/controller/Public.kt delete mode 100644 trace-demo/README.md delete mode 100644 trace-demo/app.js delete mode 100644 trace-demo/index.html delete mode 100644 trace-demo/styles.css diff --git a/ktor/src/main/kotlin/ink/snowflake/server/Application.kt b/ktor/src/main/kotlin/ink/snowflake/server/Application.kt index d95cbb9..7a5047f 100644 --- a/ktor/src/main/kotlin/ink/snowflake/server/Application.kt +++ b/ktor/src/main/kotlin/ink/snowflake/server/Application.kt @@ -5,6 +5,7 @@ import ink.snowflake.server.controller.User import ink.snowflake.server.controller.chat import ink.snowflake.server.utils.plugins.configureSockets import ink.snowflake.server.controller.ImageAnalytics +import ink.snowflake.server.controller.Public import ink.snowflake.server.controller.RemoteDebug import ink.snowflake.server.controller.Traceability import ink.snowflake.server.controller.VideoAnalytics @@ -18,6 +19,7 @@ import ink.snowflake.server.utils.plugins.configureSerialization import ink.snowflake.server.utils.plugins.configureStaticPath import ink.snowflake.server.utils.plugins.configureStatusPages import ink.snowflake.server.utils.plugins.configureTemplating +import io.ktor.http.CacheControl import io.ktor.server.application.* import io.ktor.server.tomcat.jakarta.* @@ -76,4 +78,6 @@ fun Application.module() { // 业务-图片分析 ImageAnalytics() Traceability(appConfig) + // 业务-公开接口 + Public() } diff --git a/ktor/src/main/kotlin/ink/snowflake/server/controller/Public.kt b/ktor/src/main/kotlin/ink/snowflake/server/controller/Public.kt new file mode 100644 index 0000000..c91536f --- /dev/null +++ b/ktor/src/main/kotlin/ink/snowflake/server/controller/Public.kt @@ -0,0 +1,37 @@ +package ink.snowflake.server.controller + +import ink.snowflake.server.SERVER_PATH_FRP +import ink.snowflake.server.model.response.BaseResponse +import io.ktor.server.application.Application +import io.ktor.server.response.respond +import io.ktor.server.routing.get +import io.ktor.server.routing.route +import io.ktor.server.routing.routing + + +fun Application.Public() { + routing { + route("/silk-remote") { + get("/connectLocalDevice") { + val port = call.parameters["port"] + if (port != null) { + runAdbCommand("disconnect") + runAdbCommand("connect ${SERVER_PATH_FRP}:$port") + val url = + "https://ai.ronsunny.cn:8090/remote#!action=stream&udid=s3.ronsunny.cn%3ATTT&player=mse&ws=wss%3A%2F%2Fai.ronsunny.cn%3A8090%2Fremote%3Faction%3Dproxy-adb%26remote%3Dtcp%253A8886%26udid%3Ds3.ronsunny.cn%253ATTT" + .replace( + "TTT", + port + ) + call.respond(BaseResponse(data = url)) + } else { + call.respond(BaseResponse(status = false, message = "IP或端口无效", data = null)) + } + } + get("/disConnectAll") { + val result = runAdbCommand("disconnect") + call.respond(BaseResponse(data = result)) + } + } + } +} \ No newline at end of file diff --git a/ktor/src/main/kotlin/ink/snowflake/server/controller/RemoteDebug.kt b/ktor/src/main/kotlin/ink/snowflake/server/controller/RemoteDebug.kt index b78420b..e83040e 100644 --- a/ktor/src/main/kotlin/ink/snowflake/server/controller/RemoteDebug.kt +++ b/ktor/src/main/kotlin/ink/snowflake/server/controller/RemoteDebug.kt @@ -40,65 +40,66 @@ fun Application.RemoteDebug() { } } routing { - authenticate { - route("/remote") { - get("/connect") { - val ip = call.parameters["ip"] - val port = call.parameters["port"] - if (ip != null && port != null) { - val result = runAdbCommand("connect $ip:$port") - call.respond(BaseResponse(data = result)) - } else { - call.respond(BaseResponse(status = false, message = "IP或端口无效", data = null)) - } - } - get("/connectLocalDevice") { - val port = call.parameters["port"] - if (port != null) { - val result = runAdbCommand("connect ${SERVER_PATH_FRP}:$port") - call.respond(BaseResponse(data = result)) - } else { - call.respond(BaseResponse(status = false, message = "IP或端口无效", data = null)) - } - } - get("/disConnectAll") { - val result = runAdbCommand("disconnect") + authenticate { + route("/remote") { + get("/connect") { + val ip = call.parameters["ip"] + val port = call.parameters["port"] + if (ip != null && port != null) { + val result = runAdbCommand("connect $ip:$port") call.respond(BaseResponse(data = result)) - } - get("/runLinuxCommand") { - val command = call.parameters["command"] - if (command != null) { - call.respond(BaseResponse(data = runCommand(command))) - } else { - call.respond(BaseResponse(status = false, message = "Linux命令不可为空", data = null)) - } - } - get("/refreshDeviceList") { - try { - val name = call.parameters["name"] - val response: HttpResponse = client.get("http://${SERVER_PATH_FRP}:65534/api/proxy/tcp") - val responseBody: String = response.bodyAsText() - val devicesInfoRequest: DevicesInfoRequest = Gson().fromJson(responseBody, DevicesInfoRequest::class.java) - val onlineDevices = devicesInfoRequest.proxies.stream() - .filter { it.status == "online" && it.conf != null && it.conf.remotePort >= 10000 && it.conf.remotePort <= 20000 } - val devices: MutableList = mutableListOf() - for (data in onlineDevices) { - if (name == "null" || name == null || data.name.contains(name) && data.conf != null) { - devices.add(DeviceItemResponse(data.name, data.conf!!.remotePort)) - } - } - call.respond(BaseResponse(data = devices)) - } catch (e: Exception) { - call.respond( - BaseResponse( - status = false, - message = "端口信息请求失败:${e.message}", - data = null - ) - ) - } + } else { + call.respond(BaseResponse(status = false, message = "IP或端口无效", data = null)) } } + get("/connectLocalDevice") { + val port = call.parameters["port"] + if (port != null) { + val result = runAdbCommand("connect ${SERVER_PATH_FRP}:$port") + call.respond(BaseResponse(data = result)) + } else { + call.respond(BaseResponse(status = false, message = "IP或端口无效", data = null)) + } + } + get("/disConnectAll") { + val result = runAdbCommand("disconnect") + call.respond(BaseResponse(data = result)) + } + get("/runLinuxCommand") { + val command = call.parameters["command"] + if (command != null) { + call.respond(BaseResponse(data = runCommand(command))) + } else { + call.respond(BaseResponse(status = false, message = "Linux命令不可为空", data = null)) + } + } + get("/refreshDeviceList") { + try { + val name = call.parameters["name"] + val response: HttpResponse = client.get("http://${SERVER_PATH_FRP}:65534/api/proxy/tcp") + val responseBody: String = response.bodyAsText() + val devicesInfoRequest: DevicesInfoRequest = + Gson().fromJson(responseBody, DevicesInfoRequest::class.java) + val onlineDevices = devicesInfoRequest.proxies.stream() + .filter { it.status == "online" && it.conf != null && it.conf.remotePort >= 10000 && it.conf.remotePort <= 20000 } + val devices: MutableList = mutableListOf() + for (data in onlineDevices) { + if (name == "null" || name == null || data.name.contains(name) && data.conf != null) { + devices.add(DeviceItemResponse(data.name, data.conf!!.remotePort)) + } + } + call.respond(BaseResponse(data = devices)) + } catch (e: Exception) { + call.respond( + BaseResponse( + status = false, + message = "端口信息请求失败:${e.message}", + data = null + ) + ) + } + } + } } webSocket("/logStream") { send("日志系统连接成功") diff --git a/readme.md b/readme.md index a7b710f..eda16f5 100644 --- a/readme.md +++ b/readme.md @@ -101,7 +101,7 @@ | https | **8093** | 8883 | 8883 | ce_emqx | EMQX | MQTT TCP TLS 端口 | | | | 8083 | 8083 | ce_emqx | EMQX | MQTT WS 端口 | | | | 8084 | 8084 | ce_emqx | EMQX | MQTT WS TLS 端口 | -| | | | | | | | +| | | 3000 | | ce_gitea | Gitea | 管理页面:bbit:12345678 | | | | | | | | | | | | | | | | | | | | | | | | | diff --git a/trace-demo/README.md b/trace-demo/README.md deleted file mode 100644 index 041815c..0000000 --- a/trace-demo/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# 灵活溯源系统 MVP - -这是一个纯前端的 H5 演示版,直接打开 `index.html` 就能看效果。 - -## 已包含内容 - -- 管理员端:节点库、字段编辑、模板创建、公共资料块复用 -- 业务员端:基于模板新建批次、逐节点填报、二维码预览 -- 消费者端:时间轴溯源展示、企业信息、县域情况、有机证书 - -## 使用方式 - -1. 直接双击打开 `index.html` -2. 或者用任意静态文件服务打开 `trace-demo` 目录 - -## 说明 - -- 演示数据保存在浏览器 `localStorage` -- 点击“重置演示数据”可以恢复默认内容 -- 当前二维码是本地样式模拟,后续可替换成真实二维码 diff --git a/trace-demo/app.js b/trace-demo/app.js deleted file mode 100644 index a894415..0000000 --- a/trace-demo/app.js +++ /dev/null @@ -1,1016 +0,0 @@ -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 deleted file mode 100644 index 8621310..0000000 --- a/trace-demo/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - 灵活溯源系统 MVP - - - - - - -
- - - diff --git a/trace-demo/styles.css b/trace-demo/styles.css deleted file mode 100644 index 33ee271..0000000 --- a/trace-demo/styles.css +++ /dev/null @@ -1,317 +0,0 @@ -: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; } -}