// 主控:鉴权 / 事件列表 / 加载保存 / 校验 / 状态 / 导入导出 / 试走。 (function () { const App = { dict: { conditions: {}, grants: {} }, pointsets: {}, // name -> {mapId, points:[]} events: [], current: null, // 当前 group ir: null, // 工作副本 status: null, selectedNode: null, dirty: false, 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, name = $("login-name").value.trim(); 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; } if (name) { App.by = name; localStorage.setItem("story_by", name); } 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); // ---------- 导入 ---------- 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); } // ---------- 撤销 / 重做(Ctrl+Z / Ctrl+Y) ---------- let undoStack = [], redoStack = [], snapTimer = null; function snapReset() { undoStack = App.ir ? [JSON.stringify(App.ir)] : []; redoStack = []; } 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 = []; } } 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]); toast("已撤销"); } function redo() { if (!redoStack.length) return; const s = redoStack.pop(); undoStack.push(s); restoreState(s); toast("已重做"); } document.addEventListener("keydown", e => { if (!(e.ctrlKey || e.metaKey) || !App.ir) return; const a = document.activeElement; if (a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA")) return; // 输入框内交给浏览器 const k = e.key.toLowerCase(); if (k === "z" && !e.shiftKey) { e.preventDefault(); undo(); } else if (k === "y" || (k === "z" && e.shiftKey)) { e.preventDefault(); redo(); } }); // ---------- 画布工具栏 ---------- $("btn-autolayout").onclick = () => { if (!App.ir) return; App.ir._layout = null; // 清坐标 → render 时按自动布局重排 GraphUI.render(App.ir, App.selectedNode); App.dirty = true; updateDirty(); }; $("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(); } })(); })();