Files
story-edit-web/web/static/app.js
邓雨鹏 676df30c67 @
feat(web): 演出配置 Timeline 加「场景底图」选择器(venue 特写)

- 后端 /api/sceneshots:列 SceneShots 全部俯视底图(venue 特写) name->{url,bounds}
- timeline.js:底图优先级 ir.stage.backdrop(venue) > 点位集默认 shot;
  顶栏加底图下拉 renderMapInfo + applyBackdrop(换底+改投影范围+重画+回调)
- app.js:拉 /api/sceneshots;performSelect 传入;saveBackdrop 写 ir.stage.backdrop 并 PUT
- venue 特写与点位集同 map-local → 换底图后锚点自动落对位(无头实拍擂台验证)
- ir.stage.backdrop 是编辑器元数据:validate 不读、compile 不碰
@
2026-06-15 12:01:14 +08:00

456 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

// 主控:鉴权 / 事件列表 / 加载保存 / 校验 / 状态 / 导入导出 / 试走。
(function () {
const App = {
dict: { conditions: {}, grants: {} },
pointsets: {}, // name -> {mapId, points:[]}
sceneshots: {}, // name -> {url,bounds,w,h}(演出配置「场景底图」可选项)
events: [],
current: null, // 当前 group
ir: null, // 工作副本
status: null,
selectedNode: null,
dirty: false,
mode: "review", // review=海选审核 / perform=演出配置
sceneCurrent: null, // 海选审核第一列选中的场景null=全部 / ""=未分类 / 具体场景名
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();
try { App.sceneshots = await (await api("/api/sceneshots")).json(); } catch (_) { App.sceneshots = {}; }
} 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() { renderSceneList(); renderEventList(); }
// 第一列:按 e.scene 聚合成场景,加「全部」「未分类」两个特殊项。
function renderSceneList() {
const host = $("scene-list"); host.innerHTML = "";
const counts = new Map(); let unclassified = 0;
App.events.forEach(e => {
const sc = e.scene || "";
if (!sc) { unclassified++; return; }
counts.set(sc, (counts.get(sc) || 0) + 1);
});
const mk = (key, label, cnt) => {
const d = document.createElement("div");
d.className = "scene-item" + (App.sceneCurrent === key ? " sel" : "");
d.innerHTML = '<span class="snm">' + esc(label) + '</span><span class="scnt">' + cnt + '</span>';
d.onclick = () => { App.sceneCurrent = key; renderList(); };
host.appendChild(d);
};
mk(null, "全部", App.events.length);
[...counts.keys()].sort().forEach(k => mk(k, k, counts.get(k)));
if (unclassified) mk("", "未分类", unclassified);
}
// 第二列:当前场景下、且匹配搜索的事件。
function renderEventList() {
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 => App.sceneCurrent === null || (e.scene || "") === App.sceneCurrent)
.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 = '<span class="badge ' + b[0] + '">' + b[1] + '</span>'
+ '<div class="t">' + esc(e.title || e.group) + '</div>'
+ '<div class="g">' + esc(e.group) + ' · ' + esc(e.updated_by || "") + ' ' + esc((e.updated_at || "").slice(5, 16)) + '</div>';
d.onclick = () => selectEvent(e.group);
host.appendChild(d);
});
if (!host.children.length) host.innerHTML = '<div class="empty" style="padding:14px">该场景下无事件</div>';
}
$("filter-status").onchange = loadList;
$("search").oninput = renderEventList;
// ---------- 选中事件 ----------
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),
editScene: id => { App.selectedNode = id; SceneEdit.open(id, App.ir, ctx(), App.pointsets, App.dict); },
});
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 = '<div class="v-ok">✓ 校验通过,无错误无警告</div>';
errs.forEach(e => h += '<div class="v-err">✗ ' + esc(e) + '</div>');
warns.forEach(w => h += '<div class="v-warn">⚠ ' + esc(w) + '</div>');
$("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 = '<div class="empty" style="padding:14px">还没有已确认的事件。去「海选审核」确认事件后再来配置演出。</div>'; return; }
list.forEach(e => {
const d = document.createElement("div");
d.className = "ev" + (e.group === performCurrent ? " sel" : "");
d.innerHTML = '<div class="t">' + esc(e.title || e.group) + '</div><div class="g">' + esc(e.group) + ' · ' + esc(e.updated_by || "") + '</div>';
d.onclick = () => performSelect(e.group);
host.appendChild(d);
});
}
let performIr = null; // 演出配置当前事件 IR底图选择写这里并持久化
async function performSelect(group) {
let d;
try { d = await (await api("/api/events/" + encodeURIComponent(group))).json(); } catch (e) { return; }
performCurrent = group;
performIr = d.ir;
performLoadList();
Timeline.show($("perform-main"), d.ir, App.dict, App.pointsets, {
sceneshots: App.sceneshots,
onPickBackdrop: name => saveBackdrop(group, name),
});
}
// 演出配置选底图 → 写 ir.stage.backdrop编辑器元数据validate/compile 都不读)并 PUT 持久化。
async function saveBackdrop(group, name) {
if (!performIr || group !== performCurrent) return;
performIr.stage = performIr.stage || {};
if (name) performIr.stage.backdrop = name; else delete performIr.stage.backdrop;
try {
await api("/api/events/" + encodeURIComponent(group), { method: "PUT", body: JSON.stringify({ ir: performIr, by: App.by }) });
toast(name ? ("底图已设为 " + name) : "已恢复默认底图");
} catch (e) { toast("底图保存失败"); }
}
// ---------- 导入 ----------
let importFiles = []; // 当前已选文件
function renderImportFiles() {
const host = $("import-files"); host.innerHTML = "";
importFiles.forEach((f, i) => {
const d = document.createElement("div");
d.className = "fileitem";
d.innerHTML = '<span class="fn">' + esc(f.name) + '</span><span class="fsz">' + (f.size > 1024 ? (f.size / 1024).toFixed(1) + " KB" : f.size + " B") + '</span><button class="rm" title="移除">✕</button>';
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
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),
onEditScene: id => { App.selectedNode = id; GraphUI.select(id); SceneEdit.open(id, App.ir, ctx(), App.pointsets, App.dict); },
});
(async function () {
try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); }
catch (e) { showLogin(); }
})();
})();