Files

1066 lines
62 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
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';
import { exportProject, importProject, importWork, exportWorkAsTxt, exportWorkAsMarkdown, exportWorkAsDocx, exportWorkAsEpub, exportWorkAsPdf } from '../lib/project-io';
import { WRITING_MODES, getAllWorks, getSettingsNodes, createWorkNode, saveSettingsNodes, setActiveWorkId as setActiveWorkIdSetting } from '../lib/settings';
import { detectConflicts, mergeChapters } from '../lib/chapter-number';
import { estimateTokens } from '../lib/context-engine';
export default function Sidebar({ onOpenHelp, onToggle, editorRef }) {
const {
chapters, addChapter, setChapters, updateChapter: updateChapterStore,
activeChapterId, setActiveChapterId,
activeWorkId, setActiveWorkId: setActiveWorkIdStore,
sidebarOpen, setSidebarOpen,
theme, setTheme,
writingMode,
setShowSettings,
setShowSnapshots,
showToast
} = useAppStore();
const [renameId, setRenameId] = useState(null);
const [renameTitle, setRenameTitle] = useState('');
const [contextMenu, setContextMenu] = useState(null);
const [showExportModal, setShowExportModal] = useState(false);
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();
// 切换主题
const toggleTheme = useCallback(() => {
const next = theme === 'light' ? 'dark' : 'light';
setTheme(next);
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('author-theme', next);
import('../lib/persistence').then(m => m.persistSet('author-theme', next).catch(() => { }));
}, [theme, setTheme]);
// 中文数字 ↔ 阿拉伯数字 互转
const cnDigits = '零一二三四五六七八九十百千万';
const parseCnNum = (s) => {
if (!s) return NaN;
let result = 0, current = 0;
for (const ch of s) {
const d = '零一二三四五六七八九'.indexOf(ch);
if (d >= 0) { current = d || current; }
else if (ch === '十') { result += (current || 1) * 10; current = 0; }
else if (ch === '百') { result += (current || 1) * 100; current = 0; }
else if (ch === '千') { result += (current || 1) * 1000; current = 0; }
else if (ch === '万') { result += (current || 1) * 10000; current = 0; }
}
return result + current;
};
const toCnNum = (n) => {
if (n <= 0) return '零';
if (n <= 10) return '零一二三四五六七八九十'[n];
const units = ['', '十', '百', '千', '万'];
const digits = '零一二三四五六七八九';
let result = '';
let str = String(n);
let len = str.length;
let lastWasZero = false;
for (let i = 0; i < len; i++) {
const d = parseInt(str[i]);
const unit = units[len - 1 - i];
if (d === 0) { lastWasZero = true; }
else {
if (lastWasZero) result += '零';
if (d === 1 && unit === '十' && result === '') result += unit;
else result += digits[d] + unit;
lastWasZero = false;
}
}
return result;
};
// 尝试从标题提取数字并生成下一章标题,返回 null 表示无法匹配
const tryNextTitle = (title) => {
// 1. "第N章" 阿拉伯数字 — 只保留章节编号,去掉后续标题名
const m1 = title.match(/第(\d+)章/);
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 `第${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 m5[1] + String(parseInt(m5[2], 10) + 1);
return null;
};
// 从章节列表中向前搜索最近的带数字章节,推算下一章名
const getNextChapterTitle = useCallback(() => {
if (chapters.length === 0) return t('sidebar.defaultChapterTitle').replace('{num}', 1);
// 从最后一章向前找,跳过"更新说明"等非标准章节
for (let i = chapters.length - 1; i >= 0; i--) {
const next = tryNextTitle(chapters[i].title);
if (next) return next;
}
return t('sidebar.defaultChapterTitle').replace('{num}', chapters.length + 1);
}, [chapters, t]);
// 创建新章节 — 一键创建并进入重命名模式
const handleCreateChapter = useCallback(async () => {
const title = getNextChapterTitle();
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, activeWorkId]);
// 删除章节
const handleDeleteChapter = useCallback(async (id) => {
if (!Array.isArray(chapters) || chapters.length <= 1) {
showToast(t('sidebar.alertRetainOne'), 'error');
return;
}
const ch = chapters.find(c => c.id === 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, activeWorkId]);
// 重命名章节
const handleRename = useCallback((id) => {
const title = renameTitle.trim();
if (!title) return;
updateChapter(id, { title }, activeWorkId);
updateChapterStore(id, { title });
setRenameId(null);
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;
return (
<>
<aside className={`sidebar ${sidebarOpen ? '' : 'collapsed'}`}>
{/* ===== 顶部关闭按钮 ===== */}
<div className="sidebar-top-row">
<button className="btn btn-ghost btn-icon btn-sm" onClick={() => onToggle?.()} title={t('sidebar.collapseSidebar')} style={{ fontSize: '16px' }}>
←
</button>
</div>
{/* ===== 文档分页 ===== */}
<div className="gdocs-section-header">
<span className="gdocs-section-title">文档分页</span>
<button id="tour-new-chapter" className="gdocs-section-add" onClick={handleCreateChapter} title={t('sidebar.newChapter')}>+</button>
</div>
<div className="gdocs-tab-list">
{chapters.map(ch => {
const isActive = ch.id === activeChapterId;
const isExpanded = isActive && headings.length > 0 && !outlineCollapsed;
return (
<div key={ch.id} className="gdocs-tab-group">
<div
className={`gdocs-tab-item ${isActive ? 'active' : ''}`}
onClick={() => {
if (isActive) {
setOutlineCollapsed(prev => !prev);
} else {
setActiveChapterId(ch.id);
setOutlineCollapsed(false);
}
}}
>
{renameId === ch.id ? (
<input
className="modal-input"
style={{ margin: 0, padding: '4px 8px', fontSize: '13px', flex: 1 }}
value={renameTitle || ''}
onChange={e => setRenameTitle(e.target.value)}
onBlur={() => handleRename(ch.id)}
onKeyDown={e => e.key === 'Enter' && handleRename(ch.id)}
onClick={e => e.stopPropagation()}
autoFocus
/>
) : (
<>
<span className="gdocs-tab-arrow" style={{ transform: isExpanded ? 'rotate(90deg)' : 'none' }}>▶</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span className="gdocs-tab-title">{ch.title}</span>
{(ch.wordCount || 0) > 0 && (
<span style={{ display: 'block', fontSize: '10px', color: 'var(--text-muted)', marginTop: '1px' }}>
{ch.wordCount.toLocaleString()}字 · ~{estimateTokens((ch.content || '').replace(/<[^>]*>/g, '')).toLocaleString()} tokens
</span>
)}
</span>
<div className="gdocs-tab-actions">
<button
className="gdocs-tab-action-btn"
title={t('sidebar.contextRename')}
onClick={(e) => {
e.stopPropagation();
setRenameId(ch.id);
setRenameTitle(ch.title);
}}
><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /></svg></button>
<button
className="gdocs-tab-action-btn danger"
title={t('sidebar.contextDelete')}
onClick={(e) => {
e.stopPropagation();
handleDeleteChapter(ch.id);
}}
><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
</div>
</>
)}
</div>
{/* 展开的章节大纲 */}
{isExpanded && (
<div className="gdocs-outline-inline">
{headings.map((h, idx) => (
<div
key={idx}
className={`gdocs-outline-item ${idx === activeHeadingIndex ? 'active' : ''}`}
style={{ paddingLeft: `${28 + (h.level - 1) * 14}px` }}
onClick={() => handleOutlineClick(idx)}
title={h.text}
>
{h.text}
</div>
))}
</div>
)}
</div>
);
})}
</div>
{/* ===== 底部功能区(保留原有功能) ===== */}
<div className="sidebar-footer" style={{ flexDirection: 'column', alignItems: 'stretch', gap: '8px' }}>
{(() => {
const modeConfig = WRITING_MODES[writingMode];
return modeConfig ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 10px', borderRadius: 'var(--radius-sm)', background: `${modeConfig.color}10`, border: `1px solid ${modeConfig.color}30`, cursor: 'pointer', transition: 'all 0.15s ease' }} onClick={() => setShowSettings(true)} title={t('sidebar.clickToSwitchMode')}>
<span style={{ fontSize: '14px' }}>{modeConfig.icon}</span>
<span style={{ fontSize: '12px', fontWeight: '600', color: modeConfig.color }}>{t('sidebar.modeLabel').replace('{mode}', modeConfig.label)}</span>
</div>
) : null;
})()}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: 'var(--text-muted)' }}>
<span>{t('sidebar.totalWords')}</span>
<span style={{ color: 'var(--accent)', fontWeight: '600' }}>{totalWords.toLocaleString()}</span>
</div>
<div style={{ display: 'flex', gap: '4px' }}>
<div style={{ position: 'relative', display: 'flex', flex: 1 }}>
<button className="btn btn-secondary btn-sm" style={{ flex: 1, justifyContent: 'center', fontSize: '11px' }} onClick={() => setShowCurrentExportMenu(!showCurrentExportMenu)}>{t('sidebar.exportCurrent')}</button>
{showCurrentExportMenu && (<>
<div style={{ position: 'fixed', inset: 0, zIndex: 99 }} onClick={() => setShowCurrentExportMenu(false)} />
<div style={{ position: 'absolute', left: 0, bottom: '100%', marginBottom: 6, minWidth: 150, zIndex: 100, background: 'var(--bg-card)', border: '1px solid var(--border-light)', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-lg)', padding: 4 }}>
{activeChapterId && chapters.find(c => c.id === activeChapterId) ? [
{ label: '📄 TXT', fn: () => exportWorkAsTxt([chapters.find(c => c.id === activeChapterId)], chapters.find(c => c.id === activeChapterId).title) },
{ label: '📝 Markdown', fn: () => exportWorkAsMarkdown([chapters.find(c => c.id === activeChapterId)], chapters.find(c => c.id === activeChapterId).title) },
{ label: '📘 DOCX', fn: async () => await exportWorkAsDocx([chapters.find(c => c.id === activeChapterId)], chapters.find(c => c.id === activeChapterId).title) },
{ label: '📚 EPUB', fn: async () => await exportWorkAsEpub([chapters.find(c => c.id === activeChapterId)], chapters.find(c => c.id === activeChapterId).title) },
{ label: '🖨️ PDF', fn: () => exportWorkAsPdf([chapters.find(c => c.id === activeChapterId)], chapters.find(c => c.id === activeChapterId).title) },
].map(item => (
<button key={item.label} className="dropdown-item" onClick={async () => { await item.fn(); setShowCurrentExportMenu(false); showToast(t('sidebar.exportedChapter'), 'success'); }}>{item.label}</button>
)) : <div style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)' }}>{t('sidebar.noActiveChapter') || '请先选择章节'}</div>}
</div>
</>)}
</div>
<button className="btn btn-secondary btn-sm" style={{ flex: 1, justifyContent: 'center', fontSize: '11px' }} onClick={() => setShowExportModal(true)}>{t('sidebar.exportMore') || '导出更多'}</button>
<button id="tour-settings" className="btn btn-secondary btn-sm btn-icon" onClick={() => setShowSettings(true)} title={t('sidebar.tooltipSettings')}>⚙️</button>
<button className="btn btn-secondary btn-sm btn-icon" onClick={toggleTheme} title={theme === 'light' ? t('sidebar.tooltipThemeDark') : t('sidebar.tooltipThemeLight')}>{theme === 'light' ? '🌙' : '☀️'}</button>
</div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'stretch' }}>
<button className="btn btn-secondary btn-sm btn-icon" onClick={() => setShowSnapshots(true)} title={t('sidebar.tooltipTimeMachine')}>🕒</button>
<button className="btn btn-secondary btn-sm btn-icon" onClick={() => { exportProject(); }} title={t('sidebar.btnSaveTitle') || '存档(导出项目 JSON)'}>💾</button>
<button className="btn btn-secondary btn-sm btn-icon" onClick={() => { document.getElementById('project-import-input')?.click(); }} title={t('sidebar.btnLoadTitle') || '读档(导入项目 JSON)'}>📂</button>
<button className="btn btn-secondary btn-sm btn-icon" onClick={() => { document.getElementById('work-import-input')?.click(); }} title={t('sidebar.btnImportWorkTitle')}>📥</button>
<button id="tour-help" className="btn btn-secondary btn-sm btn-icon" onClick={() => onOpenHelp?.()} title={t('page.helpAndGuide') || '帮助与教程'}>📖</button>
<button id="tour-github" className="btn btn-secondary btn-sm btn-icon" onClick={() => setShowGitPopup(prev => !prev)} title="GitHub / Gitee / QQ群">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" /></svg>
</button>
<input id="project-import-input" type="file" accept=".json" style={{ display: 'none' }} onChange={async (e) => { const file = e.target.files?.[0]; if (!file) return; const result = await importProject(file); if (result.success) { alert(result.message + '\n' + t('sidebar.importSuccess')); window.location.reload(); } else { alert(result.message); } e.target.value = ''; }} />
<input id="work-import-input" type="file" accept=".txt,.md,.markdown,.epub,.docx,.doc,.pdf" 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>
{/* ===== Git / 社区弹窗 ===== */}
{showGitPopup && (
<div className="modal-overlay" onClick={() => setShowGitPopup(false)}>
<div className="glass-panel" onClick={e => e.stopPropagation()} style={{
padding: '28px', maxWidth: 360, width: '90%', borderRadius: 'var(--radius-lg)',
display: 'flex', flexDirection: 'column', gap: 16,
}}>
<h3 style={{ margin: 0, fontSize: 16, textAlign: 'center' }}>社区与源码</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<a href="https://github.com/YuanShiJiLoong/author" target="_blank" rel="noopener noreferrer" onClick={() => setShowGitPopup(false)} style={{ display: 'flex', alignItems: 'center', gap: 10, textDecoration: 'none', color: 'var(--text-primary)', fontSize: 14, padding: '10px 14px', borderRadius: 'var(--radius-md)', background: 'var(--bg-secondary)', transition: 'background 0.15s' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" /></svg>
<span style={{ flex: 1 }}>GitHub</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>→</span>
</a>
<a href="https://gitee.com/yuanshijilong/author" target="_blank" rel="noopener noreferrer" onClick={() => setShowGitPopup(false)} style={{ display: 'flex', alignItems: 'center', gap: 10, textDecoration: 'none', color: 'var(--text-primary)', fontSize: 14, padding: '10px 14px', borderRadius: 'var(--radius-md)', background: 'var(--bg-secondary)', transition: 'background 0.15s' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M11.984 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.016 0zm6.09 5.333c.328 0 .593.266.592.593v1.482a.594.594 0 0 1-.593.592H9.777c-.982 0-1.778.796-1.778 1.778v5.48c0 .327.266.592.593.592h5.574c.327 0 .593-.265.593-.593v-1.482a.594.594 0 0 0-.593-.592h-3.408a.43.43 0 0 1-.43-.43v-1.455a.43.43 0 0 1 .43-.43h5.91c.329 0 .594.266.594.593v5.78a2.133 2.133 0 0 1-2.133 2.134H5.926a.593.593 0 0 1-.593-.593V9.778a4.444 4.444 0 0 1 4.444-4.444h8.297z" /></svg>
<span style={{ flex: 1 }}>Gitee(国内镜像)</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>→</span>
</a>
<div style={{ height: 1, background: 'var(--border-light)', margin: '4px 0' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', borderRadius: 'var(--radius-md)', background: 'var(--bg-secondary)' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12.003 2C6.477 2 2 6.477 2 12.003c0 2.39.84 4.584 2.236 6.31l-.924 3.468 3.592-.96A9.95 9.95 0 0 0 12.003 22C17.52 22 22 17.523 22 12.003S17.52 2 12.003 2zm4.97 13.205c-.234.657-1.378 1.257-1.902 1.313-.525.06-1.003.234-3.38-.703-2.86-1.13-4.68-4.07-4.82-4.26-.14-.19-1.15-1.53-1.15-2.92s.728-2.072.986-2.354c.258-.282.563-.352.75-.352s.375.004.54.01c.173.006.405-.066.633.483.234.563.797 1.947.867 2.088.07.14.117.305.023.492-.094.188-.14.305-.28.468-.14.164-.296.366-.422.492-.14.14-.286.292-.123.571.164.28.727 1.2 1.562 1.944 1.073.955 1.977 1.252 2.258 1.393.28.14.445.117.608-.07.164-.188.703-.82.89-1.102.188-.28.375-.234.633-.14.258.093 1.632.77 1.912.91.28.14.468.21.538.328.07.117.07.68-.164 1.336z" /></svg>
<span style={{ flex: 1, fontSize: 14 }}>QQ群:1087016949</span>
<button className="btn btn-ghost btn-sm" style={{ padding: '4px 8px', fontSize: 11 }} onClick={() => { navigator.clipboard?.writeText('1087016949'); showToast('群号已复制', 'success'); }}>复制群号</button>
<a href="https://qm.qq.com/q/wjRDkotw0E" target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm" style={{ padding: '4px 8px', fontSize: 11, textDecoration: 'none' }} onClick={() => setShowGitPopup(false)}>直达</a>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-ghost btn-sm" onClick={() => setShowGitPopup(false)}>关闭</button>
</div>
</div>
</div>
)}
{/* ===== 右键菜单 ===== */}
{contextMenu && (
<div className="modal-overlay" style={{ background: 'transparent' }} onClick={() => setContextMenu(null)}>
<div className="dropdown-menu" style={{ position: 'fixed', left: contextMenu.x, top: contextMenu.y }}>
<button className="dropdown-item" onClick={() => { setRenameId(contextMenu.id); const ch = chapters.find(c => c.id === contextMenu.id); setRenameTitle(ch?.title || ''); setContextMenu(null); }}>{t('sidebar.contextRename')}</button>
<button className="dropdown-item" onClick={() => { const ch = chapters.find(c => c.id === contextMenu.id); if (ch) exportWorkAsMarkdown([ch], ch.title); setContextMenu(null); }}>{t('sidebar.contextExport')}</button>
<button className="dropdown-item danger" onClick={() => handleDeleteChapter(contextMenu.id)}>{t('sidebar.contextDelete')}</button>
</div>
</div>
)}
{/* ===== 导入作品弹窗 ===== */}
{importModal && (
<ImportWorkModal
chapters={importModal.chapters}
totalWords={importModal.totalWords}
onClose={() => setImportModal(null)}
onImport={async (targetWorkId) => {
try {
const existingChapters = await getChapters(targetWorkId);
if (existingChapters.length === 0) {
await saveChapters(importModal.chapters, targetWorkId);
setActiveWorkIdSetting(targetWorkId);
setChapters(importModal.chapters);
if (importModal.chapters.length > 0) setActiveChapterId(importModal.chapters[0].id);
setActiveWorkIdStore(targetWorkId);
showToast(t('sidebar.importWorkSuccess').replace('{count}', importModal.chapters.length), 'success');
setImportModal(null);
return;
}
const { conflicts, noConflictExisting, noConflictImported } = detectConflicts(existingChapters, importModal.chapters);
if (conflicts.length === 0) {
const merged = mergeChapters(noConflictExisting, noConflictImported, []);
await saveChapters(merged, targetWorkId);
setActiveWorkIdSetting(targetWorkId);
setChapters(merged);
if (merged.length > 0) setActiveChapterId(merged[0].id);
setActiveWorkIdStore(targetWorkId);
showToast(t('sidebar.importWorkSuccess').replace('{count}', importModal.chapters.length), 'success');
setImportModal(null);
} else {
setConflictModal({ conflicts, noConflictExisting, noConflictImported, targetWorkId, importedCount: importModal.chapters.length });
setImportModal(null);
}
} catch (err) {
showToast(t('sidebar.importWorkFailed').replace('{error}', err.message), 'error');
}
}}
t={t}
/>
)}
{/* ===== 章节冲突弹窗 ===== */}
{conflictModal && (
<ChapterConflictModal
conflicts={conflictModal.conflicts}
onClose={() => setConflictModal(null)}
onConfirm={async (resolvedConflicts) => {
try {
const merged = mergeChapters(conflictModal.noConflictExisting, conflictModal.noConflictImported, resolvedConflicts);
await saveChapters(merged, conflictModal.targetWorkId);
setActiveWorkIdSetting(conflictModal.targetWorkId);
setChapters(merged);
if (merged.length > 0) setActiveChapterId(merged[0].id);
setActiveWorkIdStore(conflictModal.targetWorkId);
showToast(t('sidebar.importWorkSuccess').replace('{count}', conflictModal.importedCount), 'success');
setConflictModal(null);
} catch (err) {
showToast(t('sidebar.importWorkFailed').replace('{error}', err.message), 'error');
}
}}
t={t}
/>
)}
{/* ===== 导出更多弹窗 ===== */}
{showExportModal && (
<ExportModal
chapters={chapters}
onClose={() => setShowExportModal(false)}
onExport={(selectedChapters, format) => {
const fns = { txt: exportWorkAsTxt, md: exportWorkAsMarkdown, docx: exportWorkAsDocx, epub: exportWorkAsEpub, pdf: exportWorkAsPdf };
const fn = fns[format];
if (fn) fn(selectedChapters);
setShowExportModal(false);
showToast(t('sidebar.exportedAll'), 'success');
}}
t={t}
/>
)}
</>
);
}
/**
* 导入作品时的目标作品选择弹窗
*/
function ImportWorkModal({ chapters, totalWords, onClose, onImport, t }) {
const [works, setWorks] = useState([]);
const [newWorkName, setNewWorkName] = useState('');
const [showNewInput, setShowNewInput] = useState(false);
// 加载作品列表
useEffect(() => {
(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={() => 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>
);
}
/**
* 章节冲突解决弹窗
* 显示编号冲突的章节分组,用户可勾选保留哪些
*/
function ChapterConflictModal({ conflicts, onClose, onConfirm, t }) {
// 初始化选择状态:默认全选
const [selections, setSelections] = useState(() => {
const init = {};
for (const group of conflicts) {
init[group.num] = {};
for (const ch of group.existing) init[group.num][ch.id] = true;
for (const ch of group.imported) init[group.num][ch.id] = true;
}
return init;
});
const toggleChapter = (num, id) => {
setSelections(prev => ({
...prev,
[num]: { ...prev[num], [id]: !prev[num][id] },
}));
};
const isAllSelected = () => {
for (const num in selections) {
for (const id in selections[num]) {
if (!selections[num][id]) return false;
}
}
return true;
};
const toggleAll = () => {
const allSelected = isAllSelected();
const next = {};
for (const num in selections) {
next[num] = {};
for (const id in selections[num]) {
next[num][id] = !allSelected;
}
}
setSelections(next);
};
// 全选已有
const selectAllExisting = () => {
const next = {};
for (const group of conflicts) {
next[group.num] = {};
for (const ch of group.existing) next[group.num][ch.id] = true;
for (const ch of group.imported) next[group.num][ch.id] = false;
}
setSelections(next);
};
// 全选导入
const selectAllImported = () => {
const next = {};
for (const group of conflicts) {
next[group.num] = {};
for (const ch of group.existing) next[group.num][ch.id] = false;
for (const ch of group.imported) next[group.num][ch.id] = true;
}
setSelections(next);
};
// 单组全选
const toggleGroupAll = (group) => {
const ids = [...group.existing, ...group.imported].map(ch => ch.id);
const allSel = ids.every(id => selections[group.num]?.[id]);
setSelections(prev => {
const next = { ...prev, [group.num]: { ...prev[group.num] } };
ids.forEach(id => { next[group.num][id] = !allSel; });
return next;
});
};
// 单组全选已有
const selectGroupExisting = (group) => {
setSelections(prev => {
const next = { ...prev, [group.num]: { ...prev[group.num] } };
for (const ch of group.existing) next[group.num][ch.id] = true;
for (const ch of group.imported) next[group.num][ch.id] = false;
return next;
});
};
// 单组全选导入
const selectGroupImported = (group) => {
setSelections(prev => {
const next = { ...prev, [group.num]: { ...prev[group.num] } };
for (const ch of group.existing) next[group.num][ch.id] = false;
for (const ch of group.imported) next[group.num][ch.id] = true;
return next;
});
};
const handleConfirm = () => {
const resolved = conflicts.map(group => {
const selected = [];
for (const ch of group.existing) {
if (selections[group.num]?.[ch.id]) selected.push(ch);
}
for (const ch of group.imported) {
if (selections[group.num]?.[ch.id]) selected.push(ch);
}
return { num: group.num, selected };
});
onConfirm(resolved);
};
const btnStyle = (active) => ({
padding: '2px 8px', fontSize: 11, borderRadius: 4, border: '1px solid var(--border-light)',
background: active ? 'var(--accent)' : 'transparent',
color: active ? '#fff' : 'var(--text-secondary)',
cursor: 'pointer', whiteSpace: 'nowrap', transition: 'all 0.15s',
});
return (
<div className="modal-overlay" onClick={onClose}>
<div className="glass-panel" onClick={e => e.stopPropagation()} style={{
padding: '24px', maxWidth: 520, width: '90%', borderRadius: 'var(--radius-lg)',
display: 'flex', flexDirection: 'column', gap: 16,
maxHeight: '70vh', overflow: 'hidden',
}}>
<h3 style={{ margin: 0, fontSize: 16 }}>{t('sidebar.conflictTitle') || '章节编号冲突'}</h3>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)' }}>
{t('sidebar.conflictDesc') || '以下章节编号相同,请选择保留哪些:'}
</p>
<div style={{ overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: 14, paddingRight: 4 }}>
{conflicts.map((group, gi) => {
const groupIds = [...group.existing, ...group.imported].map(ch => ch.id);
const groupAllSel = groupIds.every(id => selections[group.num]?.[id]);
return (
<div key={group.num} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{(t('sidebar.conflictGroup') || '第 {index} 组冲突(编号 {num}):')
.replace('{index}', gi + 1)
.replace('{num}', group.num)}
</div>
{/* 组级快捷按钮 */}
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button style={btnStyle(groupAllSel)} onClick={() => toggleGroupAll(group)}>
{t('sidebar.conflictSelectAll') || '全选'}
</button>
<button style={btnStyle(false)} onClick={() => selectGroupExisting(group)}>
{t('sidebar.conflictSelectExisting') || '全选已有'}
</button>
<button style={btnStyle(false)} onClick={() => selectGroupImported(group)}>
{t('sidebar.conflictSelectImported') || '全选导入'}
</button>
</div>
{group.existing.map(ch => (
<label key={ch.id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', padding: '4px 8px', borderRadius: 6, background: 'var(--bg-secondary)' }}>
<input
type="checkbox"
checked={!!selections[group.num]?.[ch.id]}
onChange={() => toggleChapter(group.num, ch.id)}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>[{t('sidebar.conflictExisting') || '已有'}]</span>
<span style={{ flex: 1 }}>{ch.title}</span>
</label>
))}
{group.imported.map(ch => (
<label key={ch.id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', padding: '4px 8px', borderRadius: 6, background: 'var(--bg-secondary)' }}>
<input
type="checkbox"
checked={!!selections[group.num]?.[ch.id]}
onChange={() => toggleChapter(group.num, ch.id)}
/>
<span style={{ color: 'var(--accent)', fontSize: 11 }}>[{t('sidebar.conflictImported') || '导入'}]</span>
<span style={{ flex: 1 }}>{ch.title}</span>
</label>
))}
</div>
);
})}
</div>
{/* 底部:全局快捷按钮 + 操作 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={isAllSelected()} onChange={toggleAll} />
{t('sidebar.conflictSelectAll') || '全选'}
</label>
<button style={btnStyle(false)} onClick={selectAllExisting}>
{t('sidebar.conflictSelectExisting') || '全选已有'}
</button>
<button style={btnStyle(false)} onClick={selectAllImported}>
{t('sidebar.conflictSelectImported') || '全选导入'}
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-ghost btn-sm" onClick={onClose}>{t('common.cancel')}</button>
<button className="btn btn-primary btn-sm" onClick={handleConfirm}>{t('sidebar.conflictConfirm') || '确认合并'}</button>
</div>
</div>
</div>
</div>
);
}
// 导出更多弹窗 — 选择章节 + 格式
function ExportModal({ chapters, onClose, onExport, t }) {
const [selected, setSelected] = useState(new Set());
const [format, setFormat] = useState('txt');
// 按每 10 章分组
const groups = [];
for (let i = 0; i < chapters.length; i += 10) {
groups.push(chapters.slice(i, i + 10));
}
const toggleChapter = (id) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleGroup = (group) => {
const ids = group.map(ch => ch.id);
const allSelected = ids.every(id => selected.has(id));
setSelected(prev => {
const next = new Set(prev);
if (allSelected) {
ids.forEach(id => next.delete(id));
} else {
ids.forEach(id => next.add(id));
}
return next;
});
};
const toggleAll = () => {
if (selected.size === chapters.length) {
setSelected(new Set());
} else {
setSelected(new Set(chapters.map(ch => ch.id)));
}
};
const formats = [
{ value: 'txt', label: '📄 TXT' },
{ value: 'md', label: '📝 Markdown' },
{ value: 'docx', label: '📘 DOCX' },
{ value: 'epub', label: '📚 EPUB' },
{ value: 'pdf', label: '🖨️ PDF' },
];
return (
<div className="modal-overlay" onClick={onClose}>
<div onClick={e => e.stopPropagation()} style={{
width: '90vw', maxWidth: 500, maxHeight: '85vh',
display: 'flex', flexDirection: 'column',
background: 'var(--bg-card)',
borderRadius: 16,
border: '1px solid var(--border-light)',
boxShadow: '0 24px 48px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.05)',
overflow: 'hidden',
}}>
{/* 头部 */}
<div style={{
padding: '20px 24px 16px',
background: 'linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 70%, #000))',
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 22 }}>📤</span>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{t('sidebar.exportMoreTitle') || '导出更多'}</h3>
<span style={{ fontSize: 12, opacity: 0.85 }}>
{t('sidebar.exportSelectHint') || '选择要导出的章节'}
</span>
</div>
</div>
<button onClick={onClose} style={{
background: 'rgba(255,255,255,0.2)', border: 'none', borderRadius: 8,
color: '#fff', width: 32, height: 32, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16,
}}>✕</button>
</div>
{/* 全选栏 */}
<div style={{
padding: '10px 20px',
borderBottom: '1px solid var(--border-light)',
background: 'var(--bg-secondary)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
<input
type="checkbox"
checked={selected.size === chapters.length && chapters.length > 0}
onChange={toggleAll}
style={{ accentColor: 'var(--accent)', width: 16, height: 16 }}
/>
{t('sidebar.exportSelectAll') || '全选'}
</label>
<span style={{
fontSize: 12, fontWeight: 600,
background: selected.size > 0 ? 'var(--accent)' : 'var(--bg-tertiary, #888)',
color: selected.size > 0 ? '#fff' : 'var(--text-muted)',
padding: '2px 10px', borderRadius: 12,
transition: 'all 0.2s',
}}>
{selected.size} / {chapters.length}
</span>
</div>
{/* 章节分组列表 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 16px' }}>
{groups.map((group, gi) => {
const startIdx = gi * 10 + 1;
const endIdx = gi * 10 + group.length;
const groupIds = group.map(ch => ch.id);
const allGroupSelected = groupIds.every(id => selected.has(id));
const someGroupSelected = groupIds.some(id => selected.has(id));
return (
<div key={gi} style={{ marginBottom: 6 }}>
{/* 组标题 */}
<label style={{
display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer',
fontSize: 12, fontWeight: 700, color: 'var(--text-secondary)',
padding: '8px 8px 6px', letterSpacing: '0.5px',
textTransform: 'uppercase',
borderBottom: '2px solid var(--border-light)',
marginBottom: 2,
}}>
<input
type="checkbox"
checked={allGroupSelected}
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected; }}
onChange={() => toggleGroup(group)}
style={{ accentColor: 'var(--accent)', width: 15, height: 15 }}
/>
{t('sidebar.exportGroup') || '第'} {startIdx}–{endIdx} {t('sidebar.exportGroupSuffix') || '章'}
</label>
{/* 组内章节 */}
{group.map(ch => (
<label key={ch.id} style={{
display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer',
fontSize: 13, padding: '6px 8px 6px 24px',
color: selected.has(ch.id) ? 'var(--text-primary)' : 'var(--text-secondary)',
borderRadius: 6,
background: selected.has(ch.id) ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent',
transition: 'background 0.15s',
}}
onMouseEnter={e => { if (!selected.has(ch.id)) e.currentTarget.style.background = 'var(--bg-secondary)'; }}
onMouseLeave={e => { if (!selected.has(ch.id)) e.currentTarget.style.background = 'transparent'; }}
>
<input
type="checkbox"
checked={selected.has(ch.id)}
onChange={() => toggleChapter(ch.id)}
style={{ accentColor: 'var(--accent)', width: 14, height: 14, flexShrink: 0 }}
/>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: selected.has(ch.id) ? 500 : 400 }}>
{ch.title || t('sidebar.untitled') || '未命名'}
</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
{(ch.wordCount || 0).toLocaleString()}{t('sidebar.wordUnit') || '字'}
</span>
</label>
))}
</div>
);
})}
</div>
{/* 底部操作栏 */}
<div style={{
padding: '14px 20px',
borderTop: '1px solid var(--border-light)',
background: 'var(--bg-secondary)',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<div style={{ display: 'flex', gap: 4, flex: 1, flexWrap: 'wrap' }}>
{formats.map(f => (
<button
key={f.value}
onClick={() => setFormat(f.value)}
style={{
padding: '5px 12px', fontSize: 12, fontWeight: 500,
borderRadius: 20, border: '1px solid',
borderColor: format === f.value ? 'var(--accent)' : 'var(--border-light)',
background: format === f.value ? 'var(--accent)' : 'transparent',
color: format === f.value ? '#fff' : 'var(--text-secondary)',
cursor: 'pointer', transition: 'all 0.2s',
whiteSpace: 'nowrap',
}}
>
{f.label}
</button>
))}
</div>
<button
className="btn btn-primary"
disabled={selected.size === 0}
onClick={() => {
const selectedChapters = chapters.filter(ch => selected.has(ch.id));
onExport(selectedChapters, format);
}}
style={{
flexShrink: 0, padding: '8px 20px', fontSize: 13, fontWeight: 600,
borderRadius: 10, opacity: selected.size === 0 ? 0.5 : 1,
}}
>
{t('sidebar.exportBtn') || '导出'} ({selected.size})
</button>
</div>
</div>
</div>
);
}