initial: novel-app snapshot

This commit is contained in:
omo
2026-08-17 17:11:30 +08:00
commit 1ae06209ac
34 changed files with 2821 additions and 0 deletions

View File

@@ -0,0 +1,230 @@
/* chapter write page: 大纲编辑 + 上下文面板 + SSE 流式生成 + 暂停/继续/重写. */
import { escapeHtml, fetchJson, postJson, showError } from "../utils.js";
import { createSSEStream } from "../components/sse-client.js";
const NOVEL_ID = window.NOVEL_ID;
const CHAPTER_ID = window.CHAPTER_ID;
const alertsEl = document.getElementById("cwAlerts");
const outlineEl = document.getElementById("cwOutline");
const outputEl = document.getElementById("cwOutput");
const wordCountEl = document.getElementById("cwWordCount");
const ctxSummaryEl = document.getElementById("cwContextSummary");
const btn = {
generate: document.getElementById("cwGenerate"),
pause: document.getElementById("cwPause"),
resume: document.getElementById("cwResume"),
regen: document.getElementById("cwRegen"),
edit: document.getElementById("cwEdit"),
save: document.getElementById("cwSave"),
approve: document.getElementById("cwApprove"),
saveOutline: document.getElementById("cwSaveOutline"),
};
let stream = null;
let accumulated = ""; // 已生成文本 (暂停断点)
let editing = false;
/* ---------------- 状态管理 ---------------- */
function setStreaming(on) {
btn.generate.disabled = on;
btn.pause.disabled = !on;
btn.regen.disabled = on || !accumulated;
btn.resume.disabled = on || !accumulated;
btn.saveOutline.disabled = on;
if (on) {
btn.edit.disabled = true;
btn.save.disabled = true;
btn.approve.disabled = true;
}
}
function setIdleWithContent() {
btn.generate.disabled = false;
btn.pause.disabled = true;
btn.resume.disabled = true;
btn.regen.disabled = false;
btn.edit.disabled = false;
btn.save.disabled = false;
btn.approve.disabled = false;
}
function renderOutput() {
outputEl.innerHTML = accumulated
? escapeHtml(accumulated)
: '<span class="text-muted">点击"生成"开始写作…</span>';
outputEl.scrollTop = outputEl.scrollHeight;
wordCountEl.textContent = accumulated ? "当前字数: " + accumulated.length : "";
}
/* ---------------- 加载章节 + 上下文 ---------------- */
async function loadChapter() {
try {
const ch = await fetchJson("/api/chapters/" + CHAPTER_ID);
document.getElementById("cwTitle").textContent =
"第" + (ch.volume || 1) + "卷 第" + (ch.chapter_number || 1) + "章 " + (ch.title || "");
outlineEl.value = ch.outline || "";
if (ch.content) {
accumulated = ch.content;
renderOutput();
setIdleWithContent();
}
} catch (err) {
showError(alertsEl, "加载章节失败: " + err.message);
}
}
async function loadContext() {
const el = document.getElementById("cwContext");
try {
const ctx = await fetchJson("/api/chapters/" + CHAPTER_ID + "/context");
let html = "";
html += "<details open><summary class=\"fw-bold\">前文摘要 ("
+ ctx.earlier_summaries.length + ")</summary>";
html += ctx.earlier_summaries.length
? ctx.earlier_summaries.map((s) =>
"<p class=\"small mb-1\">第" + s.volume + "卷 第" + s.chapter_number + "章 "
+ escapeHtml(s.title || "") + ": " + escapeHtml(s.summary) + "</p>").join("")
: '<p class="small text-muted">无</p>';
html += "</details>";
html += "<details class=\"mt-2\"><summary class=\"fw-bold\">前章正文 ("
+ ctx.prev_chapters.length + ")</summary>";
html += ctx.prev_chapters.length
? ctx.prev_chapters.map((c) =>
"<details class=\"ms-2\"><summary class=\"small\">第" + c.volume + "卷 第"
+ c.chapter_number + "章 " + escapeHtml(c.title || "") + "</summary>"
+ "<pre class=\"small\" style=\"white-space:pre-wrap;max-height:200px;overflow-y:auto;\">"
+ escapeHtml(c.content) + "</pre></details>").join("")
: '<p class="small text-muted">无 (这是第一章)</p>';
html += "</details>";
html += "<details class=\"mt-2\"><summary class=\"fw-bold\">相关角色 ("
+ ctx.characters.length + ")</summary>";
html += ctx.characters.length
? ctx.characters.map((c) =>
"<p class=\"small mb-1\"><strong>" + escapeHtml(c.name) + "</strong> ("
+ escapeHtml(c.role || "") + "): "
+ escapeHtml((c.description || "").slice(0, 200)) + "</p>").join("")
: '<p class="small text-muted">无</p>';
html += "</details>";
html += "<details class=\"mt-2\"><summary class=\"fw-bold\">未回收伏笔 ("
+ ctx.open_foreshadowing.length + ")</summary>";
html += ctx.open_foreshadowing.length
? ctx.open_foreshadowing.map((f) =>
"<p class=\"small mb-1\">" + escapeHtml(f.description) + "</p>").join("")
: '<p class="small text-muted">无</p>';
html += "</details>";
html += '<p class="small text-muted mt-2">上下文总大小: ' + ctx.total_chars + " 字符</p>";
el.innerHTML = html;
} catch (err) {
el.innerHTML = '<div class="alert alert-danger small">' + escapeHtml(err.message) + "</div>";
}
}
/* ---------------- 生成控制 ---------------- */
function startStream(prefix) {
if (stream) stream.close();
setStreaming(true);
ctxSummaryEl.innerHTML = "";
const params = new URLSearchParams({ csrf_token: document.querySelector('meta[name="csrf-token"]').content });
if (prefix) params.append("prefix", prefix);
stream = createSSEStream("/api/chapters/" + CHAPTER_ID + "/generate?" + params.toString(), {
onContext: (summary) => {
ctxSummaryEl.innerHTML = '<div class="alert alert-info small py-1">'
+ escapeHtml(summary) + "</div>";
},
onToken: (t) => {
accumulated += t;
renderOutput();
},
onDone: (content) => {
accumulated = content;
renderOutput();
stream = null;
setIdleWithContent();
},
onError: (msg) => {
showError(alertsEl, "生成失败: " + msg);
stream = null;
setStreaming(false);
btn.resume.disabled = !accumulated;
},
});
}
btn.generate.addEventListener("click", () => {
accumulated = "";
renderOutput();
startStream(null);
});
btn.pause.addEventListener("click", () => {
if (stream) { stream.close(); stream = null; }
setStreaming(false);
btn.resume.disabled = !accumulated;
btn.regen.disabled = !accumulated;
});
btn.resume.addEventListener("click", () => startStream(accumulated));
btn.regen.addEventListener("click", () => {
if (stream) { stream.close(); stream = null; }
accumulated = "";
renderOutput();
startStream(null);
});
btn.edit.addEventListener("click", () => {
editing = !editing;
outputEl.contentEditable = editing ? "true" : "false";
btn.edit.textContent = editing ? "完成编辑" : "编辑";
if (editing) outputEl.focus();
else accumulated = outputEl.innerText;
});
btn.save.addEventListener("click", async () => {
if (editing) accumulated = outputEl.innerText;
try {
await postJson("/api/chapters/" + CHAPTER_ID, { content: accumulated });
renderOutput();
ctxSummaryEl.innerHTML = '<div class="alert alert-success small py-1">已保存</div>';
} catch (err) {
showError(alertsEl, "保存失败: " + err.message);
}
});
btn.approve.addEventListener("click", async () => {
if (editing) accumulated = outputEl.innerText;
try {
await postJson("/api/chapters/" + CHAPTER_ID, { content: accumulated, status: "done" });
ctxSummaryEl.innerHTML = '<div class="alert alert-success small py-1">本章已通过 (status=done)</div>';
} catch (err) {
showError(alertsEl, "操作失败: " + err.message);
}
});
btn.saveOutline.addEventListener("click", async () => {
try {
await postJson("/api/chapters/" + CHAPTER_ID, { outline: outlineEl.value });
ctxSummaryEl.innerHTML = '<div class="alert alert-success small py-1">大纲已保存</div>';
loadContext();
} catch (err) {
showError(alertsEl, "保存大纲失败: " + err.message);
}
});
/* ---------------- init ---------------- */
loadChapter();
loadContext();

