v1.1.8: feat: settings fullscreen + field expand modal, Claude API support, UI fixes
This commit is contained in:
1 parent
ec21b80ba6
commit
82014ae64c
12 files changed
+1124
-560
No files matched your search
@@ -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' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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') // *斜体* → 斜体
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "author-app",
|
||||
"version": "1.1.7",
|
||||
"version": "1.1.8",
|
||||
"private": true,
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
|
||||
Reference in new issue
Block a user