Files
story-edit-web/web/static/app.js

253 lines
12 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:[]}
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 = '<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">无事件,点「导入 IR」</div>';
}
$("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"].forEach(b => $(b).disabled = false);
renderAll(true);
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(); drawTree(); if (structural) { FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); } },
selectNode: id => { App.selectedNode = id; },
});
function drawTree() {
if (App.ir) renderTree(App.ir, {
selected: App.selectedNode, onSelect: selectNode,
onAddNext: addSuccessor, onDelete: deleteNode,
});
}
function selectNode(id) { App.selectedNode = id; drawTree(); 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; selectNode(r.id); FormUI.renderMeta(App.ir, ctx()); updateDirty();
}
function deleteNode(id) {
const isEnding = (App.ir.endings || []).some(e => e.id === id);
if (!confirm("删除" + (isEnding ? "结局" : "节点") + " " + id + "?指向它的跳转需手动修复(校验会提示)。")) return;
if (isEnding) App.ir.endings = (App.ir.endings || []).filter(e => e.id !== id);
else App.ir.nodes = (App.ir.nodes || []).filter(n => n.id !== id);
if (App.selectedNode === id) App.selectedNode = null;
App.dirty = true; drawTree(); FormUI.renderMeta(App.ir, ctx());
FormUI.renderNode(App.ir, App.selectedNode, ctx()); updateDirty();
}
function renderAll() { drawTree(); 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; selectNode(id); FormUI.renderMeta(App.ir, 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);
// ---------- 导入 ----------
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").classList.add("err"); $("import-result").style.color = "";
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();
$("import-result").textContent = "已导入 " + (d.saved || []).length + " 个" + ((d.errors || []).length ? "" + d.errors.join("") : "");
importFiles = []; renderImportFiles(); $("import-text").value = "";
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;"); }
window.addEventListener("resize", drawTree);
// ---------- 启动 ----------
(async function () {
try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); }
catch (e) { showLogin(); }
})();
})();