forked from akai/readest
fix: refine inline review panel layout
This commit is contained in:
@@ -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 注册 session,Readest 页面 URL 应改用 `session_id`,避免长期暴露本机 EPUB 路径。
|
||||
- Readest `/review-editor` 的主路径必须是原生 React 功能块,校对和翻译功能直接调用本地 sidecar API;旧 `static/index.html` 界面只保留为独立启动、调试或应急 fallback,不再作为桌面迁移验收的主界面。若 React 直接访问 loopback sidecar,sidecar 只能对本机 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 或兼容性小修",
|
||||
|
||||
Reference in New Issue
Block a user