initial: novel-app snapshot
This commit is contained in:
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