View File

@@ -0,0 +1,250 @@
/* deconstruct page: 拆书 UI - 顺序生成 5 个 section, 用户逐个确认后创建小说. */
import { escapeHtml, getCsrf, postJson, showError } from "../utils.js";
import { createSSEStream } from "../components/sse-client.js";
const SECTIONS = [
{ id: "concept", label: "主题构思" },
{ id: "world", label: "世界观" },
{ id: "characters", label: "角色" },
{ id: "outline", label: "大纲" },
{ id: "foreshadowing", label: "伏笔计划" },
];
// state: pending -> streaming -> confirm -> confirmed | skipped
const state = {};
SECTIONS.forEach((s) => { state[s.id] = { status: "pending", text: "" }; });
let activeStream = null;
let autoRunning = false;
const alertsEl = document.getElementById("dcAlerts");
const sectionsEl = document.getElementById("dcSections");
const startBtn = document.getElementById("dcStartBtn");
const createBtn = document.getElementById("dcCreateBtn");
/* ---------------- UI 构建 ---------------- */
const BADGE = {
pending: '<span class="badge text-bg-secondary">待生成</span>',
streaming: '<span class="badge text-bg-warning">生成中</span>',
confirm: '<span class="badge text-bg-info">待确认</span>',
confirmed: '<span class="badge text-bg-success">已确认</span>',
skipped: '<span class="badge text-bg-dark">已跳过</span>',
};
function buildPanels() {
sectionsEl.innerHTML = SECTIONS.map((s, i) => ''
+ '<div class="accordion-item" data-section="' + s.id + '">'
+ ' <h2 class="accordion-header">'
+ ' <button class="accordion-button' + (i === 0 ? "" : " collapsed") + '" type="button"'
+ ' data-bs-toggle="collapse" data-bs-target="#dcPanel-' + s.id + '">'
+ ' <span class="me-2">' + (i + 1) + '. ' + s.label + '</span>'
+ ' <span class="dc-badge">' + BADGE.pending + '</span>'
+ ' </button>'
+ ' </h2>'
+ ' <div id="dcPanel-' + s.id + '" class="accordion-collapse collapse' + (i === 0 ? " show" : "") + '">'
+ ' <div class="accordion-body">'
+ ' <textarea class="form-control dc-text" rows="8" placeholder="等待生成…"></textarea>'
+ ' <div class="mt-2 d-flex gap-2">'
+ ' <button type="button" class="btn btn-outline-success btn-sm dc-gen">生成</button>'
+ ' <button type="button" class="btn btn-outline-secondary btn-sm dc-skip">跳过 (用默认值)</button>'
+ ' <button type="button" class="btn btn-outline-primary btn-sm dc-confirm" disabled>确认</button>'
+ ' </div>'
+ ' </div>'
+ ' </div>'
+ '</div>').join("");
sectionsEl.querySelectorAll(".accordion-item").forEach((item) => {
const id = item.dataset.section;
item.querySelector(".dc-gen").addEventListener("click", () => streamSection(id));
item.querySelector(".dc-skip").addEventListener("click", () => skipSection(id));
item.querySelector(".dc-confirm").addEventListener("click", () => confirmSection(id));
item.querySelector(".dc-text").addEventListener("input", (e) => {
state[id].text = e.target.value;
});
});
}
function setStatus(id, status) {
state[id].status = status;
const item = sectionsEl.querySelector('[data-section="' + id + '"]');
item.querySelector(".dc-badge").innerHTML = BADGE[status];
const genBtn = item.querySelector(".dc-gen");
const confirmBtn = item.querySelector(".dc-confirm");
genBtn.disabled = status === "streaming";
genBtn.textContent = (status === "pending" || status === "streaming") ? "生成" : "重新生成";
confirmBtn.disabled = status !== "confirm";
updateCreateBtn();
}
function openPanel(id) {
const panel = document.getElementById("dcPanel-" + id);
if (panel && !panel.classList.contains("show")) {
new bootstrap.Collapse(panel, { toggle: true });
}
}
function updateCreateBtn() {
createBtn.disabled = !SECTIONS.every(
(s) => state[s.id].status === "confirmed" || state[s.id].status === "skipped"
);
}
/* ---------------- 拆书流程 ---------------- */
function inputs(section) {
const confirmed = {};
SECTIONS.forEach((s) => {
if (state[s.id].status === "confirmed" && s.id !== section) confirmed[s.id] = state[s.id].text;
});
return {
title: document.getElementById("dcTitle").value.trim(),
genre: document.getElementById("dcGenre").value,
style: document.getElementById("dcStyle").value.trim(),
target_words: document.getElementById("dcWords").value,
reference_novel: document.getElementById("dcRef").value.trim(),
section,
confirmed: JSON.stringify(confirmed),
csrf_token: getCsrf(),
};
}
function streamSection(id) {
const inp = inputs(id);
if (!inp.title) {
showError(alertsEl, "请先填写书名");
return;
}
if (activeStream) activeStream.close();
const item = sectionsEl.querySelector('[data-section="' + id + '"]');
const textarea = item.querySelector(".dc-text");
textarea.value = "";
state[id].text = "";
setStatus(id, "streaming");
openPanel(id);
const url = "/api/deconstruct/stream?" + new URLSearchParams(inp).toString();
activeStream = createSSEStream(url, {
onToken: (t) => {
textarea.value += t;
state[id].text = textarea.value;
textarea.scrollTop = textarea.scrollHeight;
},
onSectionEnd: (section, content) => {
textarea.value = content;
state[section].text = content;
setStatus(section, "confirm");
activeStream = null;
if (autoRunning) nextSection(section);
},
onError: (msg) => {
showError(alertsEl, "生成失败: " + msg);
setStatus(id, "pending");
activeStream = null;
autoRunning = false;
},
});
}
function nextSection(doneId) {
const idx = SECTIONS.findIndex((s) => s.id === doneId);
const next = SECTIONS.slice(idx + 1).find((s) => state[s.id].status === "pending");
if (next) streamSection(next.id);
else autoRunning = false;
}
function confirmSection(id) {
const item = sectionsEl.querySelector('[data-section="' + id + '"]');
state[id].text = item.querySelector(".dc-text").value;
setStatus(id, "confirmed");
}
function skipSection(id) {
if (activeStream) { activeStream.close(); activeStream = null; }
if (!state[id].text) state[id].text = "(用户跳过, 由 AI 在写作时自由发挥)";
setStatus(id, "skipped");
}
/* ---------------- 解析 + 创建小说 ---------------- */
function parseCharacters(text) {
const out = [];
text.split(/【角色】/).slice(1).forEach((chunk) => {
const lines = chunk.trim().split("\n").filter((l) => l.trim());
if (!lines.length) return;
const first = lines[0].trim();
const name = first.split(/[\s,:/]/)[0].trim() || "未命名";
out.push({ name, role: "角色", description: chunk.trim() });
});
return out;
}
function parseOutline(text) {
const chapters = [];
let volume = 1;
let seq = 0;
text.split("\n").forEach((line) => {
const t = line.trim();
if (!t) return;
const vm = t.match(/第\s*(\d+)\s*卷/);
if (vm) { volume = parseInt(vm[1], 10); return; }
const cm = t.match(/第\s*(\d+)\s*章[\s::]*(.*)/);
if (cm) {
chapters.push({
volume, chapter_number: parseInt(cm[1], 10),
title: (cm[2] || "").split(/[—\-]/)[0].trim(), outline: t,
});
seq = cm[1] ? parseInt(cm[1], 10) : seq;
return;
}
seq += 1;
chapters.push({ volume: 1, chapter_number: seq, title: "", outline: t });
});
return chapters;
}
function parseForeshadowing(text) {
return text.split(/【伏笔】/).slice(1).map((chunk) => ({
description: chunk.trim(),
})).filter((f) => f.description);
}
async function createNovel() {
const inp = inputs("concept");
const characters = parseCharacters(state.characters.text);
const outlineChapters = parseOutline(state.outline.text);
const foreshadowing = parseForeshadowing(state.foreshadowing.text);
if (state.characters.status === "confirmed" && !characters.length) {
alert("角色文本未能解析出【角色】条目, 将只创建小说本体。");
}
createBtn.disabled = true;
try {
const resp = await postJson("/api/novels", {
title: inp.title,
genre: inp.genre,
style: inp.style,
target_words: parseInt(inp.target_words, 10),
outline_chapters: outlineChapters,
characters,
foreshadowing,
});
window.location.href = "/novel/" + resp.id;
} catch (err) {
showError(alertsEl, "创建失败: " + err.message);
createBtn.disabled = false;
}
}
/* ---------------- 入口 ---------------- */
buildPanels();
startBtn.addEventListener("click", () => {
autoRunning = true;
const first = SECTIONS.find((s) => state[s.id].status === "pending") || SECTIONS[0];
streamSection(first.id);
});
createBtn.addEventListener("click", createNovel);

