'use client'; import { useState, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useAppStore } from '../store/useAppStore'; import { getProjectSettings, saveProjectSettings, getSettingsNodes, addSettingsNode, updateSettingsNode, deleteSettingsNode, WRITING_MODES, getWritingMode, setWritingMode, createWorkNode, saveSettingsNodes, getActiveWorkId, setActiveWorkId, getAllWorks, rebuildAllEmbeddings, } from '../lib/settings'; import SettingsTree from './SettingsTree'; import { useI18n } from '../lib/useI18n'; import SettingsItemEditor from './SettingsItemEditor'; import { downloadFile, downloadBlob } from '../lib/project-io'; import { detectCategory, parseTextToFields, mapFieldsToContent, parseMultipleEntries, isStructuredText, parseStructuredText, preprocessPdfText, exportNodesToTxt, exportNodesToMarkdown, exportNodesToDocx, exportSettingsAsPdf, parseDocxToText, parsePdfToText, } from '../lib/settings-io'; import SettingsConflictModal from './SettingsConflictModal'; const CAT_STYLES = { work: { color: 'var(--cat-work)', bg: 'var(--cat-work-bg)', icon: '📕' }, character: { color: 'var(--cat-character)', bg: 'var(--cat-character-bg)', icon: '👤' }, location: { color: 'var(--cat-location)', bg: 'var(--cat-location-bg)', icon: '🗺️' }, world: { color: 'var(--cat-world)', bg: 'var(--cat-world-bg)', icon: '🌍' }, object: { color: 'var(--cat-object)', bg: 'var(--cat-object-bg)', icon: '🔮' }, plot: { color: 'var(--cat-plot)', bg: 'var(--cat-plot-bg)', icon: '📋' }, rules: { color: 'var(--cat-rules)', bg: 'var(--cat-rules-bg)', icon: '📐' }, }; export default function SettingsPanel() { const { showSettings: open, setShowSettings, setWritingMode: setGlobalWritingMode, incrementSettingsVersion, jumpToNodeId, setJumpToNodeId, } = useAppStore(); const onClose = () => { setShowSettings(false); setGlobalWritingMode(getWritingMode()); incrementSettingsVersion(); }; const [settings, setSettings] = useState(null); const [activeTab, setActiveTab] = useState('settings'); const [nodes, setNodes] = useState([]); const [selectedNodeId, setSelectedNodeId] = useState(null); const [writingMode, setWritingModeState] = useState('webnovel'); const [searchQuery, setSearchQuery] = useState(''); const [activeWorkId, setActiveWorkIdState] = useState(null); const [showNewWorkInput, setShowNewWorkInput] = useState(false); const [newWorkName, setNewWorkName] = useState(''); const { t } = useI18n(); const [expandedCategory, setExpandedCategory] = useState(null); const [showExportFormat, setShowExportFormat] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); // 删除确认弹窗状态 const [deleteConfirm, setDeleteConfirm] = useState(null); // { message, onConfirm } // 检查是否应跳过删除确认 const shouldSkipDeleteConfirm = () => { try { if (localStorage.getItem('author-delete-never-remind') === 'true') return true; const skipDate = localStorage.getItem('author-delete-skip-today'); if (skipDate && skipDate === new Date().toISOString().slice(0, 10)) return true; } catch { /* ignore */ } return false; }; // 获取当前作品的节点 useEffect(() => { if (open) { setSettings(getProjectSettings()); const loadNodes = async () => { const allNodes = await getSettingsNodes(); setNodes(allNodes); setWritingModeState(getWritingMode()); setSearchQuery(''); // 初始化激活作品 let wid = getActiveWorkId(); if (!wid || !allNodes.find(n => n.id === wid)) { const firstWork = allNodes.find(n => n.type === 'work'); wid = firstWork?.id || null; if (wid) setActiveWorkId(wid); } setActiveWorkIdState(wid); // 跳转到指定节点 if (jumpToNodeId) { setActiveTab('settings'); setSelectedNodeId(jumpToNodeId); setJumpToNodeId(null); } }; loadNodes(); } }, [open]); // 所有作品列表 const works = useMemo(() => getAllWorks(nodes), [nodes]); // 当前作品下的节点 const visibleNodes = useMemo(() => { if (!activeWorkId) return nodes; // 递归收集当前作品的所有后代 id const workDescendants = new Set(); const collectDescendants = (parentId) => { nodes.filter(n => n.parentId === parentId).forEach(n => { workDescendants.add(n.id); collectDescendants(n.id); }); }; workDescendants.add(activeWorkId); collectDescendants(activeWorkId); return nodes.filter(n => workDescendants.has(n.id)); }, [nodes, activeWorkId]); const stats = useMemo(() => { const items = visibleNodes.filter(n => n.type === 'item'); return Object.entries(CAT_STYLES).filter(([cat]) => cat !== 'work').map(([cat, style]) => ({ category: cat, count: items.filter(n => n.category === cat).length, label: t(`settings.categories.${cat}`), ...style, })); }, [visibleNodes, t]); // 作品管理 const handleSwitchWork = (workId) => { setActiveWorkIdState(workId); setActiveWorkId(workId); // 同步 Zustand store,触发 page.js 重载章节 useAppStore.getState().setActiveWorkId(workId); setSelectedNodeId(null); }; const handleCreateWork = async () => { const name = newWorkName.trim(); if (!name) return; const { workNode, subNodes } = createWorkNode(name); const updatedNodes = [...nodes, workNode, ...subNodes]; await saveSettingsNodes(updatedNodes); setNodes(updatedNodes); setActiveWorkIdState(workNode.id); setActiveWorkId(workNode.id); // 同步 Zustand store,触发 page.js 重载章节 useAppStore.getState().setActiveWorkId(workNode.id); setNewWorkName(''); setShowNewWorkInput(false); setSelectedNodeId(null); }; const handleDeleteWork = async (workId) => { const work = nodes.find(n => n.id === workId); if (!work) return; if (works.length <= 1) { alert(t('settings.deleteWorkAlert')); return; } if (shouldSkipDeleteConfirm()) { await doDeleteWork(workId); } else { return new Promise((resolve) => { setDeleteConfirm({ message: t('settings.deleteWorkPrompt').replace('{name}', work.name), onConfirm: async () => { setDeleteConfirm(null); await doDeleteWork(workId); resolve(); }, onCancel: () => { setDeleteConfirm(null); resolve(); }, }); }); } }; const doDeleteWork = async (workId) => { // 递归删除作品及其所有后代 const toDelete = new Set(); const collect = (pid) => { toDelete.add(pid); nodes.filter(n => n.parentId === pid).forEach(n => collect(n.id)); }; collect(workId); const updatedNodes = nodes.filter(n => !toDelete.has(n.id)); await saveSettingsNodes(updatedNodes); setNodes(updatedNodes); // 切换到第一个存活的作品 const nextWork = updatedNodes.find(n => n.type === 'work'); if (nextWork) { setActiveWorkIdState(nextWork.id); setActiveWorkId(nextWork.id); } setSelectedNodeId(null); }; // 一键清空当前作品的所有条目(保留文件夹结构) const handleClearAllItems = async () => { if (!activeWorkId) return; const workNode = nodes.find(n => n.id === activeWorkId); const workName = workNode?.name || ''; // 统计当前作品下的 item 数量 const workDescendants = new Set(); const collectWork = (pid) => { nodes.filter(n => n.parentId === pid).forEach(n => { workDescendants.add(n.id); collectWork(n.id); }); }; workDescendants.add(activeWorkId); collectWork(activeWorkId); const itemCount = nodes.filter(n => workDescendants.has(n.id) && n.type === 'item').length; if (itemCount === 0) return; const msg = t('settings.clearAllPrompt').replace('{name}', workName).replace('{count}', itemCount); setDeleteConfirm({ message: msg, onConfirm: async () => { setDeleteConfirm(null); const updatedNodes = nodes.filter(n => !(workDescendants.has(n.id) && n.type === 'item')); await saveSettingsNodes(updatedNodes); setNodes(updatedNodes); setSelectedNodeId(null); }, onCancel: () => setDeleteConfirm(null), }); }; // 收集当前作品的所有节点 const getWorkNodes = () => { if (!activeWorkId) return []; const workDescendants = new Set(); const collect = (parentId) => { nodes.filter(n => n.parentId === parentId).forEach(n => { workDescendants.add(n.id); collect(n.id); }); }; workDescendants.add(activeWorkId); collect(activeWorkId); return nodes.filter(n => workDescendants.has(n.id)); }; // 导出当前作品的设定集 const handleExportSettings = async (format = 'json') => { if (!activeWorkId) return; const workNode = nodes.find(n => n.id === activeWorkId); if (!workNode) return; const workNodes = getWorkNodes(); const baseName = workNode.name || '设定集'; setShowExportFormat(false); if (format === 'txt') { const txt = exportNodesToTxt(workNodes); await downloadFile(txt, `${baseName}-设定集.txt`, 'text/plain'); } else if (format === 'md') { const md = exportNodesToMarkdown(workNodes); await downloadFile(md, `${baseName}-设定集.md`, 'text/markdown'); } else if (format === 'docx') { const blob = await exportNodesToDocx(workNodes); await downloadBlob(blob, `${baseName}-设定集.docx`, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); } else if (format === 'pdf') { exportSettingsAsPdf(workNodes); } else { // JSON 格式 const exportNodes = workNodes.map(({ embedding, ...rest }) => rest); const projectSettings = getProjectSettings(); const data = { type: 'author-settings-export', version: 2, workName: workNode.name, exportedAt: new Date().toISOString(), nodes: exportNodes, writingMode: projectSettings.writingMode || 'webnovel', }; await downloadFile(JSON.stringify(data, null, 2), `${baseName}-设定集.json`, 'application/json'); } }; // 导入设定集 const handleImportSettings = async (e) => { const file = e.target.files?.[0]; if (!file) return; e.target.value = ''; const ext = file.name.split('.').pop().toLowerCase(); // 先把文件转换为纯文本 let text; if (ext === 'json') { text = await file.text(); } else if (ext === 'docx') { text = await parseDocxToText(file); } else if (ext === 'pdf') { text = await parsePdfToText(file); text = preprocessPdfText(text); // 恢复标题结构 } else { text = await file.text(); } // JSON 导入 if (ext === 'json') { try { const data = JSON.parse(text); if (data.type !== 'author-settings-export' || !Array.isArray(data.nodes)) { alert(t('settings.importInvalidFile')); return; } const importedNodes = data.nodes; const workNode = importedNodes.find(n => n.type === 'work'); if (!workNode) { alert(t('settings.importNoWork')); return; } const restorePS = () => { if (data.writingMode) { const ps = getProjectSettings(); if (data.writingMode) ps.writingMode = data.writingMode; saveProjectSettings(ps); setSettings(ps); if (data.writingMode) { setWritingModeState(data.writingMode); setWritingMode(data.writingMode); } } // 兼容旧版导出:如果 JSON 中有 bookInfo,写入作品的 bookInfo 节点 if (data.bookInfo && Object.values(data.bookInfo).some(v => v)) { const importedNodes = data.nodes; const importedWork = importedNodes.find(n => n.type === 'work'); if (importedWork) { const biNode = importedNodes.find(n => n.parentId === importedWork.id && n.category === 'bookInfo'); if (biNode && (!biNode.content || Object.keys(biNode.content).length === 0)) { biNode.content = data.bookInfo; } } } }; const existingWork = nodes.find(n => n.type === 'work' && n.name === workNode.name); if (existingWork) { if (!confirm((t('settings.importOverwrite')).replace('{name}', workNode.name))) return; const toDelete = new Set(); const collectDel = (pid) => { toDelete.add(pid); nodes.filter(n => n.parentId === pid).forEach(n => collectDel(n.id)); }; collectDel(existingWork.id); const merged = [...nodes.filter(n => !toDelete.has(n.id)), ...importedNodes]; await saveSettingsNodes(merged); setNodes(merged); restorePS(); handleSwitchWork(workNode.id); } else { const merged = [...nodes, ...importedNodes]; await saveSettingsNodes(merged); setNodes(merged); restorePS(); handleSwitchWork(workNode.id); } } catch (err) { alert((t('settings.importError')) + err.message); } return; } // TXT / MD / DOCX / PDF 智能导入 try { if (!activeWorkId) { alert(t('settings.importNoWork')); return; } console.log('[Settings Import] activeWorkId:', activeWorkId); console.log('[Settings Import] text length:', text?.length, 'first 500 chars:', text?.substring(0, 500)); console.log('[Settings Import] isStructured:', isStructuredText(text)); // 解析文本为条目列表 [{name, category, content}] let importedItems = []; if (isStructuredText(text)) { const parsedEntries = parseStructuredText(text); console.log('[Settings Import] structured entries:', parsedEntries.length, parsedEntries.map(e => e.name)); for (const entry of parsedEntries) { const mapped = mapFieldsToContent(entry.fields, entry.category); const nodeName = mapped.name || entry.name || '导入条目'; if (Object.keys(mapped.content).length === 0) continue; importedItems.push({ name: nodeName, category: entry.category, content: mapped.content }); } } else { const blocks = parseMultipleEntries(text); console.log('[Settings Import] unstructured blocks:', blocks.length); for (const block of blocks) { const parsed = parseTextToFields(block); if (Object.keys(parsed).length === 0) continue; const category = detectCategory(block); const mapped = mapFieldsToContent(parsed, category); const nodeName = mapped.name || Object.values(parsed)[0]?.substring(0, 20) || '导入条目'; importedItems.push({ name: nodeName, category, content: mapped.content }); } } console.log('[Settings Import] importedItems:', importedItems.length, importedItems.map(i => i.name)); if (importedItems.length === 0) { alert(t('settings.importEmpty') || '未能从文件中解析出任何设定条目'); return; } // 检测冲突(同名 + 同分类) const existingItems = nodes.filter(n => n.type === 'item' && n.parentId); console.log('[Settings Import] existingItems in activeWork:', existingItems.filter(n => nodes.find(p => p.id === n.parentId && (p.parentId === activeWorkId || p.id === activeWorkId)) ).map(n => `${n.name}(${n.category})`)); const conflicts = []; const noConflicts = []; for (const item of importedItems) { const existing = existingItems.find(n => n.name === item.name && n.category === item.category && nodes.find(p => p.id === n.parentId && (p.parentId === activeWorkId || p.id === activeWorkId)) ); console.log('[Settings Import] checking:', item.name, 'cat:', item.category, '→', existing ? 'CONFLICT' : 'new'); if (existing) { conflicts.push({ name: item.name, category: item.category, existing, imported: item }); } else { noConflicts.push(item); } } console.log('[Settings Import] conflicts:', conflicts.length, 'noConflicts:', noConflicts.length); if (conflicts.length > 0) { // 有冲突 → 显示冲突弹窗 console.log('[Settings Import] SHOWING CONFLICT MODAL with', conflicts.length, 'conflicts'); setConflictData({ conflicts, noConflicts }); return; // 不继续执行后续逻辑 } else { // 无冲突 → 直接导入 await doImportItems(noConflicts, []); } } catch (err) { alert((t('settings.importError')) + err.message); } }; // 冲突解决状态 const [conflictData, setConflictData] = useState(null); // 查找分类对应的父文件夹 const catSuffixMap = { character: 'characters', location: 'locations', object: 'objects', world: 'world', plot: 'plot', rules: 'rules', }; const findParentFolder = (category) => { const suffix = catSuffixMap[category] || category; let parentId = nodes.find(n => n.parentId === activeWorkId && n.id.endsWith('-' + suffix))?.id; if (!parentId) { parentId = nodes.find(n => n.parentId === activeWorkId && n.category === category)?.id; } return parentId || activeWorkId; }; // 执行导入 const doImportItems = async (items, updates) => { let updatedNodes = [...nodes]; // 处理冲突解决的更新 for (const up of updates) { updatedNodes = updatedNodes.map(n => { if (n.id === up.nodeId) { return { ...n, content: up.content, name: up.name || n.name, updatedAt: new Date().toISOString() }; } return n; }); } // 添加新条目 let importedCount = 0; for (const item of items) { const parentId = findParentFolder(item.category); const nodeId = Date.now().toString(36) + Math.random().toString(36).substr(2, 6) + importedCount; updatedNodes.push({ id: nodeId, name: item.name, type: 'item', category: item.category, parentId, order: importedCount, icon: '📄', content: item.content, collapsed: false, enabled: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); importedCount++; } await saveSettingsNodes(updatedNodes); setNodes(updatedNodes); const totalCount = items.length + updates.length; alert((t('settings.importTextSuccess') || '成功导入 {count} 个设定条目').replace('{count}', totalCount)); }; // 冲突解决确认 const handleConflictConfirm = async (resolvedUpdates, noConflictItems) => { setConflictData(null); await doImportItems(noConflictItems, resolvedUpdates); }; if ((!open && !conflictData) || !settings) return null; const handleSettingsSave = (section, data) => { const newSettings = { ...settings, [section]: data }; setSettings(newSettings); saveProjectSettings(newSettings); }; // 节点操作 const handleAddNode = async (parentId, category) => { const parent = parentId ? nodes.find(n => n.id === parentId) : null; let cat = category || (parent ? parent.category : 'custom'); // 如果父节点是作品节点,创建文件夹(大分类);否则创建条目 const isParentWork = parent && parent.type === 'work'; const newNode = await addSettingsNode({ name: isParentWork ? t('settings.newFolder') : t('settings.newItem'), type: isParentWork ? 'folder' : 'item', category: cat, parentId, enabled: true, }); setNodes(await getSettingsNodes()); setSelectedNodeId(newNode.id); }; const handleDeleteNode = async (id) => { const node = nodes.find(n => n.id === id); if (!node) return; if (shouldSkipDeleteConfirm()) { await doDeleteNode(id); } else { return new Promise((resolve) => { setDeleteConfirm({ message: t('settings.deleteNodePrompt').replace('{name}', node.name), onConfirm: async () => { setDeleteConfirm(null); await doDeleteNode(id); resolve(); }, onCancel: () => { setDeleteConfirm(null); resolve(); }, }); }); } }; const doDeleteNode = async (id) => { await deleteSettingsNode(id); setNodes(await getSettingsNodes()); if (selectedNodeId === id) setSelectedNodeId(null); }; const handleRenameNode = async (id, newName) => { await updateSettingsNode(id, { name: newName }); setNodes(await getSettingsNodes()); }; const handleToggleEnabled = async (id) => { const node = nodes.find(n => n.id === id); if (!node) return; const newEnabled = node.enabled === false ? true : false; await updateSettingsNode(id, { enabled: newEnabled }); setNodes(prev => prev.map(n => n.id === id ? { ...n, enabled: newEnabled } : n)); }; const handleUpdateNode = (id, updates) => { // 乐观更新:立即同步 React 状态,防止异步操作(如 embedding API)导致文字回退 setNodes(prev => prev.map(n => n.id === id ? { ...n, ...updates, updatedAt: new Date().toISOString() } : n)); // 后台持久化(不 await,避免阻塞 UI) updateSettingsNode(id, updates); }; const selectedNode = visibleNodes.find(n => n.id === selectedNodeId); const showBookInfo = selectedNode?.type === 'special' && selectedNode?.category === 'bookInfo'; const tabs = [ { key: 'settings', label: t('settings.tabSettings') }, { key: 'apiConfig', label: t('settings.tabApi') }, { key: 'preferences', label: t('settings.tabPreferences') }, ]; return (
e.stopPropagation()}> {/* 头部 */}

