// 主控:鉴权 / 事件列表 / 加载保存 / 校验 / 状态 / 导入导出 / 试走。 (function () { const App = { dict: { conditions: {}, grants: {} }, pointsets: {}, // name -> {mapId, points:[]} events: [], current: null, // 当前 group ir: null, // 工作副本 status: null, selectedNode: null, dirty: false, mode: "review", // review=海选审核 / perform=演出配置 by: localStorage.getItem("story_by") || "匿名", }; window.App = App; const $ = id => document.getElementById(id); async function api(path, opts) { const r = await fetch(path, Object.assign({ headers: { "Content-Type": "application/json" } }, opts)); if (r.status === 401) { showLogin(); throw new Error("未授权"); } return r; } // ---------- 鉴权 ---------- function showLogin() { $("login").classList.remove("hidden"); $("login").style.display = "flex"; } function hideLogin() { $("login").style.display = "none"; } $("login-btn").onclick = async () => { const pass = $("login-pass").value; const r = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pass }) }); if (!r.ok) { $("login-err").textContent = "口令错误"; return; } const d = await r.json(); // 身份由服务端按口令认定(口令即身份) App.by = d.user || "?"; localStorage.setItem("story_by", App.by); hideLogin(); init(); }; $("login-pass").onkeydown = e => { if (e.key === "Enter") $("login-btn").click(); }; // ---------- 初始化 ---------- async function init() { $("who").textContent = "你:" + App.by; try { App.dict = await (await api("/api/dictionary")).json(); App.pointsets = await (await api("/api/pointsets")).json(); } catch (e) { return; } await loadList(); } // ---------- 列表 ---------- async function loadList() { const status = $("filter-status").value; const r = await api("/api/events?status=" + encodeURIComponent(status)); App.events = await r.json(); renderList(); } function renderList() { const q = $("search").value.trim().toLowerCase(); const host = $("event-list"); host.innerHTML = ""; const badge = { pending: ["b-pending", "待审"], confirmed: ["b-confirmed", "已确认"], discarded: ["b-discarded", "已丢弃"] }; App.events .filter(e => !q || (e.title || "").toLowerCase().includes(q) || (e.group || "").toLowerCase().includes(q)) .forEach(e => { const b = badge[e.status] || ["b-pending", e.status]; const d = document.createElement("div"); d.className = "ev" + (e.group === App.current ? " sel" : ""); d.innerHTML = '' + b[1] + '' + '
' + esc(e.title || e.group) + '
' + '
' + esc(e.group) + ' · ' + esc(e.updated_by || "") + ' ' + esc((e.updated_at || "").slice(5, 16)) + '
'; d.onclick = () => selectEvent(e.group); host.appendChild(d); }); if (!host.children.length) host.innerHTML = '
无事件,点「导入 IR」
'; } $("filter-status").onchange = loadList; $("search").oninput = renderList; // ---------- 选中事件 ---------- async function selectEvent(group) { if (App.dirty && !confirm("当前事件有未保存修改,放弃并切换?")) return; const r = await api("/api/events/" + encodeURIComponent(group)); const d = await r.json(); App.current = group; App.ir = JSON.parse(JSON.stringify(d.ir)); App.status = d.status; App.selectedNode = null; App.dirty = false; $("graph-empty").style.display = "none"; ["btn-save", "btn-validate", "btn-playtest", "btn-confirm", "btn-discard", "btn-addnode", "btn-autolayout", "btn-addsucc"].forEach(b => $(b).disabled = false); renderAll(true); GraphUI.focusStart(App.ir); // 定位到开头节点 snapReset(); // 初始化撤销栈 renderList(); updateDirty(); } const ctx = () => ({ dict: App.dict, pointNames: (App.pointsets[(App.ir.stage || {}).point_set || App.ir.id] || {}).points || [], onChange: structural => { App.dirty = true; updateDirty(); if (structural) { GraphUI.render(App.ir, App.selectedNode); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); } else { GraphUI.updateLabel(App.ir, App.selectedNode); } scheduleSnapshot(); }, selectNode: id => { App.selectedNode = id; }, deleteNode: id => deleteNode(id), }); function selectNode(id) { App.selectedNode = id; GraphUI.select(id); FormUI.renderNode(App.ir, id, ctx()); } // 工具栏「加后继」:给当前选中节点建一个后继并自动接线(无选中则忽略) function addSuccessor(id) { const r = FormUI.addSuccessor(App.ir, id); if (!r) return; if (!r.linked) alert("该战斗节点的「胜→win」「败→lose」出口都已占用,\n新节点已创建但未自动接线——请在右栏把胜或败指向它(id: " + r.id + ")。"); App.dirty = true; App.selectedNode = r.id; GraphUI.render(App.ir, r.id); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, r.id, ctx()); updateDirty(); } // 纯数据删除节点/结局 function removeFromIr(id) { App.ir.nodes = (App.ir.nodes || []).filter(n => n.id !== id); App.ir.endings = (App.ir.endings || []).filter(e => e.id !== id); if (App.ir._layout) delete App.ir._layout[id]; if (App.selectedNode === id) App.selectedNode = null; App.dirty = true; updateDirty(); } // 线性节点的唯一后继(多出口/结局返回 null,不缝合) function uniqueSuccessor(node) { if (!node) return null; const k = node.kind; if (k === "choice" || k === "choice_once" || k === "random" || k === "fight") return null; if ((App.ir.endings || []).some(e => e.id === node.id)) return null; return node.next || null; } // 把所有指向 from 的跳转改接到 to function retarget(from, to) { (App.ir.nodes || []).forEach(n => { if (n.next === from) n.next = to; if (n.win === from) n.win = to; if (n.lose === from) n.lose = to; (n.options || []).forEach(o => { if (o.goto === from) o.goto = to; if (o.skip && o.skip.node === from) o.skip.node = to; }); (n.branches || []).forEach(b => { if (b.goto === from) b.goto = to; }); }); } // 删除节点:若是线性中间节点,删除后把前驱缝合到它的后继 function deleteNode(id) { const node = (App.ir.nodes || []).find(n => n.id === id); const succ = uniqueSuccessor(node); if (succ && succ !== id) retarget(id, succ); removeFromIr(id); renderAll(); scheduleSnapshot(); } function renderAll() { GraphUI.render(App.ir, App.selectedNode); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); } function updateDirty() { $("btn-save").textContent = App.dirty ? "保存 *" : "保存"; $("who").textContent = "你:" + App.by + (App.current ? (" | " + App.current + "(" + (App.status || "") + ")") : ""); } // ---------- 增节点 ---------- $("btn-addnode").onclick = () => { if (!App.ir) return; const id = FormUI.newNode(App.ir); App.dirty = true; App.selectedNode = id; GraphUI.render(App.ir, id); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, id, ctx()); updateDirty(); }; // ---------- 保存 ---------- $("btn-save").onclick = async () => { if (!App.ir) return; const r = await api("/api/events/" + encodeURIComponent(App.current), { method: "PUT", body: JSON.stringify({ ir: App.ir, by: App.by }) }); if (r.ok) { App.dirty = false; updateDirty(); await loadList(); } else alert("保存失败:" + (await r.text())); }; // ---------- 校验 ---------- $("btn-validate").onclick = async () => { const r = await api("/api/validate", { method: "POST", body: JSON.stringify({ ir: App.ir }) }); const d = await r.json(); showValidate(d.errors || [], d.warnings || []); }; function showValidate(errs, warns) { let h = ""; if (!errs.length && !warns.length) h = '
✓ 校验通过,无错误无警告
'; errs.forEach(e => h += '
✗ ' + esc(e) + '
'); warns.forEach(w => h += '
⚠ ' + esc(w) + '
'); $("validate-body").innerHTML = h; $("validate-modal").classList.remove("hidden"); } // ---------- 状态 ---------- async function setStatus(s) { const r = await api("/api/events/" + encodeURIComponent(App.current) + "/status", { method: "POST", body: JSON.stringify({ status: s, by: App.by }) }); if (r.ok) { App.status = s; updateDirty(); await loadList(); } } $("btn-confirm").onclick = () => setStatus("confirmed"); $("btn-discard").onclick = () => setStatus("discarded"); // ---------- 试走 ---------- $("btn-playtest").onclick = () => Playtest.open(App.ir, App.dict); // ---------- 模式切换:海选审核 / 演出配置 ---------- function setMode(m) { App.mode = m; $("mode-review").classList.toggle("active", m === "review"); $("mode-perform").classList.toggle("active", m === "perform"); $("wrap").classList.toggle("hidden", m !== "review"); $("perform-wrap").classList.toggle("hidden", m !== "perform"); $("review-toolbar").style.display = m === "review" ? "" : "none"; document.body.classList.toggle("perform-mode", m === "perform"); // 切背景色调 Timeline.stop(); if (m === "perform") performLoadList(); } $("mode-review").onclick = () => setMode("review"); $("mode-perform").onclick = () => setMode("perform"); // ---------- 演出配置页:已确认事件列表 + 内嵌白模预览 ---------- let performCurrent = null; async function performLoadList() { let list; try { list = await (await api("/api/events?status=confirmed")).json(); } catch (e) { return; } const host = $("perform-list"); host.innerHTML = ""; if (!list.length) { host.innerHTML = '
还没有已确认的事件。去「海选审核」确认事件后再来配置演出。
'; return; } list.forEach(e => { const d = document.createElement("div"); d.className = "ev" + (e.group === performCurrent ? " sel" : ""); d.innerHTML = '
' + esc(e.title || e.group) + '
' + esc(e.group) + ' · ' + esc(e.updated_by || "") + '
'; d.onclick = () => performSelect(e.group); host.appendChild(d); }); } async function performSelect(group) { let d; try { d = await (await api("/api/events/" + encodeURIComponent(group))).json(); } catch (e) { return; } performCurrent = group; performLoadList(); Timeline.show($("perform-main"), d.ir, App.dict, App.pointsets); } // ---------- 导入 ---------- let importFiles = []; // 当前已选文件 function renderImportFiles() { const host = $("import-files"); host.innerHTML = ""; importFiles.forEach((f, i) => { const d = document.createElement("div"); d.className = "fileitem"; d.innerHTML = '' + esc(f.name) + '' + (f.size > 1024 ? (f.size / 1024).toFixed(1) + " KB" : f.size + " B") + ''; d.querySelector(".rm").onclick = () => { importFiles.splice(i, 1); renderImportFiles(); }; host.appendChild(d); }); } function addImportFiles(fileList) { for (const f of fileList) if (!importFiles.some(x => x.name === f.name && x.size === f.size)) importFiles.push(f); renderImportFiles(); } $("btn-import").onclick = () => { importFiles = []; renderImportFiles(); $("import-text").value = ""; $("import-result").textContent = ""; $("import-modal").classList.remove("hidden"); }; $("import-drop").onclick = () => $("import-file").click(); $("import-file").onchange = e => { addImportFiles(e.target.files); e.target.value = ""; }; const drop = $("import-drop"); ["dragenter", "dragover"].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add("over"); })); ["dragleave", "drop"].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove("over"); })); drop.addEventListener("drop", e => { addImportFiles(e.dataTransfer.files); }); async function collectImportEvents() { const events = []; for (const f of importFiles) { let text; try { text = await f.text(); } catch (e) { throw new Error(f.name + ":读取失败"); } let data; try { data = JSON.parse(text); } catch (e) { throw new Error(f.name + ":JSON 解析失败 " + e.message); } (Array.isArray(data) ? data : [data]).forEach(x => events.push(x)); } const pasted = $("import-text").value.trim(); if (pasted) { let data; try { data = JSON.parse(pasted); } catch (e) { throw new Error("粘贴文本:JSON 解析失败 " + e.message); } (Array.isArray(data) ? data : [data]).forEach(x => events.push(x)); } return events; } $("import-do").onclick = async () => { $("import-result").textContent = ""; let events; try { events = await collectImportEvents(); } catch (e) { $("import-result").textContent = e.message; return; } if (!events.length) { $("import-result").textContent = "请先选择文件或粘贴 JSON"; return; } const r = await api("/api/import", { method: "POST", body: JSON.stringify({ events, by: App.by }) }); const d = await r.json(); importFiles = []; renderImportFiles(); $("import-text").value = ""; $("import-modal").classList.add("hidden"); // 导入完直接关闭 const ne = (d.errors || []).length; toast("已导入 " + (d.saved || []).length + " 个事件" + (ne ? "," + ne + " 个跳过/出错" : "")); await loadList(); }; // ---------- 导出 ---------- $("btn-export").onclick = async () => { const r = await api("/api/export", { method: "POST", body: JSON.stringify({}) }); if (r.ok) { const blob = await r.blob(); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "story_export.zip"; a.click(); } else { const d = await r.json().catch(() => ({})); let msg = d.error || "导出失败"; if (d.report) { for (const g in d.report) { const e = d.report[g].errors; if (e.length) msg += "\n[" + g + "] " + e.join(";"); } } alert(msg); } }; // ---------- 遮罩关闭 ---------- document.querySelectorAll(".modal-close").forEach(b => b.onclick = () => b.closest(".overlay").classList.add("hidden")); // ---------- 工具 ---------- function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">"); } let toastTimer = null; function toast(msg) { let el = $("toast"); if (!el) { el = document.createElement("div"); el.id = "toast"; document.body.appendChild(el); } el.textContent = msg; el.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove("show"), 2600); } // 未捕获错误 → toast 提示(便于同事发现并反馈,而不是默默白屏) window.addEventListener("error", e => { try { toast("⚠ 页面出错:" + (e.message || "未知")); } catch (_) {} }); window.addEventListener("unhandledrejection", e => { const m = String((e.reason && e.reason.message) || e.reason || "未知"); if (m.includes("未授权")) return; try { toast("⚠ 操作失败:" + m); } catch (_) {} }); // ---------- 撤销 / 重做 ---------- let undoStack = [], redoStack = [], snapTimer = null; function updateUndoButtons() { const u = $("btn-undo"), r = $("btn-redo"); if (u) u.disabled = undoStack.length < 2; if (r) r.disabled = redoStack.length === 0; } function snapReset() { undoStack = App.ir ? [JSON.stringify(App.ir)] : []; redoStack = []; updateUndoButtons(); } function flushSnapshot() { if (!App.ir) return; const cur = JSON.stringify(App.ir); if (!undoStack.length || undoStack[undoStack.length - 1] !== cur) { undoStack.push(cur); if (undoStack.length > 60) undoStack.shift(); redoStack = []; updateUndoButtons(); } } function scheduleSnapshot() { clearTimeout(snapTimer); snapTimer = setTimeout(flushSnapshot, 450); } function restoreState(json) { App.ir = JSON.parse(json); App.selectedNode = null; App.dirty = true; renderAll(); updateDirty(); } function undo() { flushSnapshot(); if (undoStack.length < 2) return; redoStack.push(undoStack.pop()); restoreState(undoStack[undoStack.length - 1]); updateUndoButtons(); toast("已撤销"); } function redo() { if (!redoStack.length) return; const s = redoStack.pop(); undoStack.push(s); restoreState(s); updateUndoButtons(); toast("已重做"); } $("btn-undo").onclick = undo; $("btn-redo").onclick = redo; // ---------- 快捷键:Ctrl+Z/Y 撤销重做、R 自动整理、Enter 加后继 ---------- document.addEventListener("keydown", e => { if (!App.ir) return; const a = document.activeElement; const inInput = a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA"); if (e.ctrlKey || e.metaKey) { if (inInput) return; // 输入框内的 Ctrl+Z 交给浏览器 const k = e.key.toLowerCase(); if (k === "z" && !e.shiftKey) { e.preventDefault(); undo(); } else if (k === "y" || (k === "z" && e.shiftKey)) { e.preventDefault(); redo(); } return; } if (e.altKey || inInput) return; if (e.key.toLowerCase() === "r") { e.preventDefault(); doAutoLayout(); } else if (e.key === "Enter" && App.selectedNode) { e.preventDefault(); addSuccessor(App.selectedNode); } }); // ---------- 画布工具栏 ---------- function doAutoLayout() { if (!App.ir) return; App.ir._layout = null; // 清坐标 → render 时按自动布局重排 GraphUI.render(App.ir, App.selectedNode); GraphUI.focusStart(App.ir); App.dirty = true; updateDirty(); scheduleSnapshot(); toast("已自动整理"); } $("btn-autolayout").onclick = doAutoLayout; $("btn-addsucc").onclick = () => { if (!App.ir || !App.selectedNode) { alert("先在画布上点选一个节点,再点「加后继」。"); return; } addSuccessor(App.selectedNode); }; // ---------- 启动 ---------- GraphUI.init("drawflow", { onSelect: id => selectNode(id), onMove: (id, x, y) => { if (!App.ir) return; (App.ir._layout = App.ir._layout || {})[id] = { x: x, y: y }; App.dirty = true; updateDirty(); scheduleSnapshot(); }, onConnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); scheduleSnapshot(); }, onDisconnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); scheduleSnapshot(); }, onDeleteSelected: id => deleteNode(id), }); (async function () { try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); } catch (e) { showLogin(); } })(); })();