feat: per-work chapter storage, import work selector, TXT export v1.2.0
This commit is contained in:
1 parent
9859743f85
commit
65ddf48f38
11 files changed
+418
-68
No files matched your search
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前项目数据的概要信息(用于显示)
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }) => {
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "author-app",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
|
||||
Reference in new issue
Block a user