import clsx from 'clsx'; import DOMPurify from 'dompurify'; import { Check, Download, FileText, Languages, MessageSquarePlus, Pin, PinOff, RefreshCw, Save, Sparkles, X, } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { Overlay } from '@/components/Overlay'; import { useEnv } from '@/context/EnvContext'; import { useIsMobileViewport } from '@/hooks/useIsMobileViewport'; import { useTranslation } from '@/hooks/useTranslation'; import { usePanelResize } from '@/hooks/usePanelResize'; import { useBookDataStore } from '@/store/bookDataStore'; import { useReaderStore } from '@/store/readerStore'; import { useReviewModeStore } from '@/store/reviewModeStore'; import { useThemeStore } from '@/store/themeStore'; import { getPanelTopInset } from '@/utils/insets'; import { openExternalUrl } from '@/utils/open'; import { useReviewPanelDrag } from '../hooks/useReviewPanelDrag'; import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize'; import { exportReviewedEpub, generateReviewFeedback, loadReviewGlossary, retranslateReviewRow, saveReviewGlossary, saveReviewGptConfig, saveReviewRow, sidecarApi, switchReviewGlossaryPath, type ReviewEditPayload, type ReviewGlossaryEntry, type ReviewGlossaryPayload, type ReviewGptConfig, type ReviewSessionPayload, } from '@/services/reviewEditorService'; const MIN_REVIEW_PANEL_WIDTH = 0.24; const MAX_REVIEW_PANEL_WIDTH = 0.48; const MIN_FLOATING_PANEL_HEIGHT = 0.35; const MAX_FLOATING_PANEL_HEIGHT = 0.92; const FLOATING_PANEL_MARGIN = 12; const DEFAULT_REVIEW_PANEL_WIDTH = 0.32; const cnHtmlPurifyOptions = { ALLOWED_TAGS: [ 'ruby', 'rb', 'rt', 'rp', 'span', 'mark', 'br', 'em', 'strong', 'b', 'i', 'u', 's', 'sub', 'sup', ], ALLOWED_ATTR: ['class', 'title'], ALLOW_DATA_ATTR: false, }; type GptForm = { base_url: string; model: string; api_key: string; glossary_path: string; translation_prompt: string; format_prompt: string; character_prompt: string; }; const emptyEditState: ReviewEditPayload = { current_html: '', marked: false, issue_type: '', severity: '', tags: '', comment: '', learn_note: '', }; const emptyGptForm: GptForm = { base_url: '', model: '', api_key: '', glossary_path: '', translation_prompt: '', format_prompt: '', character_prompt: '', }; const sanitizeInlineHtml = (html: string) => DOMPurify.sanitize(html || '', cnHtmlPurifyOptions); const escapeInlineHtml = (value: string) => value.replace(/&/g, '&').replace(//g, '>'); const rowTitle = (row: { document_title?: string; file_label?: string; file: string }) => row.document_title || row.file_label || row.file; const toGptForm = (config: ReviewGptConfig | null | undefined): GptForm => ({ base_url: config?.base_url || '', model: config?.model || '', api_key: '', glossary_path: config?.glossary_path || '', translation_prompt: config?.translation_prompt || config?.prompt_defaults?.translation_prompt || '', format_prompt: config?.format_prompt || config?.prompt_defaults?.format_prompt || '', character_prompt: config?.character_prompt || config?.prompt_defaults?.character_prompt || '', }); const PanelButton = ({ children, icon, onClick, disabled, variant = 'ghost', }: { children: ReactNode; icon?: ReactNode; onClick: () => void; disabled?: boolean; variant?: 'primary' | 'ghost'; }) => ( svg]:shrink-0', variant === 'primary' ? 'btn-primary' : 'btn-ghost eink-bordered', disabled && 'opacity-60', )} > {icon} {children} ); function GptConfigPanel({ baseUrl, sessionId, form, setForm, onSaved, }: { baseUrl: string; sessionId: string | null | undefined; form: GptForm; setForm: React.Dispatch>; onSaved: (config: ReviewGptConfig) => void; }) { const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(''); const save = async () => { setSaving(true); setMessage(''); try { const config = await saveReviewGptConfig(baseUrl, sessionId, { base_url: form.base_url || undefined, model: form.model || undefined, api_key: form.api_key, glossary_path: form.glossary_path, translation_prompt: form.translation_prompt, format_prompt: form.format_prompt, character_prompt: form.character_prompt, }); setForm((current) => ({ ...current, api_key: '' })); onSaved(config); setMessage('已保存 API、提示词与术语表路径。'); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { setSaving(false); } }; return ( setOpen((value) => !value)} > API 与提示词 {open ? '收起' : '展开'} {open ? ( Base URL setForm((current) => ({ ...current, base_url: event.target.value })) } placeholder='https://api.openai.com/v1' /> 模型 setForm((current) => ({ ...current, model: event.target.value })) } placeholder='gpt-4.1' /> API Key setForm((current) => ({ ...current, api_key: event.target.value })) } placeholder='留空表示沿用已保存密钥' /> 术语表路径 setForm((current) => ({ ...current, glossary_path: event.target.value })) } placeholder='mingcibiao.json' /> 翻译内容提示词 setForm((current) => ({ ...current, translation_prompt: event.target.value })) } /> 格式与标点提示词 setForm((current) => ({ ...current, format_prompt: event.target.value })) } /> 角色状态与口吻提示词 setForm((current) => ({ ...current, character_prompt: event.target.value })) } /> } variant='primary' > {saving ? '保存中' : '保存设置'} {message ? {message} : null} ) : null} ); } function GlossaryPanel({ baseUrl, sessionId, path, setPath, }: { baseUrl: string; sessionId: string | null | undefined; path: string; setPath: (path: string) => void; }) { const [open, setOpen] = useState(false); const [entries, setEntries] = useState([]); const [loaded, setLoaded] = useState(false); const [dirty, setDirty] = useState(false); const [query, setQuery] = useState(''); const [message, setMessage] = useState('未读取术语表'); const [busy, setBusy] = useState(false); const filteredEntries = useMemo(() => { const needle = query.trim().toLowerCase(); if (!needle) return entries.map((entry, index) => ({ entry, index })); return entries .map((entry, index) => ({ entry, index })) .filter( ({ entry }) => entry.source.toLowerCase().includes(needle) || entry.target.toLowerCase().includes(needle), ); }, [entries, query]); const applyPayload = (payload: ReviewGlossaryPayload) => { setEntries(payload.entries || []); setLoaded(true); setDirty(false); if (payload.path) setPath(payload.path); setMessage( `当前术语表:${payload.path || path || '未设置'} · ${payload.count ?? payload.entries?.length ?? 0} 条`, ); }; const load = async (forcePath = false) => { setBusy(true); try { const payload = forcePath && path ? await switchReviewGlossaryPath(baseUrl, sessionId, path) : await loadReviewGlossary(baseUrl, sessionId); applyPayload(payload); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { setBusy(false); } }; const save = async () => { setBusy(true); try { const payload = await saveReviewGlossary(baseUrl, sessionId, path, entries); applyPayload(payload); setMessage('术语表已保存,下一次重翻会实时使用最新术语。'); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { setBusy(false); } }; const updateEntry = (index: number, patch: Partial) => { setEntries((current) => current.map((entry, itemIndex) => (itemIndex === index ? { ...entry, ...patch } : entry)), ); setDirty(true); }; const removeEntry = (index: number) => { setEntries((current) => current.filter((_entry, itemIndex) => itemIndex !== index)); setDirty(true); }; const addEntry = () => { setOpen(true); setLoaded(true); setQuery(''); setEntries((current) => [{ source: '', target: '' }, ...current]); setDirty(true); }; return ( { const nextOpen = !open; setOpen(nextOpen); if (nextOpen && !loaded) void load(false); }} > 术语表 {open ? '收起' : '展开'} {open ? ( setPath(event.target.value)} placeholder='mingcibiao.json' /> void load(true)} disabled={busy} icon={} > 读取 新增术语 void save()} disabled={busy || !loaded || !dirty} icon={} variant='primary' > 保存术语表 {message} setQuery(event.target.value)} placeholder='搜索术语或译名' /> {!loaded ? ( 展开后会读取术语表。 ) : filteredEntries.length === 0 ? ( 没有匹配的术语。 ) : ( filteredEntries.slice(0, 80).map(({ entry, index }) => ( updateEntry(index, { source: event.target.value })} placeholder='原文' /> updateEntry(index, { target: event.target.value })} placeholder='译名' /> removeEntry(index)} > 删除 )) )} ) : null} ); } const ReviewPanel: React.FC = () => { const _ = useTranslation(); const { appService } = useEnv(); const { safeAreaInsets, systemUIVisible, statusBarHeight } = useThemeStore(); const activeBookKey = useReviewModeStore((state) => state.activeBookKey); const isPanelVisible = useReviewModeStore((state) => state.isPanelVisible); const isPanelPinned = useReviewModeStore((state) => state.isPanelPinned); const panelWidth = useReviewModeStore((state) => state.panelWidth); const panelHeight = useReviewModeStore((state) => state.panelHeight); const setPanelVisible = useReviewModeStore((state) => state.setPanelVisible); const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin); const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth); const setPanelHeight = useReviewModeStore((state) => state.setPanelHeight); const updateRow = useReviewModeStore((state) => state.updateRow); const setBookData = useReviewModeStore((state) => state.setBookData); const bookState = useReviewModeStore((state) => activeBookKey ? state.books[activeBookKey] : null, ); const getBookData = useBookDataStore((state) => state.getBookData); const getProgress = useReaderStore((state) => state.getProgress); const getView = useReaderStore((state) => state.getView); const viewSettings = useReaderStore((state) => activeBookKey ? state.viewStates[activeBookKey]?.viewSettings : null, ); const [edit, setEdit] = useState(emptyEditState); const [gptForm, setGptForm] = useState(emptyGptForm); const [candidateHtml, setCandidateHtml] = useState(''); const [retranslateInstruction, setRetranslateInstruction] = useState(''); const [message, setMessage] = useState(''); const [exportInfo, setExportInfo] = useState(''); const [saving, setSaving] = useState(false); const [retranslating, setRetranslating] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); const isMobile = useIsMobileViewport(); const panelTopInset = getPanelTopInset({ isMobile, isFullHeightInMobile: true, systemUIVisible, statusBarHeight, safeAreaInsets, }); const isDocked = !isMobile && isPanelPinned; const panelRef = useRef(null); const cnEditorRef = useRef(null); const restoreTimeoutRef = useRef(null); const selectedRow = useMemo( () => bookState?.rows.find((row) => row.id === bookState.selectedRowId) || null, [bookState?.rows, bookState?.selectedRowId], ); const canUseRowActions = Boolean(selectedRow && bookState?.baseUrl); const bookTitle = activeBookKey ? getBookData(activeBookKey)?.book?.title || selectedRow?.document_title || '审校' : '审校'; useEffect(() => { setGptForm(toGptForm(bookState?.gptConfig)); }, [bookState?.gptConfig]); useEffect(() => { if (!selectedRow) { setEdit(emptyEditState); setCandidateHtml(''); setRetranslateInstruction(''); return; } setEdit({ current_html: selectedRow.current_html || selectedRow.cn_html || '', marked: Boolean(selectedRow.marked), issue_type: selectedRow.issue_type || '', severity: selectedRow.severity || '', tags: selectedRow.tags || '', comment: selectedRow.comment || '', learn_note: selectedRow.learn_note || '', }); setCandidateHtml(''); setRetranslateInstruction(''); setPreviewOpen(false); }, [selectedRow]); const restoreReadingPositionAfterLayoutChange = useCallback(() => { if (!activeBookKey) return; const view = getView(activeBookKey); const cfi = view?.lastLocation?.cfi || getProgress(activeBookKey)?.location; if (!view || !cfi) return; if (restoreTimeoutRef.current) window.clearTimeout(restoreTimeoutRef.current); restoreTimeoutRef.current = window.setTimeout(() => { window.requestAnimationFrame(() => { window.requestAnimationFrame(() => view.goTo(cfi)); }); }, 160); }, [activeBookKey, getProgress, getView]); const handleTogglePanelPin = () => { togglePanelPin(); }; const floatingPanelEnabled = !isPanelPinned && !isMobile; const { panelPosition, panelPositionRef, updatePanelPosition, moveToDefaultPosition: moveFloatingPanelToDefault, handlePanelDragStart, } = useReviewPanelDrag({ enabled: floatingPanelEnabled, panelRef, margin: FLOATING_PANEL_MARGIN, defaultWidthRatio: DEFAULT_REVIEW_PANEL_WIDTH, topInset: panelTopInset, }); const { handleWidthPointerDown: handleFloatingWidthPointerDown, handleWidthKeyDown: handleFloatingWidthKeyDown, handleHeightPointerDown: handleFloatingHeightPointerDown, handleHeightKeyDown: handleFloatingHeightKeyDown, } = useReviewPanelFloatingResize({ enabled: floatingPanelEnabled, panelRef, panelPositionRef, panelWidth, panelHeight, minWidth: MIN_REVIEW_PANEL_WIDTH, maxWidth: MAX_REVIEW_PANEL_WIDTH, minHeight: MIN_FLOATING_PANEL_HEIGHT, maxHeight: MAX_FLOATING_PANEL_HEIGHT, setPanelWidth, setPanelHeight, updatePanelPosition, }); const { handleResizeStart: handleDockedResizeStart, handleResizeKeyDown: handleDockedResizeKeyDown, } = usePanelResize({ side: 'end', minWidth: MIN_REVIEW_PANEL_WIDTH, maxWidth: MAX_REVIEW_PANEL_WIDTH, getWidth: () => panelWidth, onResize: setPanelWidth, }); const handleResizeKeyDown = (event: React.KeyboardEvent) => { if (floatingPanelEnabled) { handleFloatingWidthKeyDown(event); } else { handleDockedResizeKeyDown(event); } }; useEffect(() => { moveFloatingPanelToDefault(); }, [moveFloatingPanelToDefault]); useEffect(() => { if (!isPanelVisible || isMobile) return; restoreReadingPositionAfterLayoutChange(); }, [isMobile, isPanelPinned, isPanelVisible, restoreReadingPositionAfterLayoutChange]); useEffect(() => { if (!isPanelVisible || isMobile || !isPanelPinned) return; restoreReadingPositionAfterLayoutChange(); }, [ isMobile, isPanelPinned, isPanelVisible, panelWidth, restoreReadingPositionAfterLayoutChange, ]); useEffect(() => { return () => { if (restoreTimeoutRef.current) window.clearTimeout(restoreTimeoutRef.current); }; }, []); if (!isPanelVisible || !activeBookKey || !bookState) return null; const baseUrl = bookState.baseUrl; const sessionId = bookState.sessionId; const rows = bookState.rows; const saveCurrentRow = async () => { if (!selectedRow || !baseUrl) return; setSaving(true); setMessage(''); try { const payload = await saveReviewRow(baseUrl, sessionId, selectedRow.id, edit); const savedHtml = payload.current_html || sanitizeInlineHtml(edit.current_html); const nextRow = { ...selectedRow, ...edit, current_html: savedHtml, edited: savedHtml !== selectedRow.cn_html, updated_at: payload.updated_at || selectedRow.updated_at, }; updateRow(activeBookKey, nextRow); setEdit((current) => ({ ...current, current_html: savedHtml })); const session = await sidecarApi( baseUrl, '/api/session', {}, sessionId, ); setBookData(activeBookKey, { session }); setMessage(`已保存 ${selectedRow.id}`); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { setSaving(false); } }; const retranslateCurrentRow = async () => { if (!selectedRow || !baseUrl) return; setRetranslating(true); setMessage(''); setCandidateHtml(''); try { const payload = await retranslateReviewRow( baseUrl, sessionId, selectedRow.id, retranslateInstruction, ); setCandidateHtml(payload.candidate_html || ''); setMessage(`重翻完成,命中术语 ${payload.glossary_matches?.length || 0} 条。`); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { setRetranslating(false); } }; const generateFeedback = async () => { if (!baseUrl) return; setMessage(''); try { const payload = await generateReviewFeedback(baseUrl, sessionId); setMessage(`反馈已生成:${payload.feedback_md || payload.feedback_jsonl || ''}`); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } }; const exportEpub = async () => { if (!baseUrl) return; setExportInfo(''); setMessage(''); try { const payload = await exportReviewedEpub(baseUrl, sessionId); const downloadUrl = payload.download_url ? new URL(payload.download_url, baseUrl).toString() : ''; setExportInfo(downloadUrl || payload.output || ''); setMessage(`已导出:${payload.output || downloadUrl}`); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } }; const insertIntoCurrentHtml = ( buildInsert: (selectedText: string) => { html: string; selectStart?: number; selectEnd?: number; } | null, ) => { const editor = cnEditorRef.current; const source = edit.current_html; const selectionStart = editor?.selectionStart ?? source.length; const selectionEnd = editor?.selectionEnd ?? source.length; const selectedText = source.slice(selectionStart, selectionEnd); const insert = buildInsert(selectedText); if (insert === null) return; const nextHtml = `${source.slice(0, selectionStart)}${insert.html}${source.slice(selectionEnd)}`; setEdit((current) => ({ ...current, current_html: nextHtml })); setPreviewOpen(true); requestAnimationFrame(() => { editor?.focus(); const selectStart = selectionStart + (insert.selectStart ?? insert.html.length); const selectEnd = selectionStart + (insert.selectEnd ?? insert.html.length); editor?.setSelectionRange(selectStart, selectEnd); }); }; const insertRuby = () => { insertIntoCurrentHtml((selectedText) => { const baseText = selectedText || '文字'; const rubyText = '注音'; const baseHtml = escapeInlineHtml(baseText); const prefix = `${baseHtml}`; const html = `${prefix}${rubyText}`; if (selectedText) { return { html, selectStart: prefix.length, selectEnd: prefix.length + rubyText.length, }; } const baseStart = ''.length; return { html, selectStart: baseStart, selectEnd: baseStart + baseText.length, }; }); }; const insertAnnotation = () => { insertIntoCurrentHtml((selectedText) => { const noteText = '注释'; const baseHtml = selectedText ? escapeInlineHtml(selectedText) : ''; const prefix = `${baseHtml}(注:`; const html = `${prefix}${noteText})`; return { html, selectStart: prefix.length, selectEnd: prefix.length + noteText.length, }; }); }; return ( <> {isMobile && ( setPanelVisible(false)} /> )} > ); }; export default ReviewPanel;
{message}
展开后会读取术语表。
没有匹配的术语。