Files
AILab/trace-demo/app.js
T
2026-04-10 18:51:00 +08:00

1017 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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) => `
<div class="field-card">
<div class="form-grid compact">
<div class="field">
<label>字段编码</label>
<input ${editable ? "" : "disabled"} data-field-index="${index}" data-field-prop="key" value="${escapeHtml(field.key)}" />
</div>
<div class="field">
<label>字段名称</label>
<input ${editable ? "" : "disabled"} data-field-index="${index}" data-field-prop="label" value="${escapeHtml(field.label)}" />
</div>
<div class="field">
<label>字段类型</label>
<select ${editable ? "" : "disabled"} data-field-index="${index}" data-field-prop="type">
${FIELD_TYPES.map((type) => `<option value="${type}" ${field.type === type ? "selected" : ""}>${type}</option>`).join("")}
</select>
</div>
<div class="field">
<label>默认值</label>
<input ${editable ? "" : "disabled"} data-field-index="${index}" data-field-prop="defaultValue" value="${escapeHtml(Array.isArray(field.defaultValue) ? field.defaultValue.join("、") : (field.defaultValue ?? ""))}" />
</div>
<div class="field">
<label>必填</label>
<label class="switch"><input ${editable ? "" : "disabled"} type="checkbox" data-field-index="${index}" data-field-prop="required" ${field.required ? "checked" : ""} /><span></span></label>
</div>
<div class="field">
<label>消费者可见</label>
<label class="switch"><input ${editable ? "" : "disabled"} type="checkbox" data-field-index="${index}" data-field-prop="visible" ${field.visible ? "checked" : ""} /><span></span></label>
</div>
${["select", "multi_select"].includes(field.type) ? `
<div class="field full">
<label>选项</label>
<input ${editable ? "" : "disabled"} data-field-index="${index}" data-field-prop="options" value="${escapeHtml((field.options || []).join("、"))}" />
</div>
` : ""}
</div>
</div>
`).join("");
}
function renderNodeMetaForm(node, editable, actionsHtml = "") {
return `
<div class="editor-meta">
<div class="form-grid">
<div class="field">
<label>节点名称</label>
<input id="node-name" ${editable ? "" : "disabled"} value="${escapeHtml(node.name)}" />
</div>
<div class="field">
<label>消费者可见</label>
<label class="switch"><input id="node-visible" ${editable ? "" : "disabled"} type="checkbox" ${node.consumerVisible ? "checked" : ""} /><span></span></label>
</div>
<div class="field full">
<label>节点说明</label>
<textarea id="node-description" ${editable ? "" : "disabled"}>${escapeHtml(node.description)}</textarea>
</div>
</div>
<div class="editor-actions">${actionsHtml}</div>
<div class="field-list">${renderFieldEditor(node, editable)}</div>
</div>
`;
}
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 `
<section class="content-shell two-col">
<aside class="left-pane">
<div class="pane-head">
<h2>模板</h2>
<button class="primary-btn" id="btn-new-template">新建模板</button>
</div>
<div class="scroll-list">
${state.templates.map((item) => `
<button class="list-card ${ui.templateId === item.id ? "active" : ""}" data-template-id="${item.id}">
<strong>${escapeHtml(item.name)}</strong>
<span>${item.nodes.length} 个节点</span>
</button>
`).join("")}
</div>
</aside>
<section class="right-pane">
${!template ? `<div class="empty-panel">请选择模板。</div>` : `
<div class="editor-card">
<div class="pane-head">
<h2>模板编排</h2>
<button class="ghost-btn" id="btn-save-template-meta">保存模板信息</button>
</div>
<div class="form-grid compact">
<div class="field"><label>模板名称</label><input id="template-name" value="${escapeHtml(template.name)}" /></div>
<div class="field full"><label>模板说明</label><input id="template-description" value="${escapeHtml(template.description || "")}" /></div>
</div>
<div class="template-toolbar">
<div class="inline-select"><select id="template-add-business">${businessNodes.map((item) => `<option value="${item.id}">${escapeHtml(item.name)}</option>`).join("")}</select><button class="ghost-btn" id="btn-add-business-node">添加业务节点</button></div>
<div class="inline-select"><select id="template-add-public">${publicNodes.map((item) => `<option value="${item.id}">${escapeHtml(item.name)}</option>`).join("")}</select><button class="ghost-btn" id="btn-add-public-node">添加公共资料块</button></div>
<button class="primary-btn" id="btn-add-custom-node">新增临时节点</button>
</div>
<div class="node-strip">
${template.nodes.map((nodeRef, index) => `
<button class="node-pill ${ui.templateNodeId === nodeRef.id ? "active" : ""}" draggable="true" data-template-node-id="${nodeRef.id}">
<span class="pill-order">${index + 1}</span>
<span class="pill-name">${escapeHtml(templateNodeLabel(nodeRef))}</span>
<span class="pill-tag">${templateNodeTypeLabel(nodeRef)}</span>
</button>
`).join("")}
</div>
</div>
<div class="editor-card">
<div class="pane-head">
<h2>节点信息</h2>
<div class="pane-actions">
${currentRef ? `<button class="ghost-btn danger-btn" id="btn-remove-template-node">移除节点</button>` : ""}
</div>
</div>
${!currentRef || !currentNode
? `<div class="empty-panel">点击上方节点查看详情,支持拖动切换先后顺序。</div>`
: currentRef.source === "library"
? `<div class="readonly-tip">当前节点来自节点库,只能查看,不能在模板内直接修改。</div>${renderNodeMetaForm(currentNode, false)}`
: renderNodeMetaForm(currentNode, true, `<button class="ghost-btn" id="btn-save-custom-node">保存临时节点</button><button class="ghost-btn" id="btn-add-custom-field">新增字段</button>`)}
</div>
`}
</section>
</section>
`;
}
function renderLibraryPage() {
const currentTabNodes = state.nodeLibrary.filter((item) => item.category === ui.libraryTab);
const currentNode = currentLibraryNode();
return `
<section class="content-shell two-col">
<aside class="left-pane">
<div class="pane-head">
<div class="sub-tabs">
<button class="sub-tab ${ui.libraryTab === "business" ? "active" : ""}" data-library-tab="business">业务节点</button>
<button class="sub-tab ${ui.libraryTab === "public" ? "active" : ""}" data-library-tab="public">公共资料块</button>
</div>
<button class="primary-btn" id="btn-new-library-node">新建</button>
</div>
<div class="scroll-list">
${currentTabNodes.map((item) => `
<button class="list-card ${ui.libraryNodeId === item.id ? "active" : ""}" data-library-node-id="${item.id}">
<strong>${escapeHtml(item.name)}</strong>
<span>${item.fields.length} 个字段</span>
</button>
`).join("")}
</div>
</aside>
<section class="right-pane">
<div class="editor-card">
<div class="pane-head">
<h2>${ui.libraryTab === "business" ? "业务节点编辑器" : "公共资料块编辑器"}</h2>
<div class="pane-actions">
<button class="ghost-btn" id="btn-save-library-node">保存节点</button>
<button class="ghost-btn" id="btn-add-library-field">新增字段</button>
</div>
</div>
${currentNode ? renderNodeMetaForm(currentNode, true) : `<div class="empty-panel">请选择左侧节点开始编辑。</div>`}
</div>
</section>
</section>
`;
}
function renderTraceField(field, value) {
if (field.type === "select") {
return `<div class="field"><label>${escapeHtml(field.label)}${field.required ? " *" : ""}</label><select data-field-key="${field.key}">${(field.options || []).map((option) => `<option value="${escapeHtml(option)}" ${value === option ? "selected" : ""}>${escapeHtml(option)}</option>`).join("")}</select></div>`;
}
if (field.type === "multi_select") {
const selected = Array.isArray(value) ? value : [];
return `<div class="field full"><label>${escapeHtml(field.label)}${field.required ? " *" : ""}</label><div class="chips">${(field.options || []).map((option) => `<label class="chip-check"><input type="checkbox" data-field-key="${field.key}" value="${escapeHtml(option)}" ${selected.includes(option) ? "checked" : ""} /> ${escapeHtml(option)}</label>`).join("")}</div></div>`;
}
const type = field.type === "datetime" ? "datetime-local" : field.type === "integer" ? "number" : "text";
return `<div class="field ${["image", "link", "video_url", "json"].includes(field.type) ? "full" : ""}"><label>${escapeHtml(field.label)}${field.required ? " *" : ""}</label>${field.type === "json" ? `<textarea data-field-key="${field.key}">${escapeHtml(typeof value === "string" ? value : JSON.stringify(value ?? "", null, 2))}</textarea>` : `<input type="${type}" data-field-key="${field.key}" value="${escapeHtml(Array.isArray(value) ? value.join("、") : (value ?? ""))}" />`}</div>`;
}
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 `
<section class="content-shell operator-layout">
<aside class="left-pane">
<div class="pane-head">
<h2>批次列表</h2>
<button class="primary-btn" id="btn-new-trace">新建批次</button>
</div>
<div class="scroll-list">
${state.traces.map((item) => `
<button class="list-card ${ui.traceId === item.id ? "active" : ""}" data-trace-id="${item.id}">
<strong>${escapeHtml(item.name)}</strong>
<span>${escapeHtml(item.code)} · ${escapeHtml(item.status)}</span>
</button>
`).join("")}
</div>
</aside>
<section class="right-pane">
${!trace || !template ? `<div class="empty-panel">请先新建一个批次。</div>` : `
<div class="editor-card">
<div class="pane-head">
<h2>批次信息</h2>
<div class="pane-actions">
<button class="ghost-btn" id="btn-save-trace-base">保存基础信息</button>
<button class="primary-btn" id="btn-publish-trace">发布</button>
</div>
</div>
<div class="form-grid compact">
<div class="field"><label>批次名称</label><input id="trace-name" value="${escapeHtml(trace.name)}" /></div>
<div class="field"><label>批次码</label><input id="trace-code" value="${escapeHtml(trace.code)}" /></div>
</div>
<div class="progress-strip">
${template.nodes.map((nodeRef, index) => `
<button class="progress-step ${trace.currentIndex === index ? "active" : ""} ${index < trace.currentIndex ? "done" : ""}" data-progress-index="${index}">
<span class="step-index">${index + 1}</span>
<span class="step-name">${escapeHtml(templateNodeLabel(nodeRef))}</span>
</button>
`).join("")}
</div>
</div>
<div class="editor-card">
<div class="pane-head">
<div>
<h2>当前节点</h2>
<p class="muted-line">${escapeHtml(currentNode?.name || "")} · ${escapeHtml(templateNodeTypeLabel(currentNodeRef))}</p>
</div>
<button class="primary-btn" id="btn-save-trace-node">保存并推进</button>
</div>
${currentNode ? `<div class="form-grid">${currentNode.fields.map((field) => renderTraceField(field, currentValues[field.key])).join("")}</div>` : `<div class="empty-panel">暂无节点。</div>`}
</div>
`}
</section>
</section>
`;
}
function renderConsumerTimeline(trace, template) {
const refs = template.nodes.filter((item) => !isPublicNode(item));
return `
<div class="timeline-v2">
${refs.map((nodeRef, index) => {
const node = nodeRefToModel(nodeRef);
const values = trace.nodeData[nodeRef.id] || {};
return `
<div class="timeline-row">
<div class="timeline-rail">
<span class="timeline-dot ${index <= trace.currentIndex ? "active" : ""}"></span>
${index < refs.length - 1 ? `<span class="timeline-line"></span>` : ""}
</div>
<div class="timeline-body">
<div class="timeline-head">
<h3>${escapeHtml(node.name)}</h3>
<span>${escapeHtml(node.description)}</span>
</div>
<div class="timeline-grid">
${node.fields.filter((field) => field.visible).map((field) => {
const value = values[field.key];
const rendered = field.type === "image" && value
? `<a href="${escapeHtml(String(value))}" target="_blank" rel="noreferrer">查看图片</a>`
: (field.type === "link" || field.type === "video_url") && value
? `<a href="${escapeHtml(String(value))}" target="_blank" rel="noreferrer">打开链接</a>`
: escapeHtml(formatValue(value, field));
return `<div class="kv-card"><span>${escapeHtml(field.label)}</span><strong>${rendered}</strong></div>`;
}).join("")}
</div>
</div>
</div>
`;
}).join("")}
</div>
`;
}
function renderConsumerMaterials(trace, template) {
const refs = template.nodes.filter((item) => isPublicNode(item));
return `
<div class="materials-grid">
${refs.map((nodeRef) => {
const node = nodeRefToModel(nodeRef);
const values = trace.nodeData[nodeRef.id] || {};
return `
<div class="material-card">
<div class="timeline-head">
<h3>${escapeHtml(node.name)}</h3>
<span>${escapeHtml(node.description)}</span>
</div>
<div class="timeline-grid">
${node.fields.filter((field) => field.visible).map((field) => `<div class="kv-card"><span>${escapeHtml(field.label)}</span><strong>${escapeHtml(formatValue(values[field.key], field))}</strong></div>`).join("")}
</div>
</div>
`;
}).join("")}
</div>
`;
}
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 `
<section class="content-shell single-col">
<div class="editor-card">
<div class="pane-head">
<h2>消费者端</h2>
<div class="query-bar">
<input id="consumer-query" value="${escapeHtml(ui.consumerQuery)}" placeholder="请输入批次码,例如 TR-2026-001" />
<button class="primary-btn" id="btn-search-consumer">查询</button>
</div>
</div>
${!trace || !template ? `<div class="empty-panel">没有查到对应批次。</div>` : `
<div class="consumer-tabs">
<button class="sub-tab ${ui.consumerTab === "trace" ? "active" : ""}" data-consumer-tab="trace">溯源</button>
<button class="sub-tab ${ui.consumerTab === "materials" ? "active" : ""}" data-consumer-tab="materials">资料</button>
</div>
<div class="consumer-shell">
<div class="consumer-topbar">
<div>
<h2>${escapeHtml(trace.name)}</h2>
<p>${escapeHtml(trace.code)} · ${escapeHtml(trace.status)} · 累计扫码 ${trace.scans} 次</p>
</div>
</div>
${ui.consumerTab === "trace" ? renderConsumerTimeline(trace, template) : renderConsumerMaterials(trace, template)}
</div>
`}
</div>
</section>
`;
}
function renderStatsPage() {
return `
<section class="content-shell single-col">
<div class="stats-grid">
<div class="stats-card"><span>模板数量</span><strong>${state.templates.length}</strong></div>
<div class="stats-card"><span>节点定义</span><strong>${state.nodeLibrary.length}</strong></div>
<div class="stats-card"><span>累计扫码</span><strong>${totalScans()}</strong></div>
</div>
</section>
`;
}
function render() {
const pageHtml = {
templates: renderTemplatePage(),
library: renderLibraryPage(),
operator: renderOperatorPage(),
consumer: renderConsumerPage(),
stats: renderStatsPage()
}[ui.page];
$("#app").innerHTML = `
<main class="system-shell">
<aside class="main-nav">
<div class="brand">Trace Demo</div>
<button class="nav-btn ${ui.page === "templates" ? "active" : ""}" data-page="templates">模板</button>
<button class="nav-btn ${ui.page === "library" ? "active" : ""}" data-page="library">节点库</button>
<button class="nav-btn ${ui.page === "operator" ? "active" : ""}" data-page="operator">业务员端</button>
<button class="nav-btn ${ui.page === "consumer" ? "active" : ""}" data-page="consumer">消费者端</button>
<button class="nav-btn ${ui.page === "stats" ? "active" : ""}" data-page="stats">统计</button>
<button class="nav-btn danger" id="btn-reset-demo">重置</button>
</aside>
<section class="main-panel">${pageHtml}</section>
</main>
`;
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();