44
static/js/pages/index.js Normal file
View File

@@ -0,0 +1,44 @@
/* index page: 小说列表 dashboard. */
import { escapeHtml, fetchJson } from "../utils.js";
import { statusBadge } from "../components/status-badge.js";
function renderCard(n) {
const target = n.target_words || 0;
const current = n.current_words || 0;
const pct = target > 0 ? Math.min(100, Math.round((current / target) * 100)) : 0;
return ''
+ '<div class="col-md-4">'
+ ' <div class="card h-100">'
+ ' <div class="card-body">'
+ ' <h5 class="card-title">' + escapeHtml(n.title) + '</h5>'
+ ' <p class="card-text mb-2">'
+ ' <span class="badge text-bg-primary">' + escapeHtml(n.genre || "未分类") + '</span> '
+ statusBadge(n.status)
+ ' </p>'
+ ' <p class="card-text text-muted small">' + escapeHtml(n.style || "") + '</p>'
+ ' <div class="progress mb-2" role="progressbar" aria-label="写作进度">'
+ ' <div class="progress-bar" style="width:' + pct + '%">' + pct + '%</div>'
+ ' </div>'
+ ' <p class="card-text small text-muted">' + current + ' / ' + target + ' 字</p>'
+ ' <a class="btn btn-outline-primary btn-sm" href="/novel/' + n.id + '">打开</a>'
+ ' </div>'
+ ' </div>'
+ '</div>';
}
async function loadNovels() {
const container = document.getElementById("novelCards");
try {
const novels = await fetchJson("/api/novels");
if (!novels.length) {
container.innerHTML = '<div class="text-muted">还没有小说, 点击右上角"拆书创建新小说"开始。</div>';
return;
}
container.innerHTML = novels.map(renderCard).join("");
} catch (err) {
container.innerHTML = '<div class="alert alert-danger">加载失败: ' + escapeHtml(err.message) + "</div>";
}
}
loadNovels();

