initial: novel-app snapshot
This commit is contained in:
230
static/js/pages/chapter-write.js
Normal file
230
static/js/pages/chapter-write.js
Normal 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();
|
||||
250
static/js/pages/deconstruct.js
Normal file
250
static/js/pages/deconstruct.js
Normal 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
44
static/js/pages/index.js
Normal 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();
|
||||
180
static/js/pages/novel-detail.js
Normal file
180
static/js/pages/novel-detail.js
Normal 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();
|
||||
Reference in New Issue
Block a user