index.html 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>AI 小说生成器</title>
  7. <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  8. <script src="https://cdn.tailwindcss.com"></script>
  9. <style>
  10. .fade-enter-active, .fade-leave-active {
  11. transition: opacity 0.3s ease;
  12. }
  13. .fade-enter-from, .fade-leave-to {
  14. opacity: 0;
  15. }
  16. </style>
  17. </head>
  18. <body class="bg-gray-50 text-gray-800 font-sans min-h-screen">
  19. <div id="app" class="max-w-7xl mx-auto p-6">
  20. <header class="mb-8 text-center">
  21. <h1 class="text-4xl font-extrabold text-blue-600 tracking-tight">AI 小说创作助手</h1>
  22. <p class="text-gray-500 mt-2">释放你的创意,让 AI 帮你构建世界</p>
  23. </header>
  24. <!-- 顶部:项目配置区域 (隐式包含 Novel ID) -->
  25. <div class="bg-white rounded-xl shadow-lg p-6 mb-8 border border-gray-100">
  26. <div class="flex flex-col md:flex-row gap-4 items-end">
  27. <div class="flex-grow w-full">
  28. <label class="block text-sm font-bold text-gray-700 mb-1">小说标题</label>
  29. <input v-model="project.title" type="text" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition shadow-sm" placeholder="请输入你的小说名称">
  30. </div>
  31. <div class="w-full md:w-auto">
  32. <!-- 只有当标题输入后,才真正去加载或初始化项目 -->
  33. <button @click="initOrLoadProject" :disabled="!project.title || loading" class="w-full md:w-auto px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition disabled:opacity-50 disabled:cursor-not-allowed shadow-md">
  34. {{ projectLoaded ? '刷新项目' : '开始创作' }}
  35. </button>
  36. </div>
  37. </div>
  38. <div v-if="project.novel_id" class="mt-2 text-xs text-gray-400">
  39. 项目 ID: {{ project.novel_id }}
  40. </div>
  41. </div>
  42. <div v-if="projectLoaded" class="space-y-8">
  43. <!-- 第一部分:大纲生成 -->
  44. <section class="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
  45. <div class="bg-gradient-to-r from-blue-50 to-indigo-50 px-6 py-4 border-b border-gray-100">
  46. <h2 class="text-xl font-bold text-gray-800 flex items-center">
  47. <span class="bg-blue-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2">1</span>
  48. 大纲生成与管理
  49. </h2>
  50. </div>
  51. <div class="flex flex-col md:flex-row">
  52. <!-- 左侧 1/3:输入与配置 -->
  53. <div class="w-full md:w-1/3 p-6 border-b md:border-b-0 md:border-r border-gray-100 bg-gray-50/50">
  54. <div class="space-y-4">
  55. <div>
  56. <label class="block text-sm font-semibold text-gray-700 mb-1">核心思路 / 故事梗概</label>
  57. <textarea v-model="outlineInput.user_input" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 shadow-sm h-32" placeholder="例如:一个关于程序员穿越到修仙世界修复天道Bug的故事..."></textarea>
  58. </div>
  59. <div>
  60. <label class="block text-sm font-semibold text-gray-700 mb-1">预计字数</label>
  61. <input v-model.number="outlineInput.target_length" type="number" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 shadow-sm" step="1000">
  62. </div>
  63. <!-- 频段选择 -->
  64. <div>
  65. <label class="block text-sm font-semibold text-gray-700 mb-2">小说频段</label>
  66. <div class="flex gap-4">
  67. <label class="flex items-center cursor-pointer">
  68. <input type="radio" v-model="outlineInput.channel" value="男频" class="form-radio text-blue-600 h-4 w-4">
  69. <span class="ml-2 text-gray-700">男频</span>
  70. </label>
  71. <label class="flex items-center cursor-pointer">
  72. <input type="radio" v-model="outlineInput.channel" value="女频" class="form-radio text-pink-600 h-4 w-4">
  73. <span class="ml-2 text-gray-700">女频</span>
  74. </label>
  75. </div>
  76. </div>
  77. <!-- 风格选择 -->
  78. <div>
  79. <label class="block text-sm font-semibold text-gray-700 mb-2">作品风格</label>
  80. <select v-model="outlineInput.style" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white shadow-sm">
  81. <option value="" disabled>请选择风格</option>
  82. <option v-for="style in currentStyles" :key="style" :value="style">{{ style }}</option>
  83. </select>
  84. </div>
  85. <button @click="generateOutline" :disabled="loading" class="w-full py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-bold rounded-lg hover:from-blue-700 hover:to-indigo-700 transition shadow-md flex items-center justify-center">
  86. <span v-if="loading && currentAction === 'outline'" class="animate-spin mr-2">⟳</span>
  87. {{ outline.id ? '重新生成大纲' : '生成大纲' }}
  88. </button>
  89. </div>
  90. </div>
  91. <!-- 右侧 2/3:显示与操作 -->
  92. <div class="w-full md:w-2/3 p-6 flex flex-col">
  93. <div class="flex-grow mb-4">
  94. <label class="block text-sm font-semibold text-gray-700 mb-2">
  95. 大纲内容
  96. <span v-if="!outline.id" class="text-gray-400 font-normal ml-2">(尚未生成)</span>
  97. <span v-else class="text-green-600 font-normal ml-2 text-xs">已保存 (ID: {{outline.id}})</span>
  98. </label>
  99. <textarea v-model="outline.content" class="w-full h-[500px] p-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm leading-relaxed bg-white shadow-inner resize-none" placeholder="大纲将显示在这里..."></textarea>
  100. </div>
  101. <div v-if="outline.id" class="flex justify-end gap-3 pt-2 border-t border-gray-100">
  102. <button @click="updateOutline" :disabled="loading" class="px-5 py-2 bg-yellow-500 text-white font-semibold rounded-lg hover:bg-yellow-600 transition shadow flex items-center">
  103. <span v-if="loading && currentAction === 'update_outline'" class="animate-spin mr-2">⟳</span>
  104. 保存修改
  105. </button>
  106. <button @click="deleteOutline" :disabled="loading" class="px-5 py-2 bg-red-500 text-white font-semibold rounded-lg hover:bg-red-600 transition shadow flex items-center">
  107. <span v-if="loading && currentAction === 'delete_outline'" class="animate-spin mr-2">⟳</span>
  108. 删除大纲
  109. </button>
  110. </div>
  111. </div>
  112. </div>
  113. </section>
  114. <!-- 第二部分:章节生成 -->
  115. <section class="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
  116. <div class="bg-gradient-to-r from-green-50 to-teal-50 px-6 py-4 border-b border-gray-100">
  117. <h2 class="text-xl font-bold text-gray-800 flex items-center">
  118. <span class="bg-green-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2">2</span>
  119. 章节生成
  120. </h2>
  121. </div>
  122. <div class="p-6">
  123. <!-- 上方:输入配置 -->
  124. <div class="grid grid-cols-1 md:grid-cols-12 gap-6 mb-6">
  125. <div class="md:col-span-8">
  126. <label class="block text-sm font-semibold text-gray-700 mb-1">本章思路 / 剧情走向 (可选)</label>
  127. <textarea v-model="chapterInput.user_input" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 shadow-sm h-24" placeholder="留空则 AI 将根据大纲和前文自动续写..."></textarea>
  128. </div>
  129. <div class="md:col-span-4 space-y-4">
  130. <div>
  131. <label class="block text-sm font-semibold text-gray-700 mb-1">生成数量</label>
  132. <div class="flex items-center">
  133. <input v-model.number="chapterInput.num_chapters" type="range" min="1" max="5" class="w-full mr-3 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
  134. <span class="text-lg font-bold text-green-600 w-8">{{ chapterInput.num_chapters }}章</span>
  135. </div>
  136. </div>
  137. <div>
  138. <label class="block text-sm font-semibold text-gray-700 mb-1">单章字数</label>
  139. <input v-model.number="chapterInput.chapter_length" type="number" step="100" class="w-full p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 shadow-sm">
  140. </div>
  141. <button @click="generateChapters" :disabled="loading || !outline.id" class="w-full py-3 bg-gradient-to-r from-green-600 to-teal-600 text-white font-bold rounded-lg hover:from-green-700 hover:to-teal-700 transition shadow-md flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed">
  142. <span v-if="loading && currentAction === 'chapter'" class="animate-spin mr-2">⟳</span>
  143. {{ !outline.id ? '请先生成大纲' : '开始生成章节' }}
  144. </button>
  145. </div>
  146. </div>
  147. <!-- 下方:最新生成预览 (如果有) -->
  148. <div v-if="lastGeneratedChapter" class="mt-6 border-t border-gray-100 pt-6">
  149. <div class="bg-green-50 border border-green-100 rounded-lg p-4">
  150. <h3 class="text-md font-bold text-green-800 mb-2">最新生成预览: {{ lastGeneratedChapter.title }}</h3>
  151. <div class="text-sm text-gray-600 line-clamp-6 whitespace-pre-wrap">{{ lastGeneratedChapter.content }}</div>
  152. <div class="mt-2 text-right">
  153. <button @click="scrollToChapter(lastGeneratedChapter.id)" class="text-green-600 text-sm font-medium hover:underline">去列表中查看完整内容 &rarr;</button>
  154. </div>
  155. </div>
  156. </div>
  157. </div>
  158. </section>
  159. <!-- 第三部分:章节列表 -->
  160. <section class="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden pb-12">
  161. <div class="bg-gradient-to-r from-purple-50 to-pink-50 px-6 py-4 border-b border-gray-100 flex justify-between items-center">
  162. <h2 class="text-xl font-bold text-gray-800 flex items-center">
  163. <span class="bg-purple-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2">3</span>
  164. 章节列表
  165. </h2>
  166. <span class="text-sm text-gray-500">共 {{ chapters.length }} 章</span>
  167. </div>
  168. <div v-if="chapters.length === 0" class="p-12 text-center text-gray-400">
  169. <p>暂无章节,快去生成第一章吧!</p>
  170. </div>
  171. <div class="divide-y divide-gray-100">
  172. <div v-for="(chapter, index) in reversedChapters" :key="chapter.id" :id="'chapter-' + chapter.id" class="transition hover:bg-gray-50">
  173. <!-- 章节标题行 (可点击折叠) -->
  174. <div @click="toggleChapter(chapter.id)" class="p-4 cursor-pointer flex justify-between items-center group">
  175. <div class="flex items-center gap-3">
  176. <span class="w-8 h-8 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center font-bold text-sm">{{ chapters.length - index }}</span>
  177. <h3 class="font-bold text-gray-800 group-hover:text-purple-600 transition">{{ chapter.title || '未命名章节' }}</h3>
  178. <span class="text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded">{{ chapter.summary ? (chapter.summary.substring(0, 20) + (chapter.summary.length > 20 ? '...' : '')) : '无摘要' }}</span>
  179. </div>
  180. <div class="flex items-center text-gray-400">
  181. <span class="mr-2 text-xs" v-if="chapter.loadingContent">加载中...</span>
  182. <svg class="w-5 h-5 transition-transform duration-300" :class="{'rotate-180': activeChapterId === chapter.id}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
  183. </div>
  184. </div>
  185. <!-- 章节内容区域 -->
  186. <transition name="fade">
  187. <div v-if="activeChapterId === chapter.id" class="bg-gray-50/50 border-t border-gray-100 p-6">
  188. <div v-if="chapter.loadingContent" class="flex justify-center py-8">
  189. <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
  190. </div>
  191. <div v-else>
  192. <div class="mb-4">
  193. <label class="block text-xs font-bold text-gray-500 uppercase tracking-wide mb-1">章节标题</label>
  194. <input v-model="chapter.title" class="w-full p-2 border border-gray-300 rounded focus:ring-1 focus:ring-purple-500 bg-white">
  195. </div>
  196. <div class="mb-4">
  197. <label class="block text-xs font-bold text-gray-500 uppercase tracking-wide mb-1">正文内容</label>
  198. <textarea v-model="chapter.content" class="w-full h-96 p-4 border border-gray-300 rounded focus:ring-1 focus:ring-purple-500 font-mono text-sm leading-relaxed bg-white shadow-inner resize-y"></textarea>
  199. </div>
  200. <div class="flex justify-end gap-3">
  201. <button @click.stop="updateChapter(chapter)" :disabled="loading" class="px-4 py-2 bg-yellow-500 text-white text-sm font-semibold rounded hover:bg-yellow-600 transition shadow flex items-center">
  202. <span v-if="loading && currentAction === 'update_chapter_' + chapter.id" class="animate-spin mr-2 h-3 w-3 border-b-2 border-white rounded-full"></span>
  203. 保存修改
  204. </button>
  205. <button @click.stop="deleteChapter(chapter)" :disabled="loading" class="px-4 py-2 bg-red-500 text-white text-sm font-semibold rounded hover:bg-red-600 transition shadow flex items-center">
  206. <span v-if="loading && currentAction === 'delete_chapter_' + chapter.id" class="animate-spin mr-2 h-3 w-3 border-b-2 border-white rounded-full"></span>
  207. 删除本章
  208. </button>
  209. </div>
  210. </div>
  211. </div>
  212. </transition>
  213. </div>
  214. </div>
  215. </section>
  216. </div>
  217. <!-- 初始加载状态 -->
  218. <div v-else-if="loading" class="fixed inset-0 bg-white/80 flex items-center justify-center z-50">
  219. <div class="text-center">
  220. <div class="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-blue-600 mx-auto mb-4"></div>
  221. <p class="text-blue-600 font-semibold text-lg">正在连接 AI 创作引擎...</p>
  222. </div>
  223. </div>
  224. </div>
  225. <script>
  226. const { createApp, ref, reactive, computed, onMounted, nextTick } = Vue;
  227. const API_URL = 'http://localhost:8000';
  228. // 风格配置数据
  229. const STYLE_CATEGORIES = {
  230. '男频': ['玄幻', '历史', '都市', '衍生', '悬疑'],
  231. '女频': ['年代', '纯爱', '现代言情', '古代言情', '衍生', '悬疑']
  232. };
  233. createApp({
  234. setup() {
  235. // --- State ---
  236. const loading = ref(false);
  237. const currentAction = ref(null); // 用于跟踪当前正在执行的具体操作,以便显示特定按钮的 loading
  238. const projectLoaded = ref(false);
  239. const project = reactive({
  240. novel_id: '',
  241. title: ''
  242. });
  243. const outlineInput = reactive({
  244. user_input: '',
  245. target_length: 3000,
  246. channel: '男频', // 默认男频
  247. style: ''
  248. });
  249. const outline = reactive({
  250. id: null,
  251. content: ''
  252. });
  253. const chapterInput = reactive({
  254. user_input: '',
  255. num_chapters: 1,
  256. chapter_length: 3000
  257. });
  258. const chapters = ref([]); // 存储所有章节元数据
  259. const activeChapterId = ref(null);
  260. const lastGeneratedChapter = ref(null);
  261. // --- Computed ---
  262. const currentStyles = computed(() => {
  263. return STYLE_CATEGORIES[outlineInput.channel] || [];
  264. });
  265. // 倒序排列章节用于显示
  266. const reversedChapters = computed(() => {
  267. return [...chapters.value].reverse();
  268. });
  269. // --- Methods ---
  270. // 生成随机ID
  271. const generateNovelId = () => {
  272. return 'novel_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
  273. };
  274. // 初始化或加载项目
  275. const initOrLoadProject = async () => {
  276. if (!project.title) return;
  277. loading.value = true;
  278. currentAction.value = 'init';
  279. try {
  280. // 如果没有 ID,生成一个
  281. if (!project.novel_id) {
  282. project.novel_id = generateNovelId();
  283. }
  284. // 尝试从后端获取项目信息(如果是已有项目)
  285. // 注意:这里我们总是先尝试获取,如果不存在后端会返回空结构
  286. const res = await fetch(`${API_URL}/projects/${project.title}/${project.novel_id}`);
  287. if (res.ok) {
  288. const data = await res.json();
  289. // 设置大纲ID
  290. outline.id = data.outline_id;
  291. if (outline.id) {
  292. await loadOutlineContent(outline.id);
  293. } else {
  294. outline.content = '';
  295. }
  296. // 设置章节列表
  297. chapters.value = data.chapters.map(c => ({
  298. ...c,
  299. content: null, // 内容懒加载
  300. loadingContent: false
  301. }));
  302. }
  303. projectLoaded.value = true;
  304. } catch (e) {
  305. alert('项目初始化失败: ' + e);
  306. console.error(e);
  307. } finally {
  308. loading.value = false;
  309. currentAction.value = null;
  310. }
  311. };
  312. const loadOutlineContent = async (noteId) => {
  313. try {
  314. const res = await fetch(`${API_URL}/outline/${project.title}/${project.novel_id}/${noteId}`);
  315. if (!res.ok) throw new Error('Failed to load outline');
  316. const data = await res.json();
  317. outline.content = data.content;
  318. } catch (e) {
  319. console.error("Error loading outline content:", e);
  320. }
  321. };
  322. const generateOutline = async () => {
  323. loading.value = true;
  324. currentAction.value = 'outline';
  325. try {
  326. // 构建 style_tags
  327. const styleTags = {
  328. 'channel': outlineInput.channel,
  329. 'style': outlineInput.style
  330. };
  331. const res = await fetch(`${API_URL}/outline/generate`, {
  332. method: 'POST',
  333. headers: {'Content-Type': 'application/json'},
  334. body: JSON.stringify({
  335. novel_id: project.novel_id,
  336. title: project.title,
  337. user_input: outlineInput.user_input,
  338. target_length: outlineInput.target_length,
  339. style_tags: styleTags
  340. })
  341. });
  342. if (!res.ok) throw new Error(await res.text());
  343. const data = await res.json();
  344. outline.id = data.note_id;
  345. outline.content = data.content;
  346. } catch (e) {
  347. alert('大纲生成失败: ' + e);
  348. } finally {
  349. loading.value = false;
  350. currentAction.value = null;
  351. }
  352. };
  353. const updateOutline = async () => {
  354. if (!outline.id) return;
  355. loading.value = true;
  356. currentAction.value = 'update_outline';
  357. try {
  358. const res = await fetch(`${API_URL}/outline/update`, {
  359. method: 'PUT',
  360. headers: {'Content-Type': 'application/json'},
  361. body: JSON.stringify({
  362. novel_id: project.novel_id,
  363. title: project.title,
  364. note_id: outline.id,
  365. content: outline.content
  366. })
  367. });
  368. if (!res.ok) throw new Error(await res.text());
  369. alert('大纲保存成功!');
  370. } catch (e) {
  371. alert('保存失败: ' + e);
  372. } finally {
  373. loading.value = false;
  374. currentAction.value = null;
  375. }
  376. };
  377. const deleteOutline = async () => {
  378. if(!confirm('确定要删除当前大纲吗?此操作不可恢复。')) return;
  379. loading.value = true;
  380. currentAction.value = 'delete_outline';
  381. try {
  382. const params = new URLSearchParams({
  383. novel_id: project.novel_id,
  384. title: project.title,
  385. note_id: outline.id
  386. });
  387. const res = await fetch(`${API_URL}/outline/delete?${params.toString()}`, {
  388. method: 'DELETE'
  389. });
  390. if (!res.ok) throw new Error(await res.text());
  391. outline.id = null;
  392. outline.content = '';
  393. alert('大纲已删除');
  394. } catch (e) {
  395. alert('删除失败: ' + e);
  396. } finally {
  397. loading.value = false;
  398. currentAction.value = null;
  399. }
  400. };
  401. const generateChapters = async () => {
  402. loading.value = true;
  403. currentAction.value = 'chapter';
  404. lastGeneratedChapter.value = null;
  405. try {
  406. const res = await fetch(`${API_URL}/chapter/generate`, {
  407. method: 'POST',
  408. headers: {'Content-Type': 'application/json'},
  409. body: JSON.stringify({
  410. novel_id: project.novel_id,
  411. title: project.title,
  412. user_input: chapterInput.user_input,
  413. num_chapters: chapterInput.num_chapters,
  414. chapter_length: chapterInput.chapter_length
  415. })
  416. });
  417. if (!res.ok) throw new Error(await res.text());
  418. const data = await res.json();
  419. // 添加新生成的章节到列表
  420. const newChapters = [];
  421. for (const c of data.generated_chapters) {
  422. // 为了能够立即预览,我们需要获取内容。
  423. // 此时我们其实只知道 ID。为了简单,我们立即去 fetch 一次内容,或者后端返回的时候能带上内容最好。
  424. // 查看后端接口,generate 只返回 id, title, summary。
  425. // 所以我们需要单独 fetch 内容用于预览。
  426. const chapterObj = {...c, content: null, loadingContent: false};
  427. chapters.value.push(chapterObj);
  428. newChapters.push(chapterObj);
  429. }
  430. // 自动加载最后一个生成的章节内容用于预览
  431. if (newChapters.length > 0) {
  432. const lastOne = newChapters[newChapters.length - 1];
  433. await loadChapterContent(lastOne);
  434. lastGeneratedChapter.value = lastOne;
  435. }
  436. // 清空输入
  437. chapterInput.user_input = '';
  438. } catch (e) {
  439. alert('章节生成失败: ' + e);
  440. } finally {
  441. loading.value = false;
  442. currentAction.value = null;
  443. }
  444. };
  445. const loadChapterContent = async (chapter) => {
  446. if (chapter.content) return;
  447. chapter.loadingContent = true;
  448. try {
  449. const res = await fetch(`${API_URL}/chapter/${project.title}/${project.novel_id}/${chapter.id}`);
  450. if (!res.ok) throw new Error('Failed to load chapter content');
  451. const data = await res.json();
  452. chapter.content = data.content;
  453. } catch (e) {
  454. console.error(e);
  455. chapter.content = "加载内容失败";
  456. } finally {
  457. chapter.loadingContent = false;
  458. }
  459. };
  460. const toggleChapter = async (id) => {
  461. if (activeChapterId.value === id) {
  462. activeChapterId.value = null;
  463. return;
  464. }
  465. activeChapterId.value = id;
  466. const chapter = chapters.value.find(c => c.id === id);
  467. if (chapter) {
  468. await loadChapterContent(chapter);
  469. }
  470. };
  471. const updateChapter = async (chapter) => {
  472. loading.value = true;
  473. currentAction.value = 'update_chapter_' + chapter.id;
  474. try {
  475. const res = await fetch(`${API_URL}/chapter/update`, {
  476. method: 'PUT',
  477. headers: {'Content-Type': 'application/json'},
  478. body: JSON.stringify({
  479. novel_id: project.novel_id,
  480. title: project.title,
  481. note_id: chapter.id,
  482. content: chapter.content,
  483. chapter_title: chapter.title // 支持修改标题
  484. })
  485. });
  486. if (!res.ok) throw new Error(await res.text());
  487. alert('章节更新成功');
  488. } catch (e) {
  489. alert('更新失败: ' + e);
  490. } finally {
  491. loading.value = false;
  492. currentAction.value = null;
  493. }
  494. };
  495. const deleteChapter = async (chapter) => {
  496. if(!confirm('确定要删除这一章吗?')) return;
  497. loading.value = true;
  498. currentAction.value = 'delete_chapter_' + chapter.id;
  499. try {
  500. const params = new URLSearchParams({
  501. novel_id: project.novel_id,
  502. title: project.title,
  503. note_id: chapter.id
  504. });
  505. const res = await fetch(`${API_URL}/chapter/delete?${params.toString()}`, {
  506. method: 'DELETE'
  507. });
  508. if (!res.ok) throw new Error(await res.text());
  509. chapters.value = chapters.value.filter(c => c.id !== chapter.id);
  510. if (activeChapterId.value === chapter.id) activeChapterId.value = null;
  511. if (lastGeneratedChapter.value && lastGeneratedChapter.value.id === chapter.id) lastGeneratedChapter.value = null;
  512. } catch (e) {
  513. alert('删除失败: ' + e);
  514. } finally {
  515. loading.value = false;
  516. currentAction.value = null;
  517. }
  518. };
  519. const scrollToChapter = (id) => {
  520. activeChapterId.value = id;
  521. // 等待 DOM 更新展开后再滚动
  522. nextTick(async () => {
  523. const el = document.getElementById('chapter-' + id);
  524. if (el) {
  525. el.scrollIntoView({ behavior: 'smooth' });
  526. // 确保内容已加载
  527. const chapter = chapters.value.find(c => c.id === id);
  528. if (chapter) await loadChapterContent(chapter);
  529. }
  530. });
  531. };
  532. return {
  533. loading, currentAction, projectLoaded,
  534. project, outlineInput, outline, chapterInput, chapters,
  535. activeChapterId, lastGeneratedChapter,
  536. STYLE_CATEGORIES, currentStyles, reversedChapters,
  537. initOrLoadProject, generateOutline, updateOutline, deleteOutline,
  538. generateChapters, toggleChapter, updateChapter, deleteChapter, scrollToChapter
  539. };
  540. }
  541. }).mount('#app');
  542. </script>
  543. </body>
  544. </html>