e.stopPropagation()}>
{/* 头部 */}
⚙️ {t('settings.title')}
— {t('settings.subtitle')}
-
+
+
+
+
{/* Tab 导航 */}
@@ -788,6 +794,7 @@ const PROVIDERS = [
{ key: 'zhipu', label: '智谱AI (GLM)', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', models: ['glm-4-flash', 'glm-4-plus', 'glm-4-long', 'glm-4'] },
{ key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', models: ['deepseek-chat', 'deepseek-reasoner'] },
{ key: 'openai', label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', models: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'] },
+ { key: 'claude', label: 'Claude (Anthropic)', baseUrl: 'https://api.anthropic.com', models: ['claude-sonnet-4-20250514', 'claude-3-7-sonnet-20250219', 'claude-3-5-haiku-20241022'] },
{ key: 'gemini', label: 'Gemini (OpenAI兼容)', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', models: ['gemini-2.0-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-flash', 'gemini-1.5-pro'] },
{ key: 'gemini-native', label: 'Gemini(原生格式)', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', models: ['gemini-2.0-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-flash', 'gemini-1.5-pro'] },
{ key: 'openai-responses', label: 'OpenAI Responses', baseUrl: 'https://api.openai.com/v1', models: [] },
diff --git a/app/components/Sidebar.js b/app/components/Sidebar.js
index 8ad0b34..4345cc2 100644
--- a/app/components/Sidebar.js
+++ b/app/components/Sidebar.js
@@ -1,6 +1,6 @@
'use client';
-import { useState, useCallback, useEffect } from 'react';
+import { useState, useCallback, useEffect, useRef } from 'react';
import { useAppStore } from '../store/useAppStore';
import { useI18n } from '../lib/useI18n';
import { createChapter, deleteChapter, updateChapter, saveChapters, getChapters } from '../lib/storage';
@@ -8,7 +8,7 @@ import { exportProject, importProject, importWork, exportWorkAsTxt, exportWorkAs
import { WRITING_MODES, getAllWorks, getSettingsNodes, createWorkNode, saveSettingsNodes, setActiveWorkId as setActiveWorkIdSetting } from '../lib/settings';
import { detectConflicts, mergeChapters } from '../lib/chapter-number';
-export default function Sidebar() {
+export default function Sidebar({ onOpenHelp, onToggle, editorRef }) {
const {
chapters, addChapter, setChapters, updateChapter: updateChapterStore,
activeChapterId, setActiveChapterId,
@@ -24,10 +24,15 @@ 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 [showCurrentExportMenu, setShowCurrentExportMenu] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
- const [conflictModal, setConflictModal] = useState(null); // { conflicts, noConflictExisting, noConflictImported, targetWorkId, importedChapters }
+ const [showCurrentExportMenu, setShowCurrentExportMenu] = useState(false);
+ const [importModal, setImportModal] = useState(null);
+ const [conflictModal, setConflictModal] = useState(null);
+ const [showGitPopup, setShowGitPopup] = useState(false);
+ const [outlineCollapsed, setOutlineCollapsed] = useState(false); // 手动折叠大纲
+ const [headings, setHeadings] = useState([]); // 文档大纲标题列表
+ const [activeHeadingIndex, setActiveHeadingIndex] = useState(-1); // 当前高亮的大纲项
+ const isClickScrollingRef = useRef(false); // 防 scrollspy 死循环互斥锁
const { t } = useI18n();
// 切换主题
@@ -78,19 +83,19 @@ export default function Sidebar() {
// 尝试从标题提取数字并生成下一章标题,返回 null 表示无法匹配
const tryNextTitle = (title) => {
- // 1. "第N章" 阿拉伯数字
+ // 1. "第N章" 阿拉伯数字 — 只保留章节编号,去掉后续标题名
const m1 = title.match(/第(\d+)章/);
- if (m1) return title.replace(/第\d+章/, `第${parseInt(m1[1], 10) + 1}章`);
- // 2. "第X章" 中文数字(如 第三十三章)
+ if (m1) return `第${parseInt(m1[1], 10) + 1}章`;
+ // 2. "第X章" 中文数字(如 第三十三章)— 只保留章节编号
const m2 = title.match(/第([零一二三四五六七八九十百千万]+)章/);
- if (m2) { const n = parseCnNum(m2[1]); if (!isNaN(n)) return title.replace(/第[零一二三四五六七八九十百千万]+章/, `第${toCnNum(n + 1)}章`); }
+ if (m2) { const n = parseCnNum(m2[1]); if (!isNaN(n)) return `第${toCnNum(n + 1)}章`; }
// 3. 纯阿拉伯数字(如 "33")
if (/^\d+$/.test(title.trim())) return String(parseInt(title.trim(), 10) + 1);
// 4. 纯中文数字(如 "三十三")
if (/^[零一二三四五六七八九十百千万]+$/.test(title.trim())) { const n = parseCnNum(title.trim()); if (!isNaN(n)) return toCnNum(n + 1); }
- // 5. 包含末尾数字(如 "Chapter 33")
- const m5 = title.match(/(\d+)\s*$/);
- if (m5) return title.replace(/(\d+)\s*$/, String(parseInt(m5[1], 10) + 1));
+ // 5. 包含末尾数字(如 "Chapter 33")— 只递增数字,保留前缀
+ const m5 = title.match(/^(.+?)(\d+)\s*$/);
+ if (m5) return m5[1] + String(parseInt(m5[2], 10) + 1);
return null;
};
@@ -143,6 +148,135 @@ export default function Sidebar() {
setRenameTitle('');
}, [renameTitle, updateChapterStore, activeWorkId]);
+ // ===== 文档大纲:从编辑器提取标题 + Scrollspy =====
+ useEffect(() => {
+ let debounceTimer = null;
+ let observer = null;
+ let pollTimer = null;
+ let cleanedUp = false;
+
+ // 提取标题的函数
+ const extractHeadings = (editor) => {
+ const json = editor.getJSON();
+ const h = [];
+ (json.content || []).forEach((node, idx) => {
+ if (node.type === 'heading' && node.attrs?.level) {
+ const text = (node.content || []).map(c => c.text || '').join('');
+ if (text.trim()) h.push({ level: node.attrs.level, text: text.trim(), index: idx });
+ }
+ });
+ setHeadings(h);
+ };
+
+ // 设置 IntersectionObserver
+ const setupObserver = (editor) => {
+ const container = document.querySelector('.editor-container');
+ const headingEls = editor.view?.dom?.querySelectorAll('h1, h2, h3');
+ if (!container || !headingEls?.length) return;
+
+ observer = new IntersectionObserver(
+ (entries) => {
+ if (isClickScrollingRef.current) return;
+ let topEntry = null;
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ if (!topEntry || entry.boundingClientRect.top < topEntry.boundingClientRect.top) {
+ topEntry = entry;
+ }
+ }
+ });
+ if (topEntry) {
+ const allH = Array.from(editor.view.dom.querySelectorAll('h1, h2, h3'));
+ const idx = allH.indexOf(topEntry.target);
+ if (idx >= 0) setActiveHeadingIndex(idx);
+ }
+ },
+ { root: container, rootMargin: '-10% 0px -80% 0px', threshold: 0 }
+ );
+
+ headingEls.forEach(el => observer.observe(el));
+ };
+
+ // 当编辑器就绪时,设置监听
+ const initWithEditor = (editor) => {
+ // 初始提取
+ extractHeadings(editor);
+
+ // 监听内容变化(防抖 300ms)
+ const onUpdate = () => {
+ clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(() => extractHeadings(editor), 300);
+ };
+ editor.on('update', onUpdate);
+
+ // 延迟设置 Observer
+ setTimeout(() => {
+ if (!cleanedUp) setupObserver(editor);
+ }, 500);
+
+ // 返回清理函数
+ return () => {
+ editor.off('update', onUpdate);
+ clearTimeout(debounceTimer);
+ observer?.disconnect();
+ };
+ };
+
+ // 轮询等待编辑器就绪
+ let editorCleanup = null;
+ const tryInit = () => {
+ const editor = editorRef?.current?.getEditor?.();
+ if (editor && !cleanedUp) {
+ clearInterval(pollTimer);
+ editorCleanup = initWithEditor(editor);
+ }
+ };
+
+ // 立即尝试一次
+ tryInit();
+ // 如果还没就绪,每 200ms 重试
+ if (!editorRef?.current?.getEditor?.()) {
+ pollTimer = setInterval(tryInit, 200);
+ }
+
+ return () => {
+ cleanedUp = true;
+ clearInterval(pollTimer);
+ editorCleanup?.();
+ setHeadings([]);
+ };
+ }, [editorRef, activeChapterId]);
+
+ // 点击大纲项:滚动到对应位置
+ const handleOutlineClick = useCallback((headingIdx) => {
+ const editor = editorRef?.current?.getEditor?.();
+ if (!editor) return;
+ const headingEls = editor.view?.dom?.querySelectorAll('h1, h2, h3');
+ const target = headingEls?.[headingIdx];
+ if (!target) return;
+
+ isClickScrollingRef.current = true;
+ setActiveHeadingIndex(headingIdx);
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' });
+
+ // 滚动结束后解锁
+ const unlock = () => { isClickScrollingRef.current = false; };
+ const container = document.querySelector('.editor-container');
+ if (container) {
+ container.addEventListener('scrollend', unlock, { once: true });
+ // 兜底:500ms 后强制解锁
+ setTimeout(() => {
+ container.removeEventListener('scrollend', unlock);
+ isClickScrollingRef.current = false;
+ }, 600);
+ } else {
+ setTimeout(unlock, 600);
+ }
+ }, [editorRef]);
+
+ // 统计标题数(作为 tab 角标)
+ const headingCount = headings.length;
+
// 导出
const totalWords = Array.isArray(chapters) ? chapters.reduce((sum, ch) => sum + (ch.wordCount || 0), 0) : 0;
@@ -150,107 +284,106 @@ export default function Sidebar() {
return (
<>