fix: refine inline review panel layout

This commit is contained in:
akai
2026-07-09 15:13:14 +08:00
parent afd48837f9
commit 987067414c
6 changed files with 126 additions and 145 deletions
@@ -339,7 +339,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook, onGoToLibr
return (
<div
className={clsx('books-grid bg-base-100 relative grid h-full flex-grow')}
className={clsx('books-grid bg-base-100 relative grid h-full min-w-0 flex-grow')}
style={gridStyle}
role='main'
aria-label={_('Books Content')}
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import DOMPurify from 'dompurify';
import { Check, Download, FileText, Pin, PinOff, RefreshCw, Save, Sparkles, X } from 'lucide-react';
import { Check, Download, FileText, RefreshCw, Save, Sparkles, X } from 'lucide-react';
import React, { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Overlay } from '@/components/Overlay';
@@ -87,12 +87,6 @@ const emptyGptForm: GptForm = {
const sanitizeInlineHtml = (html: string) => DOMPurify.sanitize(html || '', cnHtmlPurifyOptions);
const stripHtml = (html: string) => {
const div = document.createElement('div');
div.innerHTML = sanitizeInlineHtml(html);
return div.textContent || div.innerText || '';
};
const rowTitle = (row: { document_title?: string; file_label?: string; file: string }) =>
row.document_title || row.file_label || row.file;
@@ -467,12 +461,9 @@ const ReviewPanel: React.FC = () => {
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 setPanelVisible = useReviewModeStore((state) => state.setPanelVisible);
const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin);
const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth);
const selectRow = useReviewModeStore((state) => state.selectRow);
const updateRow = useReviewModeStore((state) => state.updateRow);
const setBookData = useReviewModeStore((state) => state.setBookData);
const bookState = useReviewModeStore((state) =>
@@ -492,6 +483,7 @@ const ReviewPanel: React.FC = () => {
const [saving, setSaving] = useState(false);
const [retranslating, setRetranslating] = useState(false);
const isMobile = window.innerWidth < 640;
const isDocked = !isMobile;
const selectedRow = useMemo(
() => bookState?.rows.find((row) => row.id === bookState.selectedRowId) || null,
@@ -630,7 +622,7 @@ const ReviewPanel: React.FC = () => {
return (
<>
{!isPanelPinned && isMobile && (
{isMobile && (
<Overlay
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')}
onDismiss={() => setPanelVisible(false)}
@@ -639,11 +631,13 @@ const ReviewPanel: React.FC = () => {
<aside
className={clsx(
'review-panel-container end-0 flex min-w-72 select-none flex-col',
isDocked && 'shrink-0',
'full-height font-sans text-base font-normal transition-[padding-top] duration-300 sm:text-sm',
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
isPanelPinned ? 'z-20' : 'z-[45] shadow-2xl',
!isPanelPinned && viewSettings?.isEink && 'border-base-content border-s',
isDocked ? 'z-20' : 'z-[45] shadow-2xl',
isDocked && 'border-base-300 border-s',
!isDocked && viewSettings?.isEink && 'border-base-content border-s',
isMobile && 'bottom-0 max-h-[92vh] rounded-t-2xl',
)}
role='group'
@@ -651,7 +645,7 @@ const ReviewPanel: React.FC = () => {
style={{
width: isMobile ? '100%' : panelWidth,
maxWidth: isMobile ? '100%' : `${MAX_REVIEW_PANEL_WIDTH * 100}%`,
position: isMobile ? 'fixed' : isPanelPinned ? 'relative' : 'absolute',
position: isMobile ? 'fixed' : 'relative',
paddingTop: `${panelTopInset}px`,
}}
>
@@ -677,14 +671,6 @@ const ReviewPanel: React.FC = () => {
{bookState.session?.marked_count || 0}
</p>
</div>
<button
type='button'
className='btn btn-ghost h-8 min-h-8 w-8 rounded-full p-0'
title={isPanelPinned ? '取消固定' : '固定面板'}
onClick={togglePanelPin}
>
{isPanelPinned ? <PinOff className='h-4 w-4' /> : <Pin className='h-4 w-4' />}
</button>
<button
type='button'
className='btn btn-ghost h-8 min-h-8 w-8 rounded-full p-0'
@@ -744,55 +730,6 @@ const ReviewPanel: React.FC = () => {
</button>
) : null}
<GptConfigPanel
baseUrl={baseUrl}
sessionId={sessionId}
form={gptForm}
setForm={setGptForm}
onSaved={(config) => setBookData(activeBookKey, { gptConfig: config })}
/>
<GlossaryPanel
baseUrl={baseUrl}
sessionId={sessionId}
path={gptForm.glossary_path}
setPath={(path) => setGptForm((current) => ({ ...current, glossary_path: path }))}
/>
{rows.length ? (
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border'>
<div className='border-base-300 border-b px-3 py-2'>
<h3 className='text-sm font-semibold'></h3>
<p className='text-base-content/60 text-xs'>
</p>
</div>
<div className='max-h-48 overflow-auto p-2'>
{rows.slice(0, 120).map((row) => (
<button
key={row.id}
type='button'
className={clsx(
'mb-2 block w-full rounded-md border p-2 text-start text-xs',
selectedRow?.id === row.id
? 'border-primary bg-primary/10'
: 'border-base-300 hover:bg-base-200 bg-base-100',
)}
onClick={() => selectRow(activeBookKey, row.id)}
>
<span className='flex items-center justify-between gap-2'>
<span className='font-mono'>{row.id}</span>
<span className='text-base-content/60 truncate'>{rowTitle(row)}</span>
</span>
<span className='mt-1 line-clamp-1'>{row.jp_text}</span>
<span className='text-base-content/70 mt-1 line-clamp-1'>
{stripHtml(row.current_html || row.cn_html)}
</span>
</button>
))}
</div>
</section>
) : null}
{!selectedRow ? (
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-5 text-center text-sm'>
{rows.length
@@ -819,17 +756,6 @@ const ReviewPanel: React.FC = () => {
<section className='grid gap-3'>
<div className='flex items-center justify-between gap-2'>
<h3 className='text-sm font-semibold'></h3>
<label className='flex items-center gap-2 text-xs'>
<input
type='checkbox'
className='checkbox checkbox-sm'
checked={edit.marked}
onChange={(event) =>
setEdit((current) => ({ ...current, marked: event.target.checked }))
}
/>
</label>
</div>
<textarea
className='textarea textarea-bordered eink-bordered min-h-44 rounded-md font-mono text-sm'
@@ -845,62 +771,101 @@ const ReviewPanel: React.FC = () => {
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(edit.current_html) }}
/>
</div>
<div className='grid grid-cols-2 gap-2'>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={edit.issue_type}
onChange={(event) =>
setEdit((current) => ({ ...current, issue_type: event.target.value }))
}
>
<option value=''></option>
<option value='误译'></option>
<option value='漏译'></option>
<option value='术语不一致'></option>
<option value='人名/地名问题'>/</option>
<option value='翻译腔'></option>
<option value='角色口吻不对'></option>
<option value='语序不顺'></option>
<option value='标点/格式'>/</option>
<option value='其他'></option>
</select>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={edit.severity}
onChange={(event) =>
setEdit((current) => ({ ...current, severity: event.target.value }))
}
>
<option value=''></option>
<option value='轻微'></option>
<option value='中等'></option>
<option value='严重'></option>
</select>
</div>
<input
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
value={edit.tags}
onChange={(event) =>
setEdit((current) => ({ ...current, tags: event.target.value }))
}
placeholder='标签'
/>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 rounded-md text-sm'
value={edit.comment}
onChange={(event) =>
setEdit((current) => ({ ...current, comment: event.target.value }))
}
placeholder='备注'
/>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 rounded-md text-sm'
value={edit.learn_note}
onChange={(event) =>
setEdit((current) => ({ ...current, learn_note: event.target.value }))
}
placeholder='可沉淀为长期规则'
/>
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<h4 className='text-sm font-semibold'></h4>
<p className='text-base-content/60 mt-1 text-xs'>
</p>
</div>
<label className='flex shrink-0 items-center gap-2 text-xs'>
<input
type='checkbox'
className='checkbox checkbox-sm'
checked={edit.marked}
onChange={(event) =>
setEdit((current) => ({ ...current, marked: event.target.checked }))
}
/>
</label>
</div>
<div className='grid grid-cols-2 gap-2'>
<label className='grid gap-1 text-xs font-medium'>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={edit.issue_type}
onChange={(event) =>
setEdit((current) => ({
...current,
issue_type: event.target.value,
}))
}
>
<option value=''></option>
<option value='误译'></option>
<option value='漏译'></option>
<option value='术语不一致'></option>
<option value='人名/地名问题'>/</option>
<option value='翻译腔'></option>
<option value='角色口吻不对'></option>
<option value='语序不顺'></option>
<option value='标点/格式'>/</option>
<option value='其他'></option>
</select>
</label>
<label className='grid gap-1 text-xs font-medium'>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={edit.severity}
onChange={(event) =>
setEdit((current) => ({ ...current, severity: event.target.value }))
}
>
<option value=''></option>
<option value='轻微'></option>
<option value='中等'></option>
<option value='严重'></option>
</select>
</label>
</div>
<label className='grid gap-1 text-xs font-medium'>
<input
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
value={edit.tags}
onChange={(event) =>
setEdit((current) => ({ ...current, tags: event.target.value }))
}
placeholder='检索归类:术语、口吻、格式,可用逗号分隔'
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 rounded-md text-sm'
value={edit.comment}
onChange={(event) =>
setEdit((current) => ({ ...current, comment: event.target.value }))
}
placeholder='只记录当前段哪里有问题、为什么这样改'
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 rounded-md text-sm'
value={edit.learn_note}
onChange={(event) =>
setEdit((current) => ({ ...current, learn_note: event.target.value }))
}
placeholder='只写后续段落也应遵守的规则或口吻偏好'
/>
</label>
</section>
<PanelButton
onClick={saveCurrentRow}
disabled={!canUseRowActions || saving}
@@ -948,6 +913,20 @@ const ReviewPanel: React.FC = () => {
</section>
</>
)}
<GptConfigPanel
baseUrl={baseUrl}
sessionId={sessionId}
form={gptForm}
setForm={setGptForm}
onSaved={(config) => setBookData(activeBookKey, { gptConfig: config })}
/>
<GlossaryPanel
baseUrl={baseUrl}
sessionId={sessionId}
path={gptForm.glossary_path}
setPath={(path) => setGptForm((current) => ({ ...current, glossary_path: path }))}
/>
</div>
</div>
</aside>
@@ -51,7 +51,7 @@ epub_review_sessions/
- `translated_outputs/` 保存 AI 翻译生成的 EPUB;生成后应创建独立 session,使 `/api/sessions` 和书架自然可见。
- Readest 桌面端入口委托 Tauri command `launch_epub_review_editor` 启动或复用本地 sidecar;dev-web 入口继续使用 `/api/review-editor/launch`。两者都应创建 `.venv`、安静检测 Flask 依赖、缺失时自动安装依赖、复用同版本且 `review_root` 一致的已运行服务或启动 `server.py --daemon`;该入口不得硬编码用户私有路径。桌面端从书架进入时,文件路径只交给 Tauri command 注册 sessionReadest 页面 URL 应改用 `session_id`,避免长期暴露本机 EPUB 路径。
- Readest `/review-editor` 的主路径必须是原生 React 功能块,校对和翻译功能直接调用本地 sidecar API;旧 `static/index.html` 界面只保留为独立启动、调试或应急 fallback,不再作为桌面迁移验收的主界面。若 React 直接访问 loopback sidecarsidecar 只能对本机 Readest/Tauri 来源返回受限 CORS,不得开放任意 Origin。
- Readest 原生阅读页可提供内嵌审校模式,但只能作为当前 EPUB 的阅读增强:顶栏“校”按钮启动/复用 sidecar、注册当前书 session、读取 `/api/rows``/api/gpt/config`,在 Foliate iframe 正文中只标记对应的中日双语段落;点击或选中日文/中文段落时打开同一条审校记录。右侧面板可复用旧审校器右侧栏能力(编辑中文、问题类型、严重程度、标签、备注、长期规则、GPT 配置、术语表、单段重翻、反馈生成、导出),不得迁入整书翻译、书架管理等无关功能。
- Readest 原生阅读页可提供内嵌审校模式,但只能作为当前 EPUB 的阅读增强:顶栏“校”按钮启动/复用 sidecar、注册当前书 session、读取 `/api/rows``/api/gpt/config`,在 Foliate iframe 正文中只标记对应的中日双语段落;点击或选中日文/中文段落时打开同一条审校记录。右侧面板可复用旧审校器右侧栏能力(编辑中文、问题类型、严重程度、标签、备注、长期规则、GPT 配置、术语表、单段重翻、反馈生成、导出),但不显示独立段落列表;GPT 配置与术语表应放在面板底部,避免挤占逐段编辑区。桌面端右侧面板应作为阅读布局的一列停靠,压缩正文宽度而不是覆盖正文;移动窄屏仍可用抽屉/底部面板。不得迁入整书翻译、书架管理等无关功能。
## 目录与插图
@@ -1,6 +1,6 @@
# 双语 EPUB 审校编辑器
当前版本:`0.16.0`
当前版本:`0.16.1`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
@@ -59,6 +59,8 @@
`0.16.0` 新增 Readest 原生阅读页审校模式:桌面端阅读界面顶栏提供“校”按钮,启用后会启动或复用本地 sidecar,并在 Foliate 原生正文里标记中日双语段落;点击或选中日文/中文段落会打开右侧审校面板,可直接修改中文译文、标记问题类型/严重程度/标签/备注/长期规则,配置 API 与术语表,执行单段重翻、生成反馈并导出修订 EPUB。
`0.16.1` 优化 Readest 原生阅读页审校面板:桌面端右侧面板改为停靠布局,打开后压缩正文而不是遮挡右侧文字;移除面板内独立段落列表,把 API 与提示词、术语表放到面板底部,并明确区分标签、本段备注和长期规则建议的用途。
## 独立安装
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub-review-editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
@@ -4,13 +4,13 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>双语 EPUB 审校编辑器</title>
<link rel="stylesheet" href="/static/style.css?v=0.16.0">
<link rel="stylesheet" href="/static/style.css?v=0.16.1">
</head>
<body>
<header class="topbar">
<button id="bookshelfBtn" class="bookshelfNavButton" type="button">书架</button>
<div class="brandBlock">
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.16.0</span></h1>
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.16.1</span></h1>
<p id="sessionMeta">正在载入……</p>
</div>
<div class="modeTabs" role="tablist" aria-label="审校模式">
@@ -509,6 +509,6 @@
</aside>
<div class="toast" id="toast" hidden></div>
<script src="/static/app.js?v=0.16.0"></script>
<script src="/static/app.js?v=0.16.1"></script>
</body>
</html>
@@ -1,4 +1,4 @@
version = "0.16.0"
version = "0.16.1"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",