feat: per-work chapter storage, import work selector, TXT export v1.2.0

This commit is contained in:
YuanShiJiLoong committed 2026-02-25 22:33:59 +09:00
1 parent 9859743f85
commit 65ddf48f38
11 files changed
+418 -68

No files matched your search

+2
View File
@@ -124,6 +124,8 @@ export default function SettingsPanel() {
const handleSwitchWork = (workId) => {
setActiveWorkIdState(workId);
setActiveWorkId(workId);
// 同步 Zustand store,触发 page.js 重载章节
useAppStore.getState().setActiveWorkId(workId);
setSelectedNodeId(null);
};
+153 -9
View File
@@ -3,14 +3,15 @@
import { useState, useCallback } from 'react';
import { useAppStore } from '../store/useAppStore';
import { useI18n } from '../lib/useI18n';
import { createChapter, deleteChapter, updateChapter, exportToMarkdown, exportAllToMarkdown } from '../lib/storage';
import { exportProject, importProject } from '../lib/project-io';
import { WRITING_MODES } from '../lib/settings';
import { createChapter, deleteChapter, updateChapter, exportToMarkdown, exportAllToMarkdown, saveChapters } from '../lib/storage';
import { exportProject, importProject, importWork, exportWorkAsTxt } from '../lib/project-io';
import { WRITING_MODES, getAllWorks, getSettingsNodes, createWorkNode, saveSettingsNodes, setActiveWorkId as setActiveWorkIdSetting } from '../lib/settings';
export default function Sidebar() {
const {
chapters, addChapter, setChapters, updateChapter: updateChapterStore,
activeChapterId, setActiveChapterId,
activeWorkId, setActiveWorkId: setActiveWorkIdStore,
sidebarOpen, setSidebarOpen,
theme, setTheme,
writingMode,
@@ -22,6 +23,7 @@ export default function Sidebar() {
const [renameId, setRenameId] = useState(null);
const [renameTitle, setRenameTitle] = useState('');
const [contextMenu, setContextMenu] = useState(null);
const [importModal, setImportModal] = useState(null); // { chapters, totalWords, file }
const { t } = useI18n();
// 切换主题
@@ -102,14 +104,14 @@ export default function Sidebar() {
// 创建新章节 — 一键创建并进入重命名模式
const handleCreateChapter = useCallback(async () => {
const title = getNextChapterTitle();
const ch = await createChapter(title);
const ch = await createChapter(title, activeWorkId);
addChapter(ch);
setActiveChapterId(ch.id);
// 立即进入重命名模式,方便用户修改标题
setRenameId(ch.id);
setRenameTitle(title);
showToast(t('sidebar.chapterCreated').replace('{title}', title), 'success');
}, [getNextChapterTitle, showToast, addChapter, setActiveChapterId, t]);
}, [getNextChapterTitle, showToast, addChapter, setActiveChapterId, t, activeWorkId]);
// 删除章节
const handleDeleteChapter = useCallback(async (id) => {
@@ -118,24 +120,24 @@ export default function Sidebar() {
return;
}
const ch = chapters.find(c => c.id === id);
const remaining = await deleteChapter(id);
const remaining = await deleteChapter(id, activeWorkId);
setChapters(remaining);
if (activeChapterId === id) {
setActiveChapterId(remaining[0]?.id || null);
}
showToast(t('sidebar.chapterDeleted').replace('{title}', ch?.title), 'info');
setContextMenu(null);
}, [chapters, activeChapterId, showToast, setChapters, setActiveChapterId, t]);
}, [chapters, activeChapterId, showToast, setChapters, setActiveChapterId, t, activeWorkId]);
// 重命名章节
const handleRename = useCallback((id) => {
const title = renameTitle.trim();
if (!title) return;
const updated = updateChapter(id, { title });
updateChapter(id, { title }, activeWorkId);
updateChapterStore(id, { title });
setRenameId(null);
setRenameTitle('');
}, [renameTitle, updateChapterStore]);
}, [renameTitle, updateChapterStore, activeWorkId]);
// 导出
const handleExport = useCallback((type) => {
@@ -290,6 +292,12 @@ export default function Sidebar() {
<button className="btn btn-secondary btn-sm" style={{ flex: 1, justifyContent: 'center', fontSize: '11px' }} onClick={() => { document.getElementById('project-import-input')?.click(); }}>
{t('sidebar.btnLoad')}
</button>
<button className="btn btn-secondary btn-sm" style={{ flex: 1, justifyContent: 'center', fontSize: '11px' }} onClick={() => { document.getElementById('work-import-input')?.click(); }} title={t('sidebar.btnImportWorkTitle')}>
{t('sidebar.btnImportWork')}
</button>
<button className="btn btn-secondary btn-sm" style={{ flex: 1, justifyContent: 'center', fontSize: '11px' }} onClick={() => { exportWorkAsTxt(chapters); }} title={t('sidebar.btnExportTxtTitle')}>
{t('sidebar.btnExportTxt')}
</button>
<input
id="project-import-input"
type="file"
@@ -308,6 +316,32 @@ export default function Sidebar() {
e.target.value = '';
}}
/>
<input
id="work-import-input"
type="file"
accept=".txt"
style={{ display: 'none' }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const result = await importWork(file);
if (!result.success) {
const msg = result.message === 'noChapter'
? t('sidebar.importWorkNoChapter')
: t('sidebar.importWorkFailed').replace('{error}', result.message);
showToast(msg, 'error');
e.target.value = '';
return;
}
// 弹出作品选择
setImportModal({ chapters: result.chapters, totalWords: result.totalWords });
} catch (err) {
showToast(t('sidebar.importWorkFailed').replace('{error}', err.message), 'error');
}
e.target.value = '';
}}
/>
</div>
</div>
</aside>
@@ -358,6 +392,116 @@ export default function Sidebar() {
</div>
</div>
)}
{/* ===== 导入作品-选择目标作品弹窗 ===== */}
{importModal && (
<ImportWorkModal
chapters={importModal.chapters}
totalWords={importModal.totalWords}
onClose={() => setImportModal(null)}
onImport={async (targetWorkId) => {
try {
await saveChapters(importModal.chapters, targetWorkId);
// 切换到目标作品
setActiveWorkIdSetting(targetWorkId);
setActiveWorkIdStore(targetWorkId);
showToast(t('sidebar.importWorkSuccess').replace('{count}', importModal.chapters.length), 'success');
setImportModal(null);
} catch (err) {
showToast(t('sidebar.importWorkFailed').replace('{error}', err.message), 'error');
}
}}
t={t}
/>
)}
</>
);
}
/**
* 导入作品时的目标作品选择弹窗
*/
function ImportWorkModal({ chapters, totalWords, onClose, onImport, t }) {
const [works, setWorks] = useState([]);
const [newWorkName, setNewWorkName] = useState('');
const [showNewInput, setShowNewInput] = useState(false);
// 加载作品列表
useState(() => {
(async () => {
const nodes = await getSettingsNodes();
setWorks(getAllWorks(nodes));
})();
});
const handleCreateAndImport = async () => {
const name = newWorkName.trim();
if (!name) return;
const { workNode, subNodes } = createWorkNode(name);
const allNodes = await getSettingsNodes();
const updatedNodes = [...allNodes, workNode, ...subNodes];
await saveSettingsNodes(updatedNodes);
onImport(workNode.id);
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="glass-panel" onClick={e => e.stopPropagation()} style={{
padding: '24px', maxWidth: 420, width: '90%', borderRadius: 'var(--radius-lg)',
display: 'flex', flexDirection: 'column', gap: 16,
}}>
<h3 style={{ margin: 0, fontSize: 16 }}>{t('sidebar.importWorkSelectTitle')}</h3>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)' }}>
{t('sidebar.importWorkSelectDesc')
.replace('{count}', chapters.length)
.replace('{words}', totalWords.toLocaleString())}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{works.map(w => (
<button
key={w.id}
className="btn btn-secondary"
style={{ justifyContent: 'flex-start', padding: '10px 14px', fontSize: 13 }}
onClick={() => {
if (confirm(t('sidebar.importWorkReplaceConfirm').replace('{name}', w.name))) {
onImport(w.id);
}
}}
>
📕 {w.name}
</button>
))}
{showNewInput ? (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
className="modal-input"
style={{ margin: 0, flex: 1, padding: '8px 10px', fontSize: 13 }}
value={newWorkName}
onChange={e => setNewWorkName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateAndImport()}
placeholder={t('sidebar.importWorkNewPlaceholder')}
autoFocus
/>
<button className="btn btn-primary btn-sm" style={{ padding: '8px 14px', whiteSpace: 'nowrap' }} onClick={handleCreateAndImport}>
{t('common.confirm')}
</button>
</div>
) : (
<button
className="btn btn-primary"
style={{ justifyContent: 'center', padding: '10px 14px', fontSize: 13 }}
onClick={() => setShowNewInput(true)}
>
+ {t('sidebar.importWorkNewBtn')}
</button>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-ghost btn-sm" onClick={onClose}>{t('common.cancel')}</button>
</div>
</div>
</div>
);
}
+3 -3
View File
@@ -38,7 +38,7 @@ const PRIORITY = {
*/
export async function getContextItems(activeChapterId) {
const settings = getProjectSettings();
const chapters = await getChapters();
const chapters = await getChapters(getActiveWorkId());
const currentIndex = chapters.findIndex(ch => ch.id === activeChapterId);
const allNodes = await getSettingsNodes();
@@ -152,7 +152,7 @@ export async function getContextItems(activeChapterId) {
*/
export async function buildContext(activeChapterId, selectedText, selectedIds = null) {
const settings = getProjectSettings();
const chapters = await getChapters();
const chapters = await getChapters(getActiveWorkId());
const currentChapter = chapters.find(ch => ch.id === activeChapterId);
const currentIndex = chapters.findIndex(ch => ch.id === activeChapterId);
@@ -307,7 +307,7 @@ function applyTokenBudget(modules) {
*/
export async function getContextPreview(activeChapterId, selectedText) {
const settings = getProjectSettings();
const chapters = await getChapters();
const chapters = await getChapters(getActiveWorkId());
const currentChapter = chapters.find(ch => ch.id === activeChapterId);
const currentIndex = chapters.findIndex(ch => ch.id === activeChapterId);
+125
View File
@@ -108,6 +108,131 @@ export async function importProject(file) {
}
}
/**
* 导入作品 — 从 TXT 文件中自动识别章节并批量创建
* 支持格式:第X章/回/节/卷(中文/阿拉伯数字)、Chapter X、纯数字、纯中文数字
* @param {File} file - 用户选择的 TXT 文件
* @returns {Promise<{ success: boolean, message: string, chapters?: Array, totalWords?: number }>}
*/
export async function importWork(file) {
if (typeof window === 'undefined') return { success: false, message: '环境不支持' };
try {
const text = await file.text();
if (!text.trim()) {
return { success: false, message: '文件内容为空' };
}
// 章节标题正则 — 支持多种格式
// 1. 第X章/回/节/卷(中文数字或阿拉伯数字)+ 可选标题
// 2. Chapter X + 可选标题
// 3. 纯阿拉伯数字行(如 "1"、"23")
// 4. 纯中文数字行(如 "一"、"三十三")
const CHAPTER_REGEX = /^(?:第[零一二三四五六七八九十百千万\d]+[章回节卷](?:\s+.*)?|Chapter\s+\d+(?:\s+.*)?|\d+|[零一二三四五六七八九十百千万]+)$/i;
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
const rawChapters = [];
let currentChapter = null;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (CHAPTER_REGEX.test(trimmed)) {
// 找到章节标题
if (currentChapter) {
rawChapters.push(currentChapter);
}
currentChapter = { title: trimmed, lines: [] };
} else {
if (!currentChapter) {
// 第一个章节标题之前的内容归为序章
currentChapter = { title: null, lines: [] };
}
currentChapter.lines.push(lines[i]);
}
}
if (currentChapter) {
rawChapters.push(currentChapter);
}
// 如果只有一个无标题章节且无内容,说明没识别到任何章节
if (rawChapters.length === 0 || (rawChapters.length === 1 && !rawChapters[0].title && rawChapters[0].lines.join('').trim() === '')) {
return { success: false, message: 'noChapter' };
}
// 转换为章节对象
const { generateId } = await import('./storage');
const now = new Date().toISOString();
const chapters = rawChapters.map((raw, idx) => {
const content = textToHtml(raw.lines);
const plainText = raw.lines.join('').replace(/\s/g, '');
return {
id: generateId(),
title: raw.title || `序章`,
content,
wordCount: plainText.length,
createdAt: now,
updatedAt: now,
};
});
const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
return { success: true, chapters, totalWords, message: '' };
} catch (err) {
return { success: false, message: err.message };
}
}
/**
* 将纯文本行数组转换为 HTML(匹配编辑器 insertText 格式)
* 规则:空行分段(<p>),段内换行用 <br>,去掉多余空行
*/
function textToHtml(lines) {
const normalized = lines.join('\n').trim();
if (!normalized) return '';
// 按空行(连续换行)分段
const blocks = normalized.split(/\n\n+/);
return blocks
.map(block => {
const blockLines = block.split('\n').map(l => l.trimEnd()).filter(l => l);
if (blockLines.length === 0) return '';
return `<p>${blockLines.join('<br>')}</p>`;
})
.filter(p => p && p !== '<p></p>')
.join('');
}
/**
* 导出章节为 TXT 文件
* @param {Array} chapters - 章节数组
* @param {string} [fileName] - 文件名(不含扩展名)
*/
export function exportWorkAsTxt(chapters, fileName) {
if (!chapters || chapters.length === 0) return;
const text = chapters.map(ch => {
const title = ch.title || '';
const content = (ch.content || '')
.replace(/<\/p>/gi, '\n\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.trim();
return `${title}\n\n${content}`;
}).join('\n\n\n');
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName || '导出作品'}.txt`;
a.click();
URL.revokeObjectURL(url);
}
/**
* 获取当前项目数据的概要信息(用于显示)
*/
+3 -3
View File
@@ -1,6 +1,6 @@
import { get, set } from 'idb-keyval';
import { getChapters, saveChapters } from './storage';
import { getSettingsNodes, saveSettingsNodes } from './settings';
import { getSettingsNodes, saveSettingsNodes, getActiveWorkId } from './settings';
const SNAPSHOTS_KEY = 'author-snapshots';
@@ -26,7 +26,7 @@ export async function getSnapshots() {
*/
export async function createSnapshot(label, type = 'auto') {
try {
const chapters = await getChapters();
const chapters = await getChapters(getActiveWorkId());
const settingsNodes = await getSettingsNodes();
const snapshot = {
@@ -80,7 +80,7 @@ export async function restoreSnapshot(snapshotId) {
await createSnapshot('恢复前的备份', 'auto');
// 覆盖现有数据
await saveChapters(target.data.chapters || []);
await saveChapters(target.data.chapters || [], getActiveWorkId());
await saveSettingsNodes(target.data.settingsNodes || []);
return true;
+57 -27
View File
@@ -1,27 +1,57 @@
// 本地存储工具 - 使用 IndexedDB 管理核心数据 (章节、摘要)
import { get, set } from 'idb-keyval';
// 章节按作品(workId)隔离存储
import { get, set, del } from 'idb-keyval';
const STORAGE_KEY = 'author-chapters';
const LEGACY_STORAGE_KEY = 'author-chapters';
function getStorageKey(workId) {
return workId ? `author-chapters-${workId}` : LEGACY_STORAGE_KEY;
}
// 生成唯一ID
export function generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
}
// 获取所有章节 (Async)
export async function getChapters() {
if (typeof window === 'undefined') return [];
/**
* 一次性迁移:将旧的全局 author-chapters 剪切到当前活跃作品
* 调用方在 page.js initData 中负责调用
*/
export async function migrateGlobalChapters(workId) {
if (typeof window === 'undefined' || !workId) return;
try {
let chapters = await get(STORAGE_KEY);
if (!chapters) {
// Fallback: 第一次迁移,从 localStorage 拿
const legacyData = localStorage.getItem(STORAGE_KEY);
if (legacyData) {
chapters = JSON.parse(legacyData);
await set(STORAGE_KEY, chapters);
} else {
chapters = [];
const perWorkData = await get(getStorageKey(workId));
if (perWorkData) return; // 该作品已有数据,不迁移
const globalData = await get(LEGACY_STORAGE_KEY);
if (!globalData || !Array.isArray(globalData) || globalData.length === 0) {
// 也检查 localStorage fallback
const legacyLocal = localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacyLocal) {
const parsed = JSON.parse(legacyLocal);
if (Array.isArray(parsed) && parsed.length > 0) {
await set(getStorageKey(workId), parsed);
localStorage.removeItem(LEGACY_STORAGE_KEY);
}
}
return;
}
// 剪切到新 key
await set(getStorageKey(workId), globalData);
await del(LEGACY_STORAGE_KEY);
} catch (e) {
console.warn('[迁移] 章节迁移失败:', e);
}
}
// 获取所有章节 (Async)
export async function getChapters(workId) {
if (typeof window === 'undefined') return [];
const key = getStorageKey(workId);
try {
let chapters = await get(key);
if (!chapters) {
chapters = [];
}
return chapters;
} catch {
@@ -30,14 +60,14 @@ export async function getChapters() {
}
// 保存所有章节 (Async)
export async function saveChapters(chapters) {
export async function saveChapters(chapters, workId) {
if (typeof window === 'undefined') return;
await set(STORAGE_KEY, chapters);
await set(getStorageKey(workId), chapters);
}
// 创建新章节 (Async)
export async function createChapter(title = '未命名章节') {
const chapters = await getChapters();
export async function createChapter(title = '未命名章节', workId) {
const chapters = await getChapters(workId);
const newChapter = {
id: generateId(),
title,
@@ -47,13 +77,13 @@ export async function createChapter(title = '未命名章节') {
updatedAt: new Date().toISOString(),
};
chapters.push(newChapter);
await saveChapters(chapters);
await saveChapters(chapters, workId);
return newChapter;
}
// 更新章节 (Async)
export async function updateChapter(id, updates) {
const chapters = await getChapters();
export async function updateChapter(id, updates, workId) {
const chapters = await getChapters(workId);
const index = chapters.findIndex(ch => ch.id === id);
if (index === -1) return null;
@@ -62,21 +92,21 @@ export async function updateChapter(id, updates) {
...updates,
updatedAt: new Date().toISOString(),
};
await saveChapters(chapters);
await saveChapters(chapters, workId);
return chapters[index];
}
// 删除章节 (Async)
export async function deleteChapter(id) {
const chapters = await getChapters();
export async function deleteChapter(id, workId) {
const chapters = await getChapters(workId);
const newChapters = chapters.filter(ch => ch.id !== id);
await saveChapters(newChapters);
await saveChapters(newChapters, workId);
return newChapters;
}
// 获取单个章节 (Async)
export async function getChapter(id) {
const chapters = await getChapters();
export async function getChapter(id, workId) {
const chapters = await getChapters(workId);
return chapters.find(ch => ch.id === id) || null;
}
+14 -1
View File
@@ -255,7 +255,20 @@
"clickToSwitchMode": "Click to switch writing mode",
"exportedChapter": "Current chapter exported",
"exportedAll": "All chapters exported",
"importSuccess": "Page will reload automatically."
"importSuccess": "Page will reload automatically.",
"btnImportWork": "📥 Import",
"btnImportWorkTitle": "Import work from TXT file (auto-detect chapters)",
"importWorkConfirm": "Importing will replace all current chapters. Detected {count} chapters, {words} words total. Confirm import?",
"importWorkSuccess": "Successfully imported {count} chapters",
"importWorkFailed": "Import failed: {error}",
"importWorkNoChapter": "No chapters detected. Please check the file format.",
"importWorkSelectTitle": "Select Import Target",
"importWorkSelectDesc": "Detected {count} chapters, {words} words total. Choose which work to import into:",
"importWorkReplaceConfirm": "Import chapters into \"{name}\"? This will replace all existing chapters in that work.",
"importWorkNewBtn": "New Work",
"importWorkNewPlaceholder": "Enter new work name",
"btnExportTxt": "📤 Export",
"btnExportTxtTitle": "Export all chapters as TXT file"
},
"page": {
"noChapterHint": "Select or create a chapter to start writing",
+14 -1
View File
@@ -255,7 +255,20 @@
"clickToSwitchMode": "点击切换写作模式",
"exportedChapter": "已导出当前章节",
"exportedAll": "已导出全部章节",
"importSuccess": "页面将自动刷新。"
"importSuccess": "页面将自动刷新。",
"btnImportWork": "📥 导入",
"btnImportWorkTitle": "从 TXT 文件导入作品(自动识别章节)",
"importWorkConfirm": "导入作品将替换当前所有章节。已识别 {count} 个章节,共 {words} 字。确认导入?",
"importWorkSuccess": "成功导入 {count} 个章节",
"importWorkFailed": "导入失败:{error}",
"importWorkNoChapter": "未识别到任何章节,请检查文件格式",
"importWorkSelectTitle": "选择导入目标",
"importWorkSelectDesc": "已识别 {count} 个章节,共 {words} 字。请选择导入到哪个作品:",
"importWorkReplaceConfirm": "确定将章节导入到「{name}」?这将替换该作品的所有现有章节。",
"importWorkNewBtn": "新建作品",
"importWorkNewPlaceholder": "输入新作品名称",
"btnExportTxt": "📤 导出",
"btnExportTxtTitle": "将当前作品所有章节导出为 TXT 文件"
},
"page": {
"noChapterHint": "请选择或创建一个章节开始写作",
+44 -23
View File
@@ -11,6 +11,8 @@ import {
deleteChapter,
exportToMarkdown,
exportAllToMarkdown,
migrateGlobalChapters,
saveChapters,
} from './lib/storage';
import { buildContext, compileSystemPrompt, compileUserPrompt, OUTPUT_TOKEN_BUDGET, getContextItems, estimateTokens } from './lib/context-engine';
import { getProjectSettings, WRITING_MODES, getWritingMode, addSettingsNode, updateSettingsNode, deleteSettingsNode, getSettingsNodes, getActiveWorkId } from './lib/settings';
@@ -35,6 +37,7 @@ export default function Home() {
const {
chapters, setChapters, addChapter, updateChapter: updateChapterStore,
activeChapterId, setActiveChapterId,
activeWorkId, setActiveWorkId: setActiveWorkIdStore,
sidebarOpen, setSidebarOpen, toggleSidebar,
aiSidebarOpen, setAiSidebarOpen, toggleAiSidebar,
showSettings, setShowSettings,
@@ -66,30 +69,41 @@ export default function Home() {
const activeSession = useMemo(() => getActiveSession(sessionStore), [sessionStore]);
const chatHistory = useMemo(() => activeSession?.messages || [], [activeSession]);
// 加载指定作品的章节
const loadChaptersForWork = useCallback(async (workId) => {
let saved = await getChapters(workId);
// 自动修复:过滤掉损坏的章节数据
if (Array.isArray(saved)) {
const cleaned = saved.filter(ch => ch && typeof ch === 'object' && ch.id);
if (cleaned.length !== saved.length) {
console.warn(`[数据修复] 发现 ${saved.length - cleaned.length} 条损坏的章节数据,已自动清理`);
saved = cleaned;
await saveChapters(saved, workId);
}
} else {
saved = [];
}
if (saved.length === 0) {
const first = await createChapter(t('page.firstChapterTitle'), workId);
setChapters([first]);
setActiveChapterId(first.id);
} else {
setChapters(saved);
setActiveChapterId(saved[0].id);
}
}, [t, setChapters, setActiveChapterId]);
// 初始化数据
useEffect(() => {
const initData = async () => {
let saved = await getChapters();
// 自动修复:过滤掉损坏的章节数据(如缺少 id 或非对象的条目)
if (Array.isArray(saved)) {
const cleaned = saved.filter(ch => ch && typeof ch === 'object' && ch.id);
if (cleaned.length !== saved.length) {
console.warn(`[数据修复] 发现 ${saved.length - cleaned.length} 条损坏的章节数据,已自动清理`);
saved = cleaned;
const { saveChapters } = await import('./lib/storage');
await saveChapters(saved);
}
} else {
saved = [];
}
if (saved.length === 0) {
const first = await createChapter(t('page.firstChapterTitle'));
setChapters([first]);
setActiveChapterId(first.id);
} else {
setChapters(saved);
setActiveChapterId(saved[0].id);
const workId = getActiveWorkId();
if (workId) {
setActiveWorkIdStore(workId);
// 一次性迁移旧全局章节
await migrateGlobalChapters(workId);
}
await loadChaptersForWork(workId);
const savedTheme = localStorage.getItem('author-theme') || 'light';
setTheme(savedTheme);
setWritingMode(getWritingMode());
@@ -97,7 +111,6 @@ export default function Home() {
// 加载会话数据
let store = await loadSessionStore();
if (store.sessions.length === 0) {
// 首次使用:创建一个空会话
store = createSession(store);
}
setSessionStore(store);
@@ -105,6 +118,14 @@ export default function Home() {
initData();
}, []);
// 切换作品时重新加载章节
const prevWorkIdRef = useRef(activeWorkId);
useEffect(() => {
if (prevWorkIdRef.current === activeWorkId) return;
prevWorkIdRef.current = activeWorkId;
loadChaptersForWork(activeWorkId);
}, [activeWorkId, loadChaptersForWork]);
// 初始化上下文条目和勾选状态(设定集 + 章节 + 对话历史)
useEffect(() => {
if (!activeChapterId) return;
@@ -167,11 +188,11 @@ export default function Home() {
const updated = await updateChapter(activeChapterId, {
content: html,
wordCount,
});
}, activeWorkId);
if (updated) {
updateChapterStore(activeChapterId, { content: html, wordCount });
}
}, [activeChapterId, updateChapterStore]);
}, [activeChapterId, activeWorkId, updateChapterStore]);
// Inline AI 回调:编辑器调用此函数发起 AI 请求
const handleInlineAiRequest = useCallback(async ({ mode, text, instruction, signal, onChunk }) => {
+2
View File
@@ -4,8 +4,10 @@ export const useAppStore = create((set, get) => ({
// --- Chapter State ---
chapters: [],
activeChapterId: null,
activeWorkId: typeof window !== 'undefined' ? localStorage.getItem('author-active-work') || null : null,
setChapters: (chapters) => set({ chapters: Array.isArray(chapters) ? chapters.filter(ch => ch && typeof ch === 'object' && ch.id) : [] }),
setActiveChapterId: (id) => set({ activeChapterId: id }),
setActiveWorkId: (id) => set({ activeWorkId: id }),
addChapter: (chapter) => set((state) => ({ chapters: [...state.chapters, chapter] })),
deleteChapter: (id) => set((state) => ({ chapters: state.chapters.filter((ch) => ch.id !== id) })),
updateChapter: (id, updates) => set((state) => ({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "author-app",
"version": "1.1.1",
"version": "1.2.0",
"private": true,
"main": "electron/main.js",
"scripts": {