v1.1.8: feat: settings fullscreen + field expand modal, Claude API support, UI fixes

This commit is contained in:
YuanShiJiLoong committed 2026-03-02 06:33:28 +09:00
1 parent ec21b80ba6
commit 82014ae64c
12 files changed
+1124 -560

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
// Claude/Anthropic Messages API — SSE 流式转发(Edge Runtime 确保流式不被缓冲)
// 使用 Anthropic Messages API 格式 (/v1/messages)
export const runtime = 'edge';
export async function POST(request) {
try {
const { systemPrompt, userPrompt, apiConfig, maxTokens } = await request.json();
const apiKey = apiConfig?.apiKey;
const baseUrl = (apiConfig?.baseUrl || 'https://api.anthropic.com').replace(/\/$/, '');
const model = apiConfig?.model || 'claude-sonnet-4-20250514';
if (!apiKey) {
return new Response(
JSON.stringify({ error: '请先配置 API Key。点击左下角 ⚙️ → API配置,填入你的 Anthropic Key' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const url = `${baseUrl}/v1/messages`;
// 构造 Messages API 请求体
const requestBody = {
model,
max_tokens: maxTokens || 2000,
system: systemPrompt,
messages: [
{ role: 'user', content: userPrompt }
],
stream: true,
};
// 如果模型支持 extended thinking (claude-3-7-sonnet),可以启用
// 但默认不启用,因为需要额外的 budget 参数
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
console.error('Claude API 错误:', response.status, errorText);
const errorMessages = {
401: 'API Key 无效或已过期,请检查后重新填写',
403: 'API Key 无权限或已被禁用',
429: '请求频率过高或额度不足,请稍后再试',
529: 'Anthropic API 过载,请稍后再试',
};
let errMsg = errorMessages[response.status];
if (!errMsg) {
try {
const errObj = JSON.parse(errorText);
errMsg = errObj?.error?.message || `Claude 服务返回错误(${response.status})`;
} catch {
errMsg = `Claude 服务返回错误(${response.status}),请检查 API 配置`;
}
}
return new Response(
JSON.stringify({ error: errMsg }),
{ status: response.status, headers: { 'Content-Type': 'application/json' } }
);
}
// 将 Claude SSE 流解析并转发为统一的 {text, thinking} 格式
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const stream = new ReadableStream({
async start(controller) {
const reader = response.body.getReader();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(':')) continue;
// Claude SSE: event: xxx\ndata: {...}
if (trimmed.startsWith('data: ')) {
try {
const json = JSON.parse(trimmed.slice(6));
const eventType = json.type;
// 文本内容 delta
if (eventType === 'content_block_delta') {
const delta = json.delta;
if (delta?.type === 'text_delta' && delta.text) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text: delta.text })}\n\n`));
}
// 思维链 delta (extended thinking)
if (delta?.type === 'thinking_delta' && delta.thinking) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ thinking: delta.thinking })}\n\n`));
}
}
// 消息结束 — 提取 usage
if (eventType === 'message_delta') {
const usage = json.usage;
if (usage) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
usage: {
promptTokens: 0, // message_delta 只有 output_tokens
completionTokens: usage.output_tokens || 0,
totalTokens: usage.output_tokens || 0,
}
})}\n\n`));
}
}
// message_start 事件中包含 input tokens
if (eventType === 'message_start') {
const usage = json.message?.usage;
if (usage?.input_tokens) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
usage: {
promptTokens: usage.input_tokens || 0,
completionTokens: 0,
totalTokens: usage.input_tokens || 0,
}
})}\n\n`));
}
}
// 消息停止
if (eventType === 'message_stop') {
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
}
} catch {
// 解析失败的行直接跳过
}
}
}
}
} catch (err) {
console.error('Claude Stream 读取错误:', err.message);
} finally {
controller.close();
reader.releaseLock();
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
} catch (error) {
console.error('Claude 接口错误:', error);
return new Response(
JSON.stringify({ error: '网络连接失败,请检查 API 地址是否正确' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
+19
View File
@@ -17,6 +17,11 @@ export async function POST(request) {
return await fetchGeminiModels(apiKey, baseUrl, embedOnly);
}
// Claude/Anthropic(无 /models 端点,返回预定义列表)
if (provider === 'claude') {
return fetchClaudeModels();
}
// OpenAI 兼容格式(适用于所有其他供应商)
return await fetchOpenAIModels(apiKey, baseUrl, embedOnly);
@@ -119,3 +124,17 @@ async function handleFetchError(response) {
{ status: response.status }
);
}
// Claude/Anthropic 模型列表(预定义,无 API 端点)
function fetchClaudeModels() {
const models = [
{ id: 'claude-sonnet-4-20250514', displayName: 'Claude Sonnet 4' },
{ id: 'claude-3-7-sonnet-20250219', displayName: 'Claude 3.7 Sonnet' },
{ id: 'claude-3-5-haiku-20241022', displayName: 'Claude 3.5 Haiku' },
{ id: 'claude-3-5-sonnet-20241022', displayName: 'Claude 3.5 Sonnet v2' },
{ id: 'claude-3-5-sonnet-20240620', displayName: 'Claude 3.5 Sonnet' },
{ id: 'claude-3-opus-20240229', displayName: 'Claude 3 Opus' },
{ id: 'claude-3-haiku-20240307', displayName: 'Claude 3 Haiku' },
];
return NextResponse.json({ models });
}
+45
View File
@@ -23,6 +23,11 @@ export async function POST(request) {
return await testResponsesAPI(apiKey, baseUrl, model);
}
// Claude/Anthropic 格式的测试
if (provider === 'claude') {
return await testClaude(apiKey, baseUrl, model);
}
// OpenAI 兼容格式的测试
return await testOpenAICompat(apiKey, baseUrl, model);
@@ -149,3 +154,43 @@ async function testResponsesAPI(apiKey, baseUrl, model) {
reply: reply.trim(),
});
}
async function testClaude(apiKey, baseUrl, model) {
const base = (baseUrl || 'https://api.anthropic.com').replace(/\/$/, '');
const m = model || 'claude-sonnet-4-20250514';
const url = `${base}/v1/messages`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: m,
max_tokens: 20,
messages: [{ role: 'user', content: '说"连接成功"' }],
}),
});
if (!response.ok) {
const errText = await response.text();
let errMsg = `连接失败(${response.status})`;
try {
const errObj = JSON.parse(errText);
errMsg = errObj?.error?.message || errMsg;
} catch { /* ignore parse error */ }
return NextResponse.json({ success: false, error: errMsg });
}
const data = await response.json();
const reply = data.content?.[0]?.text || '';
return NextResponse.json({
success: true,
message: `✅ Claude API 连接成功!`,
model: m,
reply: reply.trim(),
});
}
+5 -4
View File
@@ -287,7 +287,8 @@ export default function AiSidebar({ onInsertText }) {
const { apiConfig } = getProjectSettings();
const apiEndpoint = apiConfig?.provider === 'gemini-native' ? '/api/ai/gemini'
: apiConfig?.provider === 'openai-responses' ? '/api/ai/responses'
: '/api/ai';
: apiConfig?.provider === 'claude' ? '/api/ai/claude'
: '/api/ai';
const context = await buildContext(activeChapterId, text, contextSelection.size > 0 ? contextSelection : null);
const systemPrompt = compileSystemPrompt(context, 'chat');
@@ -353,7 +354,8 @@ export default function AiSidebar({ onInsertText }) {
const { apiConfig } = getProjectSettings();
const apiEndpoint = apiConfig?.provider === 'gemini-native' ? '/api/ai/gemini'
: apiConfig?.provider === 'openai-responses' ? '/api/ai/responses'
: '/api/ai';
: apiConfig?.provider === 'claude' ? '/api/ai/claude'
: '/api/ai';
const context = await buildContext(activeChapterId, userMsg.content, contextSelection.size > 0 ? contextSelection : null);
const systemPrompt = compileSystemPrompt(context, 'chat');
@@ -669,7 +671,7 @@ export default function AiSidebar({ onInsertText }) {
if (!open) return null;
return (
<div className="ai-sidebar" style={{ position: 'absolute', top: 0, right: 0, bottom: 0 }}>
<div className="ai-sidebar">
{/* 标题栏 */}
<div className="ai-sidebar-header">
<span className="ai-sidebar-title">{t('aiSidebar.title')}</span>
@@ -997,7 +999,6 @@ export default function AiSidebar({ onInsertText }) {
// 提取正文:去掉系统块、markdown标记、编辑点评
let text = (msg.content || '')
.replace(/\[SETTINGS_ACTION\][\s\S]*?\[\\?\/SETTINGS_ACTION\]/g, '')
.replace(/\n---[\s\S]*$/g, '') // 去掉 --- 分隔线以后的编辑点评
.replace(/^#{1,6}\s+/gm, '') // 去掉标题 #
.replace(/\*\*(.+?)\*\*/g, '$1') // **粗体** → 粗体
.replace(/\*(.+?)\*/g, '$1') // *斜体* → 斜体
+4 -3
View File
@@ -156,8 +156,9 @@ const Editor = forwardRef(function Editor({ content, onUpdate, editable = true,
}
}, [editor]);
// 通过 ref 暴露插入方法给父组件(侧栏存档插入用)
// 通过 ref 暴露方法给父组件(侧栏存档插入 + 大纲读取用)
useImperativeHandle(ref, () => ({
getEditor: () => editor,
insertText: (text) => {
if (!editor) return;
// 规范化换行
@@ -1093,7 +1094,7 @@ function EditorToolbar({ editor, margins, setMargins }) {
Aa <span className="dropdown-arrow">▾</span>
</button>
{showTypeset && (
<div className="typeset-popover" style={{ position: 'absolute', top: '100%', right: 0, marginTop: 4, zIndex: 120 }}>
<div className="typeset-popover" style={{ position: 'absolute', top: '100%', bottom: 'auto', right: 0, marginTop: 4, zIndex: 120 }}>
<div className="typeset-row">
<label>字号</label>
<input
@@ -1130,7 +1131,7 @@ function EditorToolbar({ editor, margins, setMargins }) {
📄 <span className="dropdown-arrow">▾</span>
</button>
{showMargins && (
<div className="typeset-popover" style={{ position: 'absolute', top: '100%', right: 0, marginTop: 4, zIndex: 120 }}>
<div className="typeset-popover" style={{ position: 'absolute', top: '100%', bottom: 'auto', right: 0, marginTop: 4, zIndex: 120 }}>
<div className="typeset-row">
<label>上下</label>
<input
+2 -1
View File
@@ -102,7 +102,8 @@ export default function SettingsConflictModal({ conflicts, noConflicts, onConfir
const { apiConfig } = getProjectSettings();
const apiEndpoint = apiConfig?.provider === 'gemini-native' ? '/api/ai/gemini'
: apiConfig?.provider === 'openai-responses' ? '/api/ai/responses'
: '/api/ai';
: apiConfig?.provider === 'claude' ? '/api/ai/claude'
: '/api/ai';
const existingFields = JSON.stringify(conflict.existing.content || {}, null, 2);
const importedFields = JSON.stringify(conflict.imported.content || {}, null, 2);
+90 -3
View File
@@ -19,6 +19,7 @@ const CATEGORY_COLORS = {
function TextField({ label, value, onChange, placeholder, multiline = false, rows = 3, aiBtn = false }) {
const { t } = useI18n();
const [localValue, setLocalValue] = useState(value || '');
const [isExpanded, setIsExpanded] = useState(false);
const isComposingRef = useRef(false);
const timerRef = useRef(null);
const onChangeRef = useRef(onChange);
@@ -89,6 +90,16 @@ function TextField({ label, value, onChange, placeholder, multiline = false, row
}
}, []);
// 关闭展开模态框时立即 flush
const handleCloseExpand = useCallback(() => {
setIsExpanded(false);
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
onChangeRef.current(localValueRef.current);
}, []);
const inputProps = {
value: localValue,
onChange: handleChange,
@@ -103,9 +114,26 @@ function TextField({ label, value, onChange, placeholder, multiline = false, row
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)' }}>{label}</label>
{aiBtn && (
<button className="field-ai-btn" title={t('settingsEditor.aiFill')}>✦</button>
)}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{multiline && (
<button
className="field-expand-btn"
title="展开编辑"
onClick={() => setIsExpanded(true)}
style={{
border: 'none', background: 'transparent', cursor: 'pointer',
color: 'var(--text-muted)', fontSize: 13, padding: '2px 4px',
borderRadius: 4, transition: 'all 0.15s', display: 'flex',
alignItems: 'center', justifyContent: 'center',
}}
onMouseEnter={e => { e.target.style.color = 'var(--accent)'; e.target.style.background = 'var(--bg-hover)'; }}
onMouseLeave={e => { e.target.style.color = 'var(--text-muted)'; e.target.style.background = 'transparent'; }}
>⛶</button>
)}
{aiBtn && (
<button className="field-ai-btn" title={t('settingsEditor.aiFill')}>✦</button>
)}
</div>
</div>
{multiline ? (
<textarea
@@ -129,6 +157,65 @@ function TextField({ label, value, onChange, placeholder, multiline = false, row
}}
/>
)}
{/* 展开编辑浮窗 */}
{isExpanded && (
<div
className="field-expand-overlay"
onClick={handleCloseExpand}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadeIn 0.2s ease',
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 24px 80px rgba(0,0,0,0.25), 0 0 0 1px rgba(255,255,255,0.06)',
width: '80%', maxWidth: 900, maxHeight: '85vh',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
animation: 'settingsSlideUp 0.3s cubic-bezier(0.16,1,0.3,1)',
}}
>
<div style={{
padding: '16px 20px', borderBottom: '1px solid var(--border-light)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'var(--bg-secondary)',
}}>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
✏️ {label}
</span>
<button
onClick={handleCloseExpand}
className="btn btn-ghost btn-icon"
>✕</button>
</div>
<div style={{ flex: 1, padding: 16, overflow: 'hidden', display: 'flex' }}>
<textarea
value={localValue}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
placeholder={placeholder}
autoFocus
style={{
width: '100%', height: '100%', minHeight: '60vh',
padding: '12px 16px', border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-md)', background: 'var(--bg-primary)',
color: 'var(--text-primary)', fontSize: 14, fontFamily: 'var(--font-ui)',
resize: 'none', outline: 'none', lineHeight: 1.8,
transition: 'border-color 0.15s',
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-light)'}
/>
</div>
</div>
</div>
)}
</div>
);
}
+9 -2
View File
@@ -72,6 +72,7 @@ export default function SettingsPanel() {
const [expandedCategory, setExpandedCategory] = useState(null);
const [showExportFormat, setShowExportFormat] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// 删除确认弹窗状态
const [deleteConfirm, setDeleteConfirm] = useState(null); // { message, onConfirm }
@@ -552,14 +553,19 @@ export default function SettingsPanel() {
return (
<div className="settings-panel-overlay" onClick={onClose}>
<div className="settings-panel-container glass-panel" onClick={e => e.stopPropagation()}>
<div className={`settings-panel-container glass-panel${isFullscreen ? ' fullscreen' : ''}`} onClick={e => e.stopPropagation()}>
{/* 头部 */}
<div className="settings-header" style={{ background: 'transparent' }}>
<h2>
⚙️ {t('settings.title')}
<span className="subtitle">— {t('settings.subtitle')}</span>
</h2>
<button className="btn btn-ghost btn-icon" onClick={onClose}>✕</button>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<button className="btn btn-ghost btn-icon" onClick={() => setIsFullscreen(!isFullscreen)} title={isFullscreen ? '退出全屏' : '全屏'}>
{isFullscreen ? '⛶' : '⛶'}
</button>
<button className="btn btn-ghost btn-icon" onClick={onClose}>✕</button>
</div>
</div>
{/* 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: [] },
+293 -241
View File
@@ -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 (
<>
<aside className={`sidebar ${sidebarOpen ? '' : 'collapsed'}`}>
<div className="sidebar-header">
<div className="sidebar-logo">
<span>A</span>uthor
</div>
<button className="btn btn-ghost btn-icon" onClick={() => setSidebarOpen(false)} title={t('sidebar.collapseSidebar')}>
✕
{/* ===== 顶部关闭按钮 ===== */}
<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 style={{ padding: '12px 12px 0' }}>
<button
id="tour-new-chapter"
className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center' }}
onClick={handleCreateChapter}
>
{t('sidebar.newChapter')}
</button>
{/* ===== 文档分页 ===== */}
<div className="gdocs-section-header">
<span className="gdocs-section-title">文档分页</span>
<button className="gdocs-section-add" onClick={handleCreateChapter} title={t('sidebar.newChapter')}>+</button>
</div>
<div className="sidebar-content">
<div style={{ fontSize: '11px', color: 'var(--text-muted)', padding: '8px 14px 6px', textTransform: 'uppercase', letterSpacing: '1px' }}>
{t('sidebar.chapterList')} ({chapters.length})
</div>
<div className="chapter-list">
{chapters.map(ch => (
<div
key={ch.id}
className={`chapter-item ${ch.id === activeChapterId ? 'active' : ''}`}
onClick={() => setActiveChapterId(ch.id)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ id: ch.id, x: e.clientX, y: e.clientY });
}}
>
{renameId === ch.id ? (
<input
className="modal-input"
style={{ margin: 0, padding: '4px 8px', fontSize: '13px' }}
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="chapter-title">{ch.title}</span>
<span className="chapter-count">{ch.wordCount || 0}{t('sidebar.wordUnit')}</span>
<div className="chapter-actions">
<button
className="btn btn-ghost btn-icon btn-sm"
onClick={(e) => {
e.stopPropagation();
setRenameId(ch.id);
setRenameTitle(ch.title);
}}
title={t('common.rename')}
<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()}字 · ~{Math.ceil((ch.wordCount || 0) * 1.5).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}
>
✎
</button>
<button
className="btn btn-ghost btn-icon btn-sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteChapter(ch.id);
}}
title={t('common.delete')}
style={{ color: 'var(--error)' }}
>
✕
</button>
</div>
</>
{h.text}
</div>
))}
</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')}
>
<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>
@@ -262,18 +395,10 @@ export default function Sidebar() {
</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>
<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,
}}>
<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) },
@@ -286,123 +411,70 @@ export default function Sidebar() {
</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>
<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 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 className="btn btn-secondary btn-sm btn-icon" onClick={() => onOpenHelp?.()} title={t('page.helpAndGuide') || '帮助与教程'}>📖</button>
<button 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>
<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>
<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 = '';
}}
/>
<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>
{/* ===== 右键菜单 ===== */}
{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>
{/* ===== 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}
@@ -412,7 +484,6 @@ export default function Sidebar() {
try {
const existingChapters = await getChapters(targetWorkId);
if (existingChapters.length === 0) {
// 目标作品为空,直接导入
await saveChapters(importModal.chapters, targetWorkId);
setActiveWorkIdSetting(targetWorkId);
setChapters(importModal.chapters);
@@ -422,10 +493,8 @@ export default function Sidebar() {
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);
@@ -435,14 +504,7 @@ export default function Sidebar() {
showToast(t('sidebar.importWorkSuccess').replace('{count}', importModal.chapters.length), 'success');
setImportModal(null);
} else {
// 有冲突,弹出冲突解决弹窗
setConflictModal({
conflicts,
noConflictExisting,
noConflictImported,
targetWorkId,
importedCount: importModal.chapters.length,
});
setConflictModal({ conflicts, noConflictExisting, noConflictImported, targetWorkId, importedCount: importModal.chapters.length });
setImportModal(null);
}
} catch (err) {
@@ -452,18 +514,14 @@ export default function Sidebar() {
t={t}
/>
)}
{/* ===== 章节冲突解决弹窗 ===== */}
{/* ===== 章节冲突弹窗 ===== */}
{conflictModal && (
<ChapterConflictModal
conflicts={conflictModal.conflicts}
onClose={() => setConflictModal(null)}
onConfirm={async (resolvedConflicts) => {
try {
const merged = mergeChapters(
conflictModal.noConflictExisting,
conflictModal.noConflictImported,
resolvedConflicts
);
const merged = mergeChapters(conflictModal.noConflictExisting, conflictModal.noConflictImported, resolvedConflicts);
await saveChapters(merged, conflictModal.targetWorkId);
setActiveWorkIdSetting(conflictModal.targetWorkId);
setChapters(merged);
@@ -484,13 +542,7 @@ export default function Sidebar() {
chapters={chapters}
onClose={() => setShowExportModal(false)}
onExport={(selectedChapters, format) => {
const fns = {
txt: exportWorkAsTxt,
md: exportWorkAsMarkdown,
docx: exportWorkAsDocx,
epub: exportWorkAsEpub,
pdf: exportWorkAsPdf,
};
const fns = { txt: exportWorkAsTxt, md: exportWorkAsMarkdown, docx: exportWorkAsDocx, epub: exportWorkAsEpub, pdf: exportWorkAsPdf };
const fn = fns[format];
if (fn) fn(selectedChapters);
setShowExportModal(false);
+392 -25
View File
@@ -220,31 +220,117 @@ body {
.app-layout {
display: flex;
flex-direction: column;
/* Google Docs 风格:纵向布局 */
height: 100vh;
overflow: hidden;
position: relative;
}
/* ===== 顶栏(Google Docs 风格)===== */
.top-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: var(--bg-sidebar);
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
z-index: 40;
}
.top-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-toggle-btn {
font-size: 18px;
padding: 6px 8px;
}
.top-header-logo {
font-family: var(--font-writing);
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 1px;
}
.top-header-logo span {
color: var(--accent);
}
/* ===== 内容区(编辑器 + AI 侧栏)===== */
.content-row {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
/* 侧边栏:覆盖在 editor-container 上方,不覆盖工具栏 */
.sidebar {
position: absolute !important;
top: 0 !important;
left: 0 !important;
bottom: 0 !important;
width: 280px !important;
position: absolute;
top: var(--toolbar-h, 90px);
left: 0;
bottom: 0;
width: 280px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border-light);
display: flex;
flex-direction: column;
transition: transform var(--transition-normal);
z-index: 30 !important;
transform: translateX(0) !important;
transition: transform 0.25s ease, opacity 0.25s ease;
z-index: 25;
overflow: hidden;
transform: translateX(0);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.08);
}
.sidebar.collapsed {
transform: translateX(-100%) !important;
pointer-events: none !important;
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
/* 侧边栏顶部关闭行 */
.sidebar-top-row {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 4px 8px;
flex-shrink: 0;
}
/* ☰ 展开按钮(工具栏下方左侧浮动) */
.sidebar-open-btn {
position: absolute;
top: calc(var(--toolbar-h, 90px) + 4px);
left: 4px;
z-index: 20;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
transition: background 0.2s, color 0.2s;
}
.sidebar-open-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* 保留旧选择器以免其他地方使用 */
.sidebar-header {
padding: 20px 20px 16px;
border-bottom: 1px solid var(--border-light);
@@ -338,6 +424,264 @@ body {
display: none;
}
/* ==================== Google Docs 风格侧边栏 ==================== */
/* 区域标题栏 */
.gdocs-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px 4px;
flex-shrink: 0;
}
.gdocs-section-title {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
.gdocs-section-add {
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-secondary);
font-size: 18px;
font-weight: 300;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.gdocs-section-add:hover {
background: var(--bg-hover);
color: var(--accent);
}
/* Tab 列表 */
.gdocs-tab-list {
overflow-y: auto;
flex: 1;
min-height: 0;
padding: 0 6px;
display: flex;
flex-direction: column;
gap: 1px;
}
/* Tab 列表项 */
.gdocs-tab-item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 20px;
cursor: pointer;
color: var(--text-secondary);
font-size: 13px;
transition: background 0.15s, color 0.15s;
}
.gdocs-tab-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.gdocs-tab-item.active {
background: #e8f0fe;
color: #1967d2;
font-weight: 500;
}
[data-theme="dark"] .gdocs-tab-item.active {
background: rgba(138, 180, 248, 0.12);
color: #8ab4f8;
}
.gdocs-tab-icon {
font-size: 16px;
flex-shrink: 0;
}
.gdocs-tab-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gdocs-tab-badge {
font-size: 10px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--text-muted);
color: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-weight: 600;
}
.gdocs-tab-item.active .gdocs-tab-badge {
background: #1967d2;
color: #fff;
}
[data-theme="dark"] .gdocs-tab-item.active .gdocs-tab-badge {
background: #8ab4f8;
color: #202124;
}
/* 章节操作按钮组(直接显示在右侧) */
.gdocs-tab-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.gdocs-tab-item:hover .gdocs-tab-actions {
opacity: 1;
}
.gdocs-tab-action-btn {
width: 22px;
height: 22px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s, transform 0.1s;
padding: 0;
line-height: 1;
}
.gdocs-tab-action-btn:hover {
background: var(--bg-hover);
transform: scale(1.1);
}
.gdocs-tab-action-btn.danger:hover {
background: rgba(248, 113, 113, 0.15);
}
/* 章节分组容器(展开/收起) */
.gdocs-tab-group {
display: flex;
flex-direction: column;
}
/* ▶ 展开箭头 */
.gdocs-tab-arrow {
font-size: 8px;
flex-shrink: 0;
width: 16px;
text-align: center;
color: var(--text-muted);
transition: transform 0.15s ease;
}
/* 内联大纲列表 */
.gdocs-outline-inline {
display: flex;
flex-direction: column;
padding: 2px 0 4px;
}
/* ⋮ 弹出菜单 */
.gdocs-tab-dropdown {
position: absolute;
right: 0;
top: 100%;
margin-top: 2px;
min-width: 140px;
z-index: 100;
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 4px;
}
/* ===== 文档大纲区域 ===== */
.gdocs-outline-section {
border-top: 1px solid var(--border-light);
padding: 8px 0;
flex-shrink: 0;
max-height: 40%;
overflow-y: auto;
}
.gdocs-outline-section::before {
content: '大纲';
display: block;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
padding: 0 12px 4px;
}
.gdocs-outline-empty {
padding: 12px 16px;
font-size: 12px;
color: var(--text-muted);
font-style: italic;
line-height: 1.5;
}
.gdocs-outline-list {
display: flex;
flex-direction: column;
}
.gdocs-outline-item {
padding: 5px 12px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
border-left: 2px solid transparent;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.gdocs-outline-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.gdocs-outline-item.active {
color: #1967d2;
border-left-color: #1967d2;
font-weight: 500;
background: rgba(25, 103, 210, 0.04);
}
[data-theme="dark"] .gdocs-outline-item.active {
color: #8ab4f8;
border-left-color: #8ab4f8;
background: rgba(138, 180, 248, 0.06);
}
/* ==================== 主内容区 ==================== */
.main-content {
@@ -353,6 +697,8 @@ body {
border-bottom: 1px solid var(--border-light);
background: var(--bg-secondary);
display: flex;
position: relative;
z-index: 40;
align-items: center;
justify-content: center;
gap: 4px;
@@ -546,11 +892,15 @@ body {
align-items: flex-start;
padding: 40px 16px 80px;
background: var(--bg-canvas) !important;
transition: background var(--transition-normal);
transition: background var(--transition-normal), margin-right 0.3s ease;
/* 禁用滚动锚定,防止虚拟分页元素重排时导致原生滚动条跳动 */
overflow-anchor: none;
}
/* AI 侧栏打开时,仅编辑区域收缩(工具栏保持全宽,Google Docs 风格) */
/* 现已改用 flex 布局,不再需要手动 margin */
/* 统率背景卡片和文本的包裹层 */
.document-workspace {
position: relative;
@@ -1293,9 +1643,9 @@ body {
/* 排版设置浮层 */
.typeset-popover {
position: absolute;
bottom: 100%;
right: 16px;
margin-bottom: 6px;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
@@ -1776,6 +2126,19 @@ body {
overflow: hidden;
}
.settings-panel-container.fullscreen {
position: fixed;
inset: 0;
width: 100%;
max-width: 100%;
max-height: 100%;
height: 100%;
border-radius: 0;
box-shadow: none;
z-index: 1001;
animation: none;
}
@keyframes settingsSlideUp {
from {
opacity: 0;
@@ -3072,26 +3435,30 @@ body {
.ai-sidebar {
position: absolute;
top: 0;
right: 0;
top: var(--toolbar-h, 42px);
bottom: 0;
width: 380px;
background: var(--bg-primary);
border-left: 1px solid var(--border-light);
display: flex;
flex-direction: column;
z-index: 30;
transform: translateX(0);
transition: transform var(--transition-normal), visibility 0s linear 0s;
z-index: 35;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.06);
visibility: visible;
transition: transform 0.3s ease, opacity 0.3s ease;
transform: translateX(0);
}
/* AI 侧栏打开时,编辑区右移让出滚动条 */
.app-layout.ai-open .editor-container {
margin-right: 380px;
transition: margin-right 0.3s ease;
}
.ai-sidebar.collapsed {
transform: translateX(100%);
opacity: 0;
pointer-events: none;
visibility: hidden;
transition: transform var(--transition-normal), visibility 0s linear 0.3s;
}
@keyframes slideInRight {
@@ -4356,14 +4723,14 @@ h5.chat-md-heading {
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.ai-sidebar {
position: fixed;
right: 0;
top: 0;
bottom: 0;
z-index: 50;
box-shadow: var(--shadow-lg);
width: 320px;
}
.app-layout.ai-open .editor-container {
margin-right: 320px;
}
}
/* ==================== 引导向导 (Tour Overlay) ==================== */
+88 -280
View File
@@ -34,7 +34,6 @@ const WelcomeModal = dynamic(() => import('./components/WelcomeModal'), { ssr: f
const UpdateBanner = dynamic(() => import('./components/UpdateBanner'), { ssr: false });
export default function Home() {
const [showGitMenu, setShowGitMenu] = useState(false);
const {
chapters, setChapters, addChapter, updateChapter: updateChapterStore,
activeChapterId, setActiveChapterId,
@@ -56,15 +55,32 @@ export default function Home() {
const { t } = useI18n();
const [showHelp, setShowHelp] = useState(false);
const [fabPositions, setFabPositions] = useState({ github: { right: 24, bottom: 76 }, help: { right: 24, bottom: 24 } });
const editorRef = useRef(null);
// 加载浮动按钮位置
// 监听工具栏高度,设置 CSS 变量供侧边栏定位使用
useEffect(() => {
const gh = JSON.parse(localStorage.getItem('author-fab-github') || 'null');
const hp = JSON.parse(localStorage.getItem('author-fab-help') || 'null');
if (gh || hp) setFabPositions(prev => ({ github: gh || prev.github, help: hp || prev.help }));
}, []);
const updateToolbarHeight = () => {
const toolbar = document.querySelector('.editor-toolbar');
const main = document.querySelector('.main-content');
if (toolbar && main) {
const h = toolbar.offsetHeight + 'px';
main.style.setProperty('--toolbar-h', h);
document.documentElement.style.setProperty('--toolbar-h', h);
}
};
const observer = new ResizeObserver(updateToolbarHeight);
const tryObserve = () => {
const toolbar = document.querySelector('.editor-toolbar');
if (toolbar) {
observer.observe(toolbar);
updateToolbarHeight();
} else {
requestAnimationFrame(tryObserve);
}
};
tryObserve();
return () => observer.disconnect();
}, [activeChapterId]);
// 派生:当前活动会话和消息列表
const activeSession = useMemo(() => getActiveSession(sessionStore), [sessionStore]);
@@ -209,7 +225,8 @@ export default function Home() {
const { apiConfig } = getProjectSettings();
const apiEndpoint = apiConfig?.provider === 'gemini-native' ? '/api/ai/gemini'
: apiConfig?.provider === 'openai-responses' ? '/api/ai/responses'
: '/api/ai';
: apiConfig?.provider === 'claude' ? '/api/ai/claude'
: '/api/ai';
const res = await fetch(apiEndpoint, {
method: 'POST',
@@ -313,288 +330,79 @@ export default function Home() {
}, [showToast]);
return (
<div className="app-layout">
<div className={`app-layout${aiSidebarOpen ? ' ai-open' : ''}`}>
{/* ===== 更新提示 ===== */}
<UpdateBanner />
{/* ===== 侧边栏 ===== */}
<Sidebar />
{/* ===== 主内容 ===== */}
<main className="main-content">
{!sidebarOpen && (
<button
className="btn btn-ghost btn-icon"
style={{
position: 'absolute',
top: '10px',
left: '10px',
zIndex: 10,
}}
onClick={() => setSidebarOpen(true)}
title={t('page.expandSidebar')}
>
☰
</button>
)}
{activeChapter ? (
<Editor
id="tour-editor"
ref={editorRef}
key={activeChapterId}
content={activeChapter.content}
onUpdate={handleEditorUpdate}
onAiRequest={handleInlineAiRequest}
onArchiveGeneration={handleArchiveGeneration}
contextItems={contextItems}
contextSelection={contextSelection}
setContextSelection={setContextSelection}
/>
) : (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-muted)',
fontSize: '16px',
}}>
{t('page.noChapterHint')}
{/* ===== 顶栏(Google Docs 风格,全宽,只含 Logo)===== */}
<header className="top-header-bar">
<div className="top-header-left">
<div className="top-header-logo">
<span>A</span>uthor
</div>
)}
{/* AI 侧栏浮动开关 */}
{!aiSidebarOpen && (
<button
id="tour-ai-btn"
className="ai-sidebar-toggle"
onClick={() => setAiSidebarOpen(true)}
title={t('page.openAiAssistant')}
>
✦
</button>
)}
</div>
</header>
{/* 独立可拖动浮动按钮 */}
{['github', 'help'].map(btnKey => {
const storageKey = `author-fab-${btnKey}`;
const pos = fabPositions[btnKey];
const makeDraggable = (e) => {
const el = e.currentTarget;
const parentRect = el.parentElement.getBoundingClientRect();
const rect = el.getBoundingClientRect();
const startX = e.clientX, startY = e.clientY;
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
let dragging = false;
const onMove = (ev) => {
if (!dragging && Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) < 5) return;
dragging = true;
ev.preventDefault();
const r = Math.max(0, Math.min(parentRect.width - rect.width, parentRect.right - ev.clientX - (rect.width - offsetX)));
const b = Math.max(0, Math.min(parentRect.height - rect.height, parentRect.bottom - ev.clientY - (rect.height - offsetY)));
el.style.right = `${r}px`;
el.style.bottom = `${b}px`;
};
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
if (dragging) {
el.dataset.justDragged = '1';
localStorage.setItem(storageKey, JSON.stringify({ right: parseInt(el.style.right), bottom: parseInt(el.style.bottom) }));
setTimeout(() => delete el.dataset.justDragged, 0);
}
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
};
const commonStyle = {
position: 'absolute',
right: `${pos.right}px`,
bottom: `${pos.bottom}px`,
zIndex: 40,
borderRadius: '50%',
width: '44px',
height: '44px',
boxShadow: 'var(--shadow-md)',
fontSize: '18px',
opacity: 0.8,
cursor: 'grab',
userSelect: 'none',
touchAction: 'none',
};
if (btnKey === 'github') {
const gitBtnRef = { current: null };
return (
<div
key="github"
style={{ ...commonStyle, touchAction: 'none' }}
onPointerDown={(e) => {
// Use the same drag logic but on this outer container
const el = e.currentTarget;
const parentRect = el.parentElement.getBoundingClientRect();
const rect = el.getBoundingClientRect();
const startX = e.clientX, startY = e.clientY;
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
let dragging = false;
const onMove = (ev) => {
if (!dragging && Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) < 5) return;
dragging = true;
ev.preventDefault();
const r = Math.max(0, Math.min(parentRect.width - rect.width, parentRect.right - ev.clientX - (rect.width - offsetX)));
const b = Math.max(0, Math.min(parentRect.height - rect.height, parentRect.bottom - ev.clientY - (rect.height - offsetY)));
el.style.right = `${r}px`;
el.style.bottom = `${b}px`;
};
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
if (dragging) {
el.dataset.justDragged = '1';
localStorage.setItem(storageKey, JSON.stringify({ right: parseInt(el.style.right), bottom: parseInt(el.style.bottom) }));
setTimeout(() => delete el.dataset.justDragged, 0);
}
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
}}
onMouseEnter={(e) => e.currentTarget.style.opacity = '1'}
onMouseLeave={(e) => e.currentTarget.style.opacity = '0.8'}
>
{/* Dropdown popup */}
{showGitMenu && (
<>
<div
style={{ position: 'fixed', inset: 0, zIndex: 39, cursor: 'default' }}
onClick={() => setShowGitMenu(false)}
onPointerDown={(e) => e.stopPropagation()}
/>
<div
className="git-menu-popup"
onPointerDown={(e) => e.stopPropagation()}
style={{
position: 'absolute',
bottom: '52px',
right: 0,
background: 'var(--bg-card)',
borderRadius: '12px',
boxShadow: 'var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.18))',
border: '1px solid var(--border-color)',
padding: '6px',
minWidth: '180px',
zIndex: 41,
animation: 'fadeInUp 0.18s ease',
}}
>
<a
href="https://github.com/YuanShiJiLoong/author"
target="_blank"
rel="noopener noreferrer"
className="git-menu-item"
onClick={() => setShowGitMenu(false)}
style={{
display: 'flex', alignItems: 'center', gap: '10px',
padding: '10px 14px', borderRadius: '8px',
textDecoration: 'none', color: 'var(--text-primary)',
fontSize: '14px', fontWeight: 500,
transition: 'background 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-hover, rgba(0,0,0,0.05))'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<svg width="18" height="18" 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>
GitHub
</a>
<a
href="https://gitee.com/yuanshijilong/author"
target="_blank"
rel="noopener noreferrer"
className="git-menu-item"
onClick={() => setShowGitMenu(false)}
style={{
display: 'flex', alignItems: 'center', gap: '10px',
padding: '10px 14px', borderRadius: '8px',
textDecoration: 'none', color: 'var(--text-primary)',
fontSize: '14px', fontWeight: 500,
transition: 'background 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-hover, rgba(0,0,0,0.05))'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<svg width="18" height="18" 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>
Gitee(国内镜像)
</a>
<div style={{ height: '1px', background: 'var(--border-color)', margin: '4px 8px' }} />
<a
href="https://qm.qq.com/q/wjRDkotw0E"
target="_blank"
rel="noopener noreferrer"
className="git-menu-item"
onClick={(e) => {
navigator.clipboard?.writeText('1087016949');
setShowGitMenu(false);
}}
style={{
display: 'flex', alignItems: 'center', gap: '10px',
padding: '10px 14px', borderRadius: '8px',
textDecoration: 'none', color: 'var(--text-primary)',
fontSize: '14px', fontWeight: 500,
transition: 'background 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-hover, rgba(0,0,0,0.05))'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<svg width="18" height="18" 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>
QQ群:1087016949
</a>
</div>
</>
)}
{/* FAB button */}
<div
id="tour-github"
className="btn btn-secondary btn-icon"
draggable={false}
onDragStart={(e) => e.preventDefault()}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '44px', height: '44px', borderRadius: '50%', cursor: 'grab', userSelect: 'none' }}
onClick={(e) => { if (!e.currentTarget.parentElement.dataset.justDragged) setShowGitMenu(prev => !prev); }}
title="GitHub / Gitee / QQ群"
>
<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>
</div>
</div>
);
}
return (
{/* ===== 内容区域(编辑器 + AI 侧栏)===== */}
<div className="content-row">
{/* ===== 主内容 ===== */}
<main className="main-content">
{activeChapter ? (
<Editor
id="tour-editor"
ref={editorRef}
key={activeChapterId}
content={activeChapter.content}
onUpdate={handleEditorUpdate}
onAiRequest={handleInlineAiRequest}
onArchiveGeneration={handleArchiveGeneration}
contextItems={contextItems}
contextSelection={contextSelection}
setContextSelection={setContextSelection}
/>
) : (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-muted)',
fontSize: '16px',
}}>
{t('page.noChapterHint')}
</div>
)}
{/* ===== 侧边栏(覆盖在编辑器画布上,不覆盖工具栏)===== */}
<Sidebar onOpenHelp={() => setShowHelp(true)} onToggle={() => setSidebarOpen(!sidebarOpen)} editorRef={editorRef} />
{/* 侧边栏展开按钮(编辑器画布左上角) */}
{!sidebarOpen && (
<button
key="help"
id="tour-help"
className="btn btn-secondary btn-icon"
style={commonStyle}
onPointerDown={makeDraggable}
onClick={(e) => { if (!e.currentTarget.dataset.justDragged) setShowHelp(true); }}
onMouseEnter={(e) => e.currentTarget.style.opacity = '1'}
onMouseLeave={(e) => e.currentTarget.style.opacity = '0.8'}
title={t('page.helpAndGuide')}
className="sidebar-open-btn"
onClick={() => setSidebarOpen(true)}
title={t('page.expandSidebar')}
>
📖
☰
</button>
);
})}
</main>
)}
{/* ===== AI 对话侧栏 ===== */}
<AiSidebar onInsertText={handleInsertFromArchive} />
{/* AI 侧栏浮动开关 */}
{!aiSidebarOpen && (
<button
id="tour-ai-btn"
className="ai-sidebar-toggle"
onClick={() => setAiSidebarOpen(true)}
title={t('page.openAiAssistant')}
>
✦
</button>
)}
</main>
{/* ===== AI 对话侧栏 ===== */}
<AiSidebar onInsertText={handleInsertFromArchive} />
</div>
{/* ===== Toast 通知 ===== */}
{toast && (
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "author-app",
"version": "1.1.7",
"version": "1.1.8",
"private": true,
"main": "electron/main.js",
"scripts": {