181 lines
7.9 KiB
JavaScript
181 lines
7.9 KiB
JavaScript
/* 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();
|