View File

@@ -0,0 +1,180 @@
/* novel detail page: 章节 / 角色 / 伏笔 三 tab 管理. */
import { escapeHtml, fetchJson, postJson, showError } from "../utils.js";
import { statusBadge } from "../components/status-badge.js";
const NOVEL_ID = window.NOVEL_ID;
const alertsEl = document.getElementById("ndAlerts");
/* ---------------- header ---------------- */
async function loadHeader() {
const el = document.getElementById("ndHeader");
try {
const n = await fetchJson("/api/novels/" + NOVEL_ID);
const s = n.stats || {};
const pct = n.target_words > 0
? Math.min(100, Math.round(((n.current_words || 0) / n.target_words) * 100)) : 0;
el.innerHTML = ''
+ '<div class="card-body">'
+ ' <h2 class="card-title">' + escapeHtml(n.title) + '</h2>'
+ ' <p class="card-text">'
+ ' <span class="badge text-bg-primary">' + escapeHtml(n.genre || "未分类") + '</span> '
+ statusBadge(n.status)
+ ' <span class="text-muted ms-2">' + escapeHtml(n.style || "") + '</span>'
+ ' </p>'
+ ' <div class="progress mb-2"><div class="progress-bar" style="width:' + pct + '%">' + pct + '%</div></div>'
+ ' <p class="card-text small text-muted">'
+ ' 章节 ' + (s.chapter_count || 0) + ' (完成 ' + (s.done_chapters || 0) + ') · '
+ ' 总字数 ' + (s.total_words || 0) + ' · '
+ ' 角色 ' + (s.character_count || 0) + ' · '
+ ' 未回收伏笔 ' + (s.open_foreshadowing || 0)
+ ' </p>'
+ '</div>';
} catch (err) {
el.innerHTML = '<div class="card-body"><div class="alert alert-danger mb-0">'
+ escapeHtml(err.message) + "</div></div>";
}
}
/* ---------------- chapters ---------------- */
async function loadChapters() {
const el = document.getElementById("ndChapterList");
try {
const chapters = await fetchJson("/api/novels/" + NOVEL_ID + "/chapters");
if (!chapters.length) {
el.innerHTML = '<span class="text-muted">还没有章节, 用上方表单添加。</span>';
return;
}
const byVolume = {};
chapters.forEach((c) => {
const v = c.volume || 1;
(byVolume[v] = byVolume[v] || []).push(c);
});
el.innerHTML = Object.keys(byVolume).sort((a, b) => a - b).map((v) => {
const rows = byVolume[v].map((c) => ''
+ '<tr>'
+ '<td>第' + c.chapter_number + '章</td>'
+ '<td>' + escapeHtml(c.title || "") + '</td>'
+ '<td>' + statusBadge(c.status) + '</td>'
+ '<td>' + (c.word_count || 0) + '</td>'
+ '<td><a class="btn btn-outline-primary btn-sm" href="/novel/' + NOVEL_ID
+ '/chapter/' + c.id + '">' + (c.content ? "续写/查看" : "写") + "</a></td>"
+ "</tr>").join("");
return ''
+ '<h5 class="mt-3">第' + v + '卷</h5>'
+ '<table class="table table-sm table-hover">'
+ '<thead><tr><th>章节</th><th>标题</th><th>状态</th><th>字数</th><th></th></tr></thead>'
+ "<tbody>" + rows + "</tbody></table>";
}).join("");
} catch (err) {
el.innerHTML = '<div class="alert alert-danger">' + escapeHtml(err.message) + "</div>";
}
}
document.getElementById("ndAddChapterForm").addEventListener("submit", async (e) => {
e.preventDefault();
try {
await postJson("/api/novels/" + NOVEL_ID + "/chapters", {
volume: parseInt(document.getElementById("ndChVolume").value, 10),
chapter_number: parseInt(document.getElementById("ndChNumber").value, 10),
title: document.getElementById("ndChTitle").value.trim(),
outline: document.getElementById("ndChOutline").value.trim(),
});
document.getElementById("ndChTitle").value = "";
document.getElementById("ndChOutline").value = "";
loadChapters();
} catch (err) {
showError(alertsEl, "添加章节失败: " + err.message);
}
});
/* ---------------- characters ---------------- */
async function loadCharacters() {
const el = document.getElementById("ndCharacterList");
try {
const chars = await fetchJson("/api/novels/" + NOVEL_ID + "/characters");
if (!chars.length) {
el.innerHTML = '<span class="text-muted">还没有角色。</span>';
return;
}
el.innerHTML = '<table class="table table-sm"><thead><tr>'
+ "<th>名字</th><th>定位</th><th>状态</th><th>描述</th><th></th>"
+ "</tr></thead><tbody>"
+ chars.map((c) => ''
+ "<tr><td>" + escapeHtml(c.name) + "</td>"
+ "<td>" + escapeHtml(c.role || "") + "</td>"
+ "<td>" + statusBadge(c.status) + "</td>"
+ '<td class="small">' + escapeHtml((c.description || "").slice(0, 120)) + "</td>"
+ '<td><button type="button" class="btn btn-outline-secondary btn-sm nd-edit-char" data-id="'
+ c.id + '" data-name="' + escapeHtml(c.name) + '">编辑</button></td></tr>'
).join("") + "</tbody></table>";
el.querySelectorAll(".nd-edit-char").forEach((btn) => {
btn.addEventListener("click", async () => {
const desc = prompt("编辑角色描述 (" + btn.dataset.name + "):");
if (desc === null) return;
try {
await postJson("/api/characters/" + btn.dataset.id, { description: desc });
loadCharacters();
} catch (err) {
showError(alertsEl, "编辑失败: " + err.message);
}
});
});
} catch (err) {
el.innerHTML = '<div class="alert alert-danger">' + escapeHtml(err.message) + "</div>";
}
}
/* ---------------- foreshadowing ---------------- */
async function loadForeshadowing() {
const el = document.getElementById("ndForeshadowingList");
try {
const list = await fetchJson("/api/novels/" + NOVEL_ID + "/foreshadowing");
if (!list.length) {
el.innerHTML = '<span class="text-muted">还没有伏笔。</span>';
return;
}
el.innerHTML = '<table class="table table-sm"><thead><tr>'
+ "<th>描述</th><th>埋设</th><th>回收</th><th>状态</th><th></th>"
+ "</tr></thead><tbody>"
+ list.map((f) => ''
+ '<tr><td class="small">' + escapeHtml((f.description || "").slice(0, 150)) + "</td>"
+ "<td>" + (f.planted_chapter ? "第" + f.planted_chapter + "章" : "-") + "</td>"
+ "<td>" + (f.resolved_chapter ? "第" + f.resolved_chapter + "章" : "-") + "</td>"
+ "<td>" + statusBadge(f.status) + "</td>"
+ "<td>" + (f.status !== "resolved"
? '<button type="button" class="btn btn-outline-success btn-sm nd-resolve-fs" data-id="'
+ f.id + '">标记回收</button>' : "")
+ "</td></tr>"
).join("") + "</tbody></table>";
el.querySelectorAll(".nd-resolve-fs").forEach((btn) => {
btn.addEventListener("click", async () => {
const ch = prompt("回收于第几章?");
if (ch === null) return;
try {
await postJson("/api/foreshadowing/" + btn.dataset.id, {
status: "resolved", resolved_chapter: parseInt(ch, 10) || null,
});
loadForeshadowing();
} catch (err) {
showError(alertsEl, "操作失败: " + err.message);
}
});
});
} catch (err) {
el.innerHTML = '<div class="alert alert-danger">' + escapeHtml(err.message) + "</div>";
}
}
/* ---------------- init ---------------- */
loadHeader();
loadChapters();
loadCharacters();
loadForeshadowing();