⚙️ {t('settings.title')} — {t('settings.subtitle')}

{/* Tab 导航 */}
{tabs.map(tab => ( ))}
{/* 内容区 */} {activeTab === 'apiConfig' ? (
handleSettingsSave('apiConfig', data)} />
) : activeTab === 'preferences' ? (
) : (
{/* 写作模式选择器 */}
{Object.values(WRITING_MODES).map(m => ( ))}
{/* 作品切换器 */}
{t('settings.workLabel')}
{works.map(w => (
{works.length > 1 && ( )}
))} {showNewWorkInput ? (
setNewWorkName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleCreateWork(); if (e.key === 'Escape') setShowNewWorkInput(false); }} placeholder={t('settings.workNamePlaceholder')} autoFocus />
) : (<>
{showExportFormat && (
{[{ key: 'json', label: 'JSON (完整)', icon: '📋' }, { key: 'txt', label: 'TXT (纯文本)', icon: '📝' }, { key: 'md', label: 'Markdown', icon: '📖' }, { key: 'docx', label: 'Word (.docx)', icon: '📘' }, { key: 'pdf', label: 'PDF (打印)', icon: '📕' }].map(f => ( ))}
)}
)}
{/* 统计栏 */}
{stats.map(s => (
setExpandedCategory(s.category)} > {s.icon} {s.count} {s.label}
))}
{/* 左侧:搜索 + 树形导航 */}
{/* 搜索框 */}
setSearchQuery(e.target.value)} />
{/* 树 */}
setExpandedCategory(null)} />
{/* 右侧:编辑器 */}
{showBookInfo ? (
handleUpdateNode(selectedNode.id, { content: data })} />
) : ( )}
)}
{ conflictData && createPortal( setConflictData(null)} />, document.body // Render into document.body or a specific portal root ) } { deleteConfirm && createPortal( , document.body ) }
); } 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: [] }, { key: 'siliconflow', label: 'SiliconFlow (硅基流动)', baseUrl: 'https://api.siliconflow.cn/v1', models: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct', 'THUDM/glm-4-9b-chat'] }, { key: 'volcengine', label: '火山引擎 (豆包)', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', models: [] }, { key: 'moonshot', label: 'Moonshot (Kimi)', baseUrl: 'https://api.moonshot.cn/v1', models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'] }, { key: 'custom', label: '自定义 (OpenAI兼容)', baseUrl: '', models: [] }, { key: 'custom-gemini', label: '自定义 (Gemini格式)', baseUrl: '', models: [] }, { key: 'custom-claude', label: '自定义 (Claude格式)', baseUrl: '', models: [] }, ]; function PreferencesForm() { const { language, setLanguage, visualTheme, setVisualTheme } = useAppStore(); const { t } = useI18n(); return (

{t('preferences.intro')}

{['zh', 'en', 'ru'].map(lang => ( ))}
{[{ id: 'warm', label: t('preferences.themeWarm'), desc: t('preferences.themeWarmDesc') }, { id: 'modern', label: t('preferences.themeModern'), desc: t('preferences.themeModernDesc') }].map(theme => ( ))}
); } function ApiConfigForm({ data, onChange }) { const update = (field, value) => onChange({ ...data, [field]: value }); const [testStatus, setTestStatus] = useState(null); const [fetchedModels, setFetchedModels] = useState(null); const [fetchedEmbedModels, setFetchedEmbedModels] = useState(null); const [rebuildStatus, setRebuildStatus] = useState(null); // null | 'loading' | {done, total, failed} const [balanceInfo, setBalanceInfo] = useState(null); // null | 'loading' | { supported, balance, currency, ... } | { error } const [savedProfiles, setSavedProfiles] = useState([]); const [profileName, setProfileName] = useState(''); const [showSaveInput, setShowSaveInput] = useState(false); const { t } = useI18n(); useEffect(() => { try { const saved = localStorage.getItem('author-api-profiles'); if (saved) setSavedProfiles(JSON.parse(saved)); } catch { /* ignore */ } }, []); const persistProfiles = (profiles) => { setSavedProfiles(profiles); localStorage.setItem('author-api-profiles', JSON.stringify(profiles)); import('../lib/persistence').then(m => m.persistSet('author-api-profiles', profiles).catch(() => { })); }; const handleSaveProfile = () => { const name = profileName.trim(); if (!name) return; const profile = { id: Date.now().toString(36), name, config: { ...data }, createdAt: new Date().toLocaleString('zh-CN') }; const updated = savedProfiles.filter(p => p.name !== name); updated.unshift(profile); persistProfiles(updated); setProfileName(''); setShowSaveInput(false); }; const handleLoadProfile = (profile) => { onChange({ ...profile.config }); setTestStatus(null); setFetchedModels(null); }; const handleDeleteProfile = (id) => { persistProfiles(savedProfiles.filter(p => p.id !== id)); }; const handleProviderChange = (providerKey) => { const provider = PROVIDERS.find(p => p.key === providerKey); if (provider) { onChange({ ...data, provider: providerKey, baseUrl: providerKey === 'custom' ? '' : (provider.baseUrl || data.baseUrl), model: providerKey === 'custom' ? '' : (provider.models[0] || data.model) }); } setTestStatus(null); setFetchedModels(null); setFetchedEmbedModels(null); setBalanceInfo(null); }; const handleTestConnection = async () => { setTestStatus('loading'); try { const res = await fetch('/api/ai/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiConfig: data }) }); setTestStatus(await res.json()); } catch { setTestStatus({ success: false, error: t('apiConfig.networkError') }); } }; const handleFetchModels = async () => { setFetchedModels('loading'); try { const res = await fetch('/api/ai/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: data.apiKey, baseUrl: data.baseUrl, provider: data.provider }) }); const result = await res.json(); if (result.error) { setFetchedModels(null); setTestStatus({ success: false, error: result.error }); } else { setFetchedModels(result.models || []); } } catch { setFetchedModels(null); setTestStatus({ success: false, error: t('apiConfig.fetchModelsFailed') }); } }; const handleFetchEmbedModels = async () => { setFetchedEmbedModels('loading'); try { const embedKey = data.embedApiKey || data.apiKey; const embedBase = data.embedBaseUrl || data.baseUrl; const res = await fetch('/api/ai/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: embedKey, baseUrl: embedBase, provider: data.embedProvider, embedOnly: true }) }); const result = await res.json(); if (result.error) { setFetchedEmbedModels(null); setTestStatus({ success: false, error: t('apiConfig.embedApiPrefix') + result.error }); } else { setFetchedEmbedModels(result.models || []); } } catch { setFetchedEmbedModels(null); setTestStatus({ success: false, error: t('apiConfig.fetchEmbedModelsFailed') }); } }; const handleRebuildEmbeddings = async () => { setRebuildStatus({ done: 0, total: 0, failed: 0 }); try { const result = await rebuildAllEmbeddings((done, total, failed) => { setRebuildStatus({ done, total, failed }); }); setRebuildStatus({ ...result, finished: true }); setTimeout(() => setRebuildStatus(null), 5000); } catch { setRebuildStatus({ error: true }); setTimeout(() => setRebuildStatus(null), 3000); } }; const currentProvider = PROVIDERS.find(p => p.key === data.provider) || PROVIDERS[7]; const isCustom = ['custom', 'custom-gemini', 'custom-claude'].includes(data.provider); // 余额查询 const handleQueryBalance = async () => { setBalanceInfo('loading'); try { const res = await fetch('/api/balance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: data.provider, apiKey: data.apiKey, baseUrl: data.baseUrl }), }); const result = await res.json(); if (res.ok) { setBalanceInfo(result); } else { setBalanceInfo({ error: result.error || '查询失败' }); } } catch (e) { setBalanceInfo({ error: e.message || '网络错误' }); } }; return (
{savedProfiles.length > 0 && (
{savedProfiles.map(p => (
))}
)}

{t('apiConfig.intro')}

{/* 供应商选择 */}
{PROVIDERS.map(p => ( ))}
{data.provider === 'gemini-native' && (
{t('apiConfig.geminiNativeHint')}
)} {data.provider === 'openai-responses' && (
{[ { key: 'low', label: 'low' }, { key: 'medium', label: 'medium' }, { key: 'high', label: 'high' }, { key: 'xhigh', label: 'xhigh' }, ].map(opt => ( ))}
控制模型推理深度,默认 Medium,XHigh 质量最高但更慢
)}
update('apiKey', v)} placeholder={t('apiConfig.apiKeyPlaceholder')} secret /> {data.apiKey &&
{t('apiConfig.apiKeyConfigured')}
} {/* 余额查询卡片 */} {data.apiKey && (
💰 {t('apiConfig.balance') || 'API 余额'}
{balanceInfo && balanceInfo !== 'loading' && (
{balanceInfo.error ? (
❌ {balanceInfo.error}
) : balanceInfo.supported === false ? (
⚠️ {balanceInfo.message || t('apiConfig.balanceNotSupported') || '当前供应商不支持余额查询'}
) : (
{balanceInfo.currency === 'CNY' ? '¥' : '$'}{balanceInfo.balance?.toFixed(2)} {balanceInfo.currency}
{balanceInfo.detail && (
{balanceInfo.detail.granted != null && balanceInfo.detail.granted > 0 && 赠送: {balanceInfo.detail.granted.toFixed(2)}} {balanceInfo.detail.topped_up != null && balanceInfo.detail.topped_up > 0 && 充值: {balanceInfo.detail.topped_up.toFixed(2)}} {balanceInfo.detail.used != null && 已用: {balanceInfo.detail.used.toFixed(2)}} {balanceInfo.detail.total_limit != null && 额度: {balanceInfo.detail.total_limit.toFixed(2)}} {balanceInfo.detail.remaining != null && balanceInfo.detail.total_limit != null && 剩余: {balanceInfo.detail.remaining.toFixed(2)}}
)} {balanceInfo.source &&
via {balanceInfo.source}
}
)}
)}
)} update('baseUrl', v)} placeholder={t('apiConfig.apiAddressPlaceholder')} /> {/* 模型选择 */} {currentProvider.models.length > 0 && !isCustom ? (
20 ? { maxHeight: 200, overflowY: 'auto', padding: 4, border: '1px solid var(--border-light)', borderRadius: 'var(--radius-sm)' } : {}) }}> {(Array.isArray(fetchedModels) ? fetchedModels.map(m => m.id) : currentProvider.models).map(m => ( ))}
{Array.isArray(fetchedModels) &&
{t('apiConfig.fetchedCount').replace('{count}', fetchedModels.length)}
}
) : (
update('model', e.target.value)} placeholder="例如:gpt-4o-mini" /> {Array.isArray(fetchedModels) && fetchedModels.length > 0 && ( <>
{t('apiConfig.fetchedCountClick').replace('{count}', fetchedModels.length)}
{fetchedModels.map(m => ( ))}
)}
)} {/* 高级模型参数 */}
{t('apiConfig.advancedParamsDesc')}
{data.useAdvancedParams && (
{/* Temperature */}
update('temperature', parseFloat(e.target.value))} style={{ flex: 1, accentColor: 'var(--accent)' }} /> update('temperature', parseFloat(e.target.value) || 0)} />
{t('apiConfig.temperatureDesc')}
{/* Top P */}
update('topP', parseFloat(e.target.value))} style={{ flex: 1, accentColor: 'var(--accent)' }} /> update('topP', parseFloat(e.target.value) || 0)} />
{t('apiConfig.topPDesc')}
{/* 最大上下文长度 */}
update('maxContextLength', parseInt(e.target.value) || 4096)} />
{t('apiConfig.maxContextLengthDesc')}
{/* 最大输出 Token — Responses API 不适用 */} {data.provider !== 'openai-responses' && (
update('maxOutputTokens', parseInt(e.target.value) || 4096)} />
{t('apiConfig.maxOutputTokensDesc')}
)} {/* 思考层级 — Responses API 有自己的内置思考层级 */} {data.provider !== 'openai-responses' && (
{t('apiConfig.reasoningEffortDesc')}
)}
)} {/* 独立 Embedding 配置 */}
{t('apiConfig.embedDesc')}
{data.useCustomEmbed && (
{PROVIDERS.filter(p => !['deepseek', 'moonshot', 'siliconflow', 'openai'].includes(p.key)).map(p => ( ))}
update('embedApiKey', v)} placeholder={t('apiConfig.embedApiKeyPlaceholder')} secret /> update('embedBaseUrl', v)} placeholder="https://api.example.com/v1" />
update('embedModel', e.target.value)} placeholder="例如:text-embedding-v3-small" /> {Array.isArray(fetchedEmbedModels) && fetchedEmbedModels.length > 0 && ( <>
{t('apiConfig.fetchedCountClick').replace('{count}', fetchedEmbedModels.length)}
{fetchedEmbedModels.map(m => ( ))}
)}
{/* 重建向量按钮 */}
{rebuildStatus?.finished && ( 0 ? 'var(--warning)' : 'var(--success)' }}> ✓ 完成!{rebuildStatus.done - rebuildStatus.failed}/{rebuildStatus.total} 成功{rebuildStatus.failed > 0 ? `,${rebuildStatus.failed} 失败` : ''} )} {rebuildStatus?.error && ( 重建失败 )}
首次开启或更换嵌入模型后,需要重建向量才能使用 RAG 智能检索
)} {/* 测试连接 */} {data.apiKey && (
{testStatus && testStatus !== 'loading' && (
{testStatus.success ? <>{testStatus.message}
{testStatus.reply && {t('apiConfig.testReply')}{testStatus.reply}} : <>❌ {testStatus.error}}
)}
)} {/* 保存配置 */} {data.apiKey && (
{!showSaveInput ? ( ) : (
setProfileName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveProfile()} placeholder={t('apiConfig.saveProfilePlaceholder')} autoFocus />
)}
)}
{t('apiConfig.howToGetKey')}
• {t('apiConfig.keyGuide').split('\n').map((line, i) => {line.replace(/^• /, '')}
)}
); } // ==================== 表单组件 ==================== function FieldInput({ label, value, onChange, placeholder, multiline, rows, secret }) { const [showSecret, setShowSecret] = useState(false); const Component = multiline ? 'textarea' : 'input'; return (
onChange(e.target.value)} placeholder={placeholder} rows={rows || 3} /> {secret && value && ( )}
); } function BookInfoForm({ data, onChange }) { const update = (field, value) => onChange({ ...data, [field]: value }); const { t } = useI18n(); return (

{t('bookInfo.intro')}

update('title', v)} placeholder={t('bookInfo.titlePlaceholder')} /> update('genre', v)} placeholder={t('bookInfo.genrePlaceholder')} /> update('synopsis', v)} placeholder={t('bookInfo.synopsisPlaceholder')} multiline rows={3} /> update('style', v)} placeholder={t('bookInfo.stylePlaceholder')} /> update('tone', v)} placeholder={t('bookInfo.tonePlaceholder')} /> update('pov', v)} placeholder={t('bookInfo.povPlaceholder')} /> update('targetAudience', v)} placeholder={t('bookInfo.targetAudiencePlaceholder')} />
); } function DeleteConfirmModal({ message, onConfirm, onCancel }) { const [skipToday, setSkipToday] = useState(false); const [neverRemind, setNeverRemind] = useState(false); const { t } = useI18n(); const handleConfirm = () => { try { if (neverRemind) { localStorage.setItem('author-delete-never-remind', 'true'); } else if (skipToday) { localStorage.setItem('author-delete-skip-today', new Date().toISOString().slice(0, 10)); } } catch { /* ignore */ } onConfirm(); }; return (
e.stopPropagation()} > {/* 标题 */}
⚠️ {t('settings.deleteConfirmTitle')}
{/* 消息 */}

{message}

{/* 复选框 */}
{/* 按钮 */}
); }