feat(editor): add translation launcher workflow

This commit is contained in:
xin61
2026-07-08 15:59:39 +08:00
parent 0ccd14cf67
commit f66ce37478
20 changed files with 3851 additions and 241 deletions
+103 -6
View File
@@ -2,9 +2,9 @@
## 目标与边界
本工具服务于中日双语 EPUB 的网页审校:长篇连续阅读、逐段修改中文译文、标记翻译问题、导出反馈与修订 EPUB。翻译质量规则仍由项目根目录的 `AGENTS.md``rules/translation_rules.md`、当前系列的 `series-style.md` 与术语表共同约束。
本工具服务于中日双语 EPUB 的网页审校:长篇连续阅读、逐段修改中文译文、标记翻译问题、导出反馈与修订 EPUB。它也提供可选的书架入口 AI 翻译模块,用于对已入书架的日语 EPUB 发起 API 辅助首译,并生成中日双语或纯中文 EPUB。翻译质量规则仍由项目根目录的 `AGENTS.md``rules/translation_rules.md`、当前系列的 `series-style.md` 与术语表共同约束。
审校器不负责整卷首译流程。用户在网页中留下的编辑、问题备注、长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。网页术语表编辑是用户显式操作正式术语表的入口,保存时会直接写入配置路径指向的 JSON 文件。
AI 翻译模块是可选辅助首译入口,不替代长期项目里的候选词确认、复审、QA 与正式交付流程。用户在网页中留下的编辑、问题备注、长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。网页术语表编辑是用户显式操作正式术语表的入口,保存时会直接写入配置路径指向的 JSON 文件。
## 数据模型与会话目录
@@ -14,6 +14,12 @@
epub_review_sessions/
app_state.json
gpt_config.json
translation_jobs/
<job-id>/
job.json
translations.json
translated_outputs/
*_AI翻译_*.epub
<session-id>/
source.epub
extracted/
@@ -36,6 +42,9 @@ epub_review_sessions/
- `session.json``app_state.json` 只保存会话定位信息。
- `gpt_config.json` 只属于本地运行环境,不写入 EPUB、不写入导出文件、不通过 API 回传密钥。
- `gpt_config.json` 可保存本地提示词设置与术语表路径,但仍不得写入 EPUB 或导出文件。
- `translation_jobs/<job-id>/job.json` 保存整书 AI 翻译任务状态、进度、输出路径和日志;不得保存 API Key。
- `translated_outputs/` 保存 AI 翻译生成的 EPUB;生成后应创建独立 session,使 `/api/sessions` 和书架自然可见。
- 项目根目录的 `打开EPUB审校器.cmd` 是用户一键入口,委托 `tools/epub_review_editor/open_editor.ps1` 创建 `.venv`、安静检测 Flask 依赖、缺失时自动安装依赖、复用同版本已运行服务或启动 `server.py --daemon` 并打开浏览器;该入口不得硬编码用户私有路径。
## 目录与插图
@@ -60,12 +69,19 @@ epub_review_sessions/
验收口径:
- 顶栏左上角必须保留“书架”入口;书架是独立页面,列出 `--review-root` 下历史打开过的审校书籍/会话。
- 点击书架条目必须通过会话打开流程进入对应审校页面,并恢复该书上次阅读位置;从审校页进入书架或切换书籍前必须保存当前阅读位置并沿用未保存编辑保护。
- 没有历史书籍时,书架显示空状态并提供“打开新的 EPUB”入口;开始页/书架页顶栏保持正常显示,不套用审校页沉浸隐藏。
- 初始进入审校时,左侧侧栏隐藏,正文占主要空间。
- 顶部“目录”只打开目录,“检索”只打开搜索/段落列表,不合并成一个常驻面板。
- 目录可展开/收起章节,插图作为所属章节的次级目录条目可点击阅读,文字 part 可点击定位。
- 点击目录章节、part 或插图条目后,侧栏不得自动收起;用户仍可点“隐藏”或按 `Escape` 关闭。
- 检索必须保留“当前范围 / 全书全局”切换;当前范围随当前章节或 part 更新,全局检索可跨章节返回并跳转。
- 阅读模式不显示每段编号、快速编辑、快速标记按钮。
- 阅读区提供默认、护眼、夜间背景切换,并允许调整字号与行距;设置可保存在浏览器本地,不要求写入 EPUB 或 session。
- 审校页顶部标题栏与阅读标题栏默认收起,鼠标移到页面上方或键盘焦点进入标题栏时再显示;开始页保持正常显示;展开后不得遮住侧栏“隐藏”或审校工具“关闭”等面板操作。
- 鼠标停留在顶栏或阅读标题栏时,沉浸顶栏不得因自动计时过早收起;前端脚本和样式发布时应带版本参数或等效缓存失效机制,避免浏览器继续执行旧入口代码。
- 阅读区“上一节 / 下一节”必须按 spine/目录内的小章节顺序移动,覆盖文字 part 与插图条目,不得只跳顶层大章节。
- 点击正文段落打开右侧精修与重翻面板。
- 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。
- 刷新页面、重启服务或从已有会话重新打开 EPUB 时,应恢复到上次阅读的章节/part/插图、段落和滚动位置。
@@ -98,9 +114,10 @@ GPT 能力是可选增强,不应影响离线审校。
- API Key 优先读取 `gpt_config.json`,也支持环境变量 `OPENAI_API_KEY`
- `/api/gpt/config` 不得返回 API Key。
- `/api/gpt/config` 可以返回提示词与预设,但不得返回密钥。
- `/api/glossary` 读取和保存配置路径指向的 `mingcibiao.json`;默认路径是项目根目录下当前 TRPG 系列的正式术语表
- `/api/glossary` 读取和保存配置路径指向的 `mingcibiao.json`;默认路径应由当前打开 EPUB 对应的系列目录或用户保存的配置决定,不应在通用维护口径中硬编码某个具体系列
- 未保存配置时,可在项目根目录下唯一的一级系列目录 `mingcibiao.json` 中自动推断默认术语表;若无法唯一推断,则回退到项目根目录 `mingcibiao.json` 并允许用户在网页中显式设置。
- 术语表保存应保持 JSON 对象结构:key 为源语或匹配词,value 为译名,可继续使用 `#` 作为备注分隔。
- 重翻时必须实时读取术语表,按目标段落日文、HTML 与当前中文命中相关条目,不得只在提示词里泛泛要求“遵守术语表”。
- 重翻时必须实时读取术语表,按目标段落日文纯文本、日文 HTML 与 ruby/rt 命中相关条目,不得只在提示词里泛泛要求“遵守术语表”。
- 未配置 Key 时,重翻接口应返回可读错误,不影响编辑保存。
- 重翻请求不得临时覆盖 `base_url``model` 后继续复用已保存密钥;要更换端点或模型必须先保存配置,避免把密钥发往未确认端点。
@@ -108,20 +125,74 @@ GPT 能力是可选增强,不应影响离线审校。
1. 用户点击阅读段落,打开右侧面板。
2. 用户可填写额外重翻要求。
3. `/api/row/<row_id>/retranslate` 使用提示词、目标语句、当前中文、实时命中的术语表条目、同文件前后一段少量上下文、角色口吻提示生成候选。
3. `/api/row/<row_id>/retranslate` 使用提示词、目标语句、实时命中的术语表条目、同文件前后一段少量上下文、角色口吻提示生成候选。
4. 候选只显示在面板里,不自动覆盖。
5. 用户点击“应用重翻”后写入编辑框,再点击保存才进入审校记录。
6. 候选事件写入 jsonl,便于后续根据精修反馈优化提示词;当前版本不自动改写提示词。
术语实时命中实现口径:
- 每次 `/api/row/<row_id>/retranslate` 请求都必须读取当前配置的 glossary 文件,不使用服务启动时的缓存作为唯一来源。
- 单段重翻的术语命中范围只包括目标段落日文纯文本和日文 HTML 中的可见文本/ruby/rb/rt 信息;当前中文译文不能反向触发新术语,避免把旧误译或幻觉带入 prompt。
- 传给 GPT 的目标日文 HTML 只保留必要内联标签和 ruby 结构,不携带 class/style/data-* 等属性,避免属性值污染术语命中或 prompt。
- 前后一段上下文只用于语气、指代和连贯性参考,不参与单段重翻的术语命中;单段重翻的 `glossary_matches` 只由目标段落自身触发。
- 正式术语表 value 如包含 `#``#` 前是指定译名,`#` 后只是备注,不写入正文。
- 未命中术语时,重翻 prompt 不得塞入无关术语兜底规则;例如只有原文出现 `ファルシオン``Falchion` 时,才可命中“大砍刀”。
- 读取旧版保存提示词时,若只剩“优先服从给定名词表”这类泛泛规则,应回退到当前内置预设,避免用户继续沿用已淘汰的全局术语设计。
- 任何术语特例都不得写入全局 prompt。Falchion / ファルシオン / 大砍刀 只能由 `glossary_matches` 动态生成;未命中时,重翻 messages 中不得出现这些字符串。
- 命中结果必须随候选事件写入 jsonl,字段至少包含 `glossary_path``glossary_matches`;未命中时记录空数组,便于排查 prompt。
Prompt 第一性原理:
- prompt 必须只携带目标段落、少量上下文、命中术语、系列风格摘要和输出格式;单段重翻不携带完整术语表。
- prompt 不得只泛泛要求遵守术语表,必须列出本段命中的具体术语;未命中的术语不得进入提示。
- prompt 必须要求只输出修订后的中文 HTML,不输出说明。
- prompt 必须要求不合并、不拆分段落,不删除段落编号或必要标签。
- prompt 必须要求保留有意义 ruby 为真实 `<ruby><rb>...</rb><rt>...</rt></ruby>`
Prompt 硬规则:
- 输出简体中文轻小说文风。
- 意义优先,不贴日语语序硬译。
- 不合并、不拆分段落。
- 有意义 ruby 必须保留为真实 `<ruby><rb>...</rb><rt>...</rt></ruby>`,不要改成括号。
- Falchion / ファルシオン 固定译为“大砍刀”,不要译成“偃月刀”。
- 标点统一为简中出版格式:对话引号用「」,省略号用……,破折号用——,问号用?,感叹号用!,问叹连用用?!/!?。
## 整书 AI 翻译
整书 AI 翻译从书架页进入,只处理已经上传或打开过、因此已有 session 的 EPUB。它必须作为后台任务运行,前端只负责创建任务、轮询进度、展示日志和打开输出 session。
后端接口口径:
- `GET /api/translation/defaults` 返回当前公开 GPT 配置、默认提示词、默认术语表路径、默认范围和输出模式,不返回 API Key。
- `GET /api/session/<session_id>/translation-source` 只读取指定 session,统计可翻译日文段落和样例,不切换 active session。
- `POST /api/translation/start` 创建后台任务并快速返回 `job_id`;请求不得临时覆盖 `base_url``model` 后复用已保存密钥,要更换端点或模型必须先保存配置。
- `GET /api/translation/jobs/<job_id>` 返回任务状态、进度、日志、输出 EPUB 和输出 session id,不返回 API Key。
翻译输入与提示词:
- 日语源 EPUB 需要新的源段落抽取器,不复用只识别双语灰字的 `build_rows()`
- 默认范围必须是前 N 段试译,当前默认 20 段;全书翻译必须由用户显式选择。
- 每段请求只携带目标段落日文 HTML、前后一段少量上下文、该段实时命中的术语、翻译提示词、格式提示词与角色口吻提示词。
- 不得把完整术语表塞进 prompt;未命中的术语不得进入 prompt。
- 输出只接受当前段中文 HTML 片段,随后进行中文标点规整、HTML sanitize 与术语 ruby 括号形式修复。
输出与书架:
- `bilingual` 模式在原日文 `<p>` 后插入中文 `<p>`,并把原日文段落标为灰色,方便进入现有双语审校器逐段审校。
- `translated` 模式用中文替换原日文段落;该输出会加入书架,但由于没有日中成对段落,可能没有逐段双语审校行。
- 图片、样式、OPF、spine 和非正文文件应原样保留。
- 翻译失败的单段必须写入可见占位和任务日志,不能让整本输出静默缺段。
- 输出 EPUB 创建 session 时不应自动切换用户当前 active session;只有用户点击“打开输出审校”才切换。
任务安全与恢复:
- 后台 job 不能在网络调用期间持有全局锁;只在写状态文件时短暂加锁或原子写入。
- `job.json` 和中间 `translations.json` 必须原子写入,避免前端轮询读到半截 JSON。
- 日志、状态、EPUB 元数据和反馈文件不得包含 API Key。
- 任务失败时应将状态置为 `failed` 并写清楚错误,不能让前端永久停在 running。
- 后续如加入暂停/取消/恢复,必须保持现有 `job.json` 字段向后兼容。
## 版本规则
版本号使用 `version = "0.1.0"` 格式:
@@ -139,12 +210,21 @@ Prompt 硬规则:
- `README.md` 当前版本与版本说明
- 必要时同步 `MAINTENANCE.md` 中的维护口径
版本文档同步边界:
- 审校器自身行为、API、配置、前端交互变化,同步本文件。
- 会影响整个翻译项目的规则变化,同步项目根目录 `AGENTS.md``rules/translation_rules.md`,不要只写在审校器维护手册中。
- 对外最小包内容、启动方式或发布说明变化,同步 README。
## 回归清单
每轮改动至少检查:
- `python -m py_compile tools/epub_review_editor/server.py tools/epub_review_editor/version.py`
- `node --check tools/epub_review_editor/static/app.js`
- `powershell -NoProfile -ExecutionPolicy Bypass -File tools/epub_review_editor/open_editor.ps1` 能启动服务并打印 URL
- 从项目根目录执行 `打开EPUB审校器.cmd` 能启动服务;失败时窗口停留并显示错误;成功时浏览器打开书架/上传页
- 重复执行一键入口时优先复用同版本已运行服务;如需新起服务,端口探测不得允许多个服务同时监听同一端口
- `git diff --check`
- `/api/version`
- `/api/session`
@@ -158,4 +238,21 @@ Prompt 硬规则:
- 配置保存后 `/api/gpt/config` 不包含密钥字段
- `/api/glossary` 能读取术语表;新增、修改、删除条目后保存,再读取内容一致
- 重翻候选事件包含 `glossary_path``glossary_matches`
- 无关段落的重翻 messages 不得包含 `大砍刀``Falchion``ファルシオン`;含 `ファルシオン` 或 ruby `Falchion` 的段落必须命中“大砍刀”
- 顶栏复杂操作收敛在“审校工具”入口;未选择段落时仍可配置 AI、术语表并执行反馈与交付,段落编辑/重翻按钮禁用
- `/api/reading-position` 能保存/读取位置;刷新或重启后回到上次阅读处
- 阅读外观默认/护眼/夜间、字号、行距切换即时生效,刷新后仍保留;夜间模式下正文、原文、按钮和侧栏文本可读
- 审校页顶部栏和阅读标题栏默认不遮挡正文,鼠标移到页面上方后可操作;开始页顶栏不隐藏;目录侧栏和审校工具打开时,其顶部按钮仍可直接点击
- 鼠标停留在顶栏按钮区域时,书架/打开 EPUB/审校工具按钮能连续点击,不会在点击前自动收起;浏览器重新加载后应使用当前版本 app.js/style.css
- “上一节 / 下一节”在相邻 part 和章节内插图之间移动,不再按顶层大章节跳转
- 顶栏左上角“书架”能打开独立书架页;书架列出历史会话,空状态可进入上传页,点击书籍可进入对应审校页并恢复阅读位置
- 书架卡片“翻译”按钮能进入独立翻译页,且不会触发卡片的“进入审校”
- `/api/translation/defaults` 不返回 API Key,提示词和术语表路径可读取
- `/api/session/<session_id>/translation-source` 能统计日语 EPUB 段落;非日语或无正文时返回清晰空状态
- 未配置 API Key 时,`/api/translation/start` 返回清晰错误,不影响阅读、编辑、导出和单段重翻
- 启动整书翻译任务后,`/api/translation/jobs/<job_id>` 可看到 pending/running/completed/failed、总段数、已完成数、当前文件和日志
- 默认翻译范围是前 20 段试译;全书翻译必须由用户显式切换并确认
- 整书翻译每段只注入该段源文/ruby 命中的术语,不注入无关术语或全表
- 整书翻译完成后,输出 EPUB 写入 `translated_outputs/` 并自动出现在书架;中日双语输出打开后 `row_count > 0`
- 纯中文输出可以加入书架,且文档说明其不适合逐段双语审校
- 离开翻译页、返回书架或打开输出审校时,前端进度轮询会停止
+42 -7
View File
@@ -1,6 +1,6 @@
# 双语 EPUB 审校编辑器
当前版本:`0.8.0`
当前版本:`0.12.2`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
@@ -26,12 +26,26 @@
`0.5.0` 改进长篇目录与检索:目录章节标题不再用正文首段猜测,章节内插图作为次级条目挂在所属章节下,阅读时可按原 spine 顺序穿插显示;点击目录不会自动收起侧栏,桌面端正文会为常驻侧栏让位;检索新增“当前范围 / 全书全局”切换。
`0.6.0` 新增 GPT 提示词设置:可在网页中单独配置“翻译内容提示词”“格式与标点提示词”“角色状态与口吻提示词”,并提供默认预设;单段重翻只携带目标语句、当前译文、相关名词表、少量前后文和角色口吻提示,候选输出会进行中文标点规整与术语 ruby 括号形式修正。
`0.6.0` 新增 GPT 提示词设置:可在网页中单独配置“翻译内容提示词”“格式与标点提示词”“角色状态与口吻提示词”,并提供默认预设;单段重翻只携带目标语句、相关名词表、少量前后文和角色口吻提示,候选输出会进行中文标点规整与术语 ruby 括号形式修正。
`0.7.0` 新增实时术语表能力:GPT 重翻会从配置的 `mingcibiao.json` 实时读取命中术语并写入提示词;网页中可查看当前术语表路径、搜索术语,新增、修改、删除条目并保存,保存后的术语会立即用于下一次重翻。
`0.8.0` 新增阅读位置恢复:阅读器会自动记录当前章节/part/插图、当前可见段落和滚动位置;重新打开服务、刷新页面或切回已有会话时,会回到上次阅读的位置。
`0.9.0` 收敛重翻与审校工具入口:顶栏复杂操作统一到“审校工具”;单段重翻只携带目标段落真实命中的术语,移除无关段落里的 Falchion/大砍刀全局注入,并按第一性原理精简 prompt。
`0.10.0` 新增阅读外观与沉浸阅读:阅读区可切换默认、护眼、夜间背景,并保存字号与行距;审校页顶部栏和阅读标题栏默认收起,鼠标移到页面上方时再显示;阅读区上一节/下一节按 part 或插图小章节移动。
`0.11.0` 新增书架页面:顶栏左上角提供“书架”入口,集中列出历史打开过的审校书籍;点击书籍可直接进入对应审校页面,并继续沿用该书的上次阅读位置。
`0.11.1` 修复沉浸顶栏在鼠标停留时过早收起的问题,并给前端静态资源加入版本参数,避免浏览器缓存旧脚本导致新入口不可用。
`0.12.0` 新增书架入口的 AI 翻译模块:可从书架选择日语 EPUB,设置 OpenAI 兼容 API、提示词、术语表、翻译范围与输出模式,后台显示翻译进度;完成后生成的中日双语或纯中文 EPUB 会自动加入书架。
`0.12.1` 新增一键启动入口:项目根目录提供 `打开EPUB审校器.cmd`,会自动准备本地 Python 环境、安装依赖并以前台浏览器打开书架/上传页。
`0.12.2` 修复一键启动入口首次运行时依赖检测会被 Flask 缺失的 traceback 中断的问题;现在会先安静检测依赖,缺失时自动安装,再启动网页工具。重复打开入口时会优先复用同版本已运行服务,并修正端口探测,避免多个服务同时监听同一端口。
## 独立安装
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
@@ -50,6 +64,14 @@ python3 -m venv .venv
## 启动
最方便的方式是在项目根目录双击:
```text
打开EPUB审校器.cmd
```
第一次运行会在 `tools/epub_review_editor/.venv` 创建本地 Python 环境并安装依赖;之后会直接启动服务并打开浏览器。
不预先指定 EPUB,直接打开上传界面:
```powershell
@@ -83,11 +105,17 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio
- 启动时不带 EPUB 路径,会进入开始页。
- 点击“选择 EPUB 文件”,上传中日双语 EPUB 后会自动创建审校会话。
- 开始页会列出已有会话,换电脑或重启服务后可以继续打开之前的审校记录。
- 顶栏左上角“书架”会列出历史打开过的书籍,适合在多本书之间快速切换;点击书籍后会进入对应审校页面。
- 书架中每本书都有“翻译”按钮,可对日语 EPUB 发起 AI 翻译任务;输出 EPUB 会自动加入书架。
- 上传的 EPUB、解包副本、反馈与导出文件都会保存在 `--review-root` 指定目录下。
## 能做什么
- 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。
- 顶栏左上角可进入“书架”,集中查看历史打开过的书籍、审校进度和最近阅读记录,并跳转回对应审校页面。
- 阅读区可切换默认、护眼、夜间背景,并调整字号与行距;设置会保存在当前浏览器中,下次打开继续沿用。
- 审校页顶部标题栏与阅读标题栏默认隐藏,鼠标移到页面上方时显示,减少长文阅读时的遮挡。
- 阅读区“上一节 / 下一节”按 part 或插图小章节移动,不再只跳顶层大章节。
- 每个审校会话会自动保存上次阅读位置,下次打开会回到上次所在章节、part、插图或段落附近。
- 左侧目录和检索默认隐藏,通过顶部“目录”“检索”两个按钮分别打开,不会把两种入口混在同一个常驻侧栏里。
- 目录优先按 EPUB 内部目录页或 nav/toc 组织章节;章节下显示内部 `part0000` 形式的 part 与次级插图条目,可展开/收起,点击 part 后只看该 part,点击章节则按原 spine 顺序连续阅读正文和插图。
@@ -95,8 +123,9 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio
- 检索支持“当前范围”和“全书全局”两种范围,适合在单章精查和整本书排查术语时切换。
- 在阅读模式中点击段落即可打开右侧精修与重翻面板,直接编辑中文译文、标记问题或调用 GPT 重翻;打开/关闭面板时会尽量保持原来的阅读位置。
- 精修模式保留原有逐段编辑界面,用于较细的逐段复审。
-在网页中配置 OpenAI 兼容的 `base_url``model`、API Key重翻提示词对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
- 可在“提示词与 API”里配置并读取正式术语表,实时搜索、增删改术语并保存;单段重翻会按当前段落命中术语表条目,而不是只在提示词里泛泛要求遵守术语。
-从“审校工具”统一配置 OpenAI 兼容的 `base_url``model`、API Key重翻提示词与术语表,并对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
- 可在“审校工具”的“AI 设置与术语表”里配置并读取正式术语表,实时搜索、增删改术语并保存;单段重翻会按当前段落命中术语表条目,而不是只在提示词里泛泛要求遵守术语。
- 可从书架进入“AI 翻译 EPUB”页面,对日语 EPUB 设置翻译内容提示词、格式与标点提示词、角色口吻提示词、术语表路径、翻译范围、输出为中日双语或纯中文 EPUB;任务在后台运行,页面实时显示进度与日志。
- 直接编辑中文译文。
- 标记问题段落或选中文本。
- 记录问题类型、严重程度、标签、备注和可沉淀的长期规则。
@@ -125,23 +154,29 @@ epub_review_sessions\<epub-name>_<hash>\
- `exports/*_网页审校版_*.epub`:导出的修订版 EPUB。
- `review_state/state.json`:网页编辑器当前状态。
- `extracted/`EPUB 解包后的工作副本。
- `translation_jobs/<job-id>/job.json`AI 翻译任务状态、进度和日志。
- `translated_outputs/*_AI翻译_*.epub`:AI 翻译模块生成并自动加入书架的 EPUB。
在服务器上使用时,“生成反馈”和“导出审校 EPUB”会返回浏览器可访问的下载地址,不需要登录服务器找文件。
## 使用建议
- 正式长篇 EPUB 优先使用“阅读模式”:先在左侧点章节/part 连续读,看到问题再点段落快速编辑或标记。
- 同时审校多本书时,优先从顶栏左上角进入“书架”,再选择目标书籍;从书架进入会恢复该书上次阅读位置。
- 需要集中逐段复审时切到“精修模式”,左侧段落列表会跟随当前章节筛选。
- 只想记录问题时:勾选“标记为翻译问题”,写备注,保存本段。
- 想直接修正译文时:编辑中文译文后保存本段。
- 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“Falchion 固定译为大砍刀,不要译成偃月刀”。
- 想单段重翻时:点击段落打开右侧面板,先在“提示词与 API”中保存 GPT 配置;提示词分为翻译内容、格式标点、角色口吻三块,可恢复预设。术语表路径默认指向本项目的 `TRPG玩家在异世界打造最强角色/mingcibiao.json`,也可以改成其他 `mingcibiao.json`。再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。
-调整术语时:展开“提示词与 API”,在术语表区域搜索、修改或新增条目,点击“保存术语表”;下一次重翻会重新读取已保存的最新术语
- 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“某个术语只在原文实际出现时才进入重翻提示”。
- 想单段重翻时:点击段落打开“审校工具”,先在“AI 设置与术语表”中保存 GPT 配置;提示词分为翻译内容、格式标点、角色口吻三块,可恢复预设。术语表路径会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断,也可以改成其他 `mingcibiao.json`。再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。
-整书 AI 翻译时:先上传或打开日语 EPUB,让它出现在书架里;在书架卡片点击“翻译”,保存 API 设置与术语表路径,默认先做前 20 段试译。确认效果后再把范围切换为全书。中日双语输出可直接进入审校;纯中文输出会入书架,但没有逐段双语审校行
- 术语表路径默认会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断;如果无法唯一推断,请在页面里填写目标术语表路径。
- 想调整术语时:打开“审校工具”的“AI 设置与术语表”,在术语表区域搜索、修改或新增条目,点击“保存术语表”;下一次重翻会重新读取已保存的最新术语。
- 审校结束后点击“生成反馈”,再在聊天里告诉 Codex 读取 `feedback_for_codex.md``translation_feedback.jsonl`
## 当前限制
- 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。
- AI 翻译模块是可选辅助首译入口,不替代正式长卷的候选词确认、复审和 QA;默认试译前 20 段,避免误触发全书 API 成本。
- 目录用于审校导航;插图可以阅读和跳转,但当前版本不提供图片内容编辑。
- GPT API Key、提示词与术语表路径仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;重翻时只能使用已保存的 Base URL 和模型,避免把密钥发往未确认端点。术语表编辑会直接写入配置路径指向的 JSON 文件,服务器部署时请保护好审校目录和术语表,不要把没有鉴权的服务暴露到公网。
- 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。
+134
View File
@@ -0,0 +1,134 @@
$ErrorActionPreference = "Stop"
$toolRoot = Split-Path -Parent $PSCommandPath
$projectRoot = (Resolve-Path (Join-Path $toolRoot "..\..")).Path
$serverPath = Join-Path $toolRoot "server.py"
$reviewRoot = Join-Path $projectRoot "epub_review_sessions"
$venvRoot = Join-Path $toolRoot ".venv"
$venvPython = Join-Path $venvRoot "Scripts\python.exe"
$requirements = Join-Path $toolRoot "requirements.txt"
$versionFile = Join-Path $toolRoot "version.py"
$defaultPort = 5177
$env:PYTHONIOENCODING = "utf-8"
function Get-PythonCommand {
$pyLauncher = Get-Command py -ErrorAction SilentlyContinue
if ($pyLauncher) {
return [pscustomobject]@{ Exe = $pyLauncher.Source; Args = @("-3") }
}
$python = Get-Command python -ErrorAction SilentlyContinue
if ($python) {
return [pscustomobject]@{ Exe = $python.Source; Args = @() }
}
throw "Python was not found. Install Python 3, then run this launcher again."
}
function Invoke-PythonCommand {
param(
[Parameter(Mandatory = $true)]
[string]$Exe,
[string[]]$Args = @()
)
& $Exe @Args
return $LASTEXITCODE
}
function Test-FlaskInstalled {
& $venvPython -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('flask') else 1)" 2>$null
return ($LASTEXITCODE -eq 0)
}
function Get-ExpectedVersion {
if (!(Test-Path $versionFile)) {
return ""
}
$content = Get-Content -Raw -Encoding UTF8 $versionFile
$match = [regex]::Match($content, 'version\s*=\s*"([^"]+)"')
if ($match.Success) {
return $match.Groups[1].Value
}
return ""
}
function Test-LocalPortOpen {
param(
[Parameter(Mandatory = $true)]
[int]$Port
)
$client = New-Object System.Net.Sockets.TcpClient
try {
$async = $client.BeginConnect("127.0.0.1", $Port, $null, $null)
if (!$async.AsyncWaitHandle.WaitOne(100, $false)) {
return $false
}
$client.EndConnect($async)
return $true
} catch {
return $false
} finally {
$client.Close()
}
}
function Get-RunningEditorUrl {
$expectedVersion = Get-ExpectedVersion
for ($port = $defaultPort; $port -lt ($defaultPort + 100); $port++) {
if (!(Test-LocalPortOpen -Port $port)) {
continue
}
$url = "http://127.0.0.1:$port"
try {
$versionPayload = Invoke-RestMethod -Uri "$url/api/version" -TimeoutSec 1 -ErrorAction Stop
if (!$expectedVersion -or $versionPayload.version -eq $expectedVersion) {
return "http://localhost:$port"
}
} catch {
continue
}
}
return ""
}
$runningUrl = Get-RunningEditorUrl
if ($runningUrl) {
Write-Host "EPUB review editor is already running."
Start-Process $runningUrl
Write-Host $runningUrl
Write-Host "review root: $reviewRoot"
exit 0
}
if (!(Test-Path $venvPython)) {
Write-Host "Creating local Python environment..."
$python = Get-PythonCommand
$venvArgs = @()
if ($python.Args) {
$venvArgs += $python.Args
}
$venvArgs += @("-m", "venv", $venvRoot)
if ((Invoke-PythonCommand -Exe $python.Exe -Args $venvArgs) -ne 0) {
throw "Failed to create local Python environment."
}
}
if (!(Test-FlaskInstalled)) {
Write-Host "Installing review editor dependencies..."
& $venvPython -m pip install -r $requirements
if ($LASTEXITCODE -ne 0) {
throw "Failed to install dependencies from requirements.txt."
}
if (!(Test-FlaskInstalled)) {
throw "Dependencies were installed, but Flask is still unavailable in the local Python environment."
}
}
Write-Host "Starting EPUB review editor..."
& $venvPython $serverPath --review-root $reviewRoot --daemon
if ($LASTEXITCODE -ne 0) {
throw "Failed to start EPUB review editor."
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+230 -74
View File
@@ -4,12 +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">
<link rel="stylesheet" href="/static/style.css?v=0.12.2">
</head>
<body>
<header class="topbar">
<button id="bookshelfBtn" class="bookshelfNavButton" type="button">书架</button>
<div class="brandBlock">
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.8.0</span></h1>
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.12.2</span></h1>
<p id="sessionMeta">正在载入……</p>
</div>
<div class="modeTabs" role="tablist" aria-label="审校模式">
@@ -20,13 +21,141 @@
<button id="tocPanelBtn" type="button">目录</button>
<button id="searchPanelBtn" type="button">检索</button>
<button id="openLibraryBtn" type="button">打开 EPUB</button>
<button id="showTouchedBtn" type="button">仅看已记录</button>
<button id="feedbackBtn" type="button">生成反馈</button>
<button id="applyBtn" type="button">写回 EPUB 内容</button>
<button id="exportBtn" type="button">导出审校 EPUB</button>
<button id="reviewToolsBtn" type="button">审校工具</button>
</div>
</header>
<main class="bookshelfScreen" id="bookshelfScreen" hidden>
<section class="bookshelfPanel">
<div class="bookshelfHeader">
<div>
<h2>书架</h2>
<p>这里列出这个审校目录里历史打开过的 EPUB。点击一本书即可回到对应审校页面。</p>
</div>
<div class="bookshelfActions">
<button id="bookshelfRefreshBtn" type="button">刷新书架</button>
<button id="bookshelfOpenUploadBtn" class="primary" type="button">打开新的 EPUB</button>
</div>
</div>
<div class="bookshelfMeta" id="bookshelfMeta">正在读取书架……</div>
<div class="bookshelfGrid" id="bookshelfList"></div>
</section>
</main>
<main class="translationScreen" id="translationScreen" hidden>
<section class="translationPanel">
<div class="translationHeader">
<div>
<h2>AI 翻译 EPUB</h2>
<p id="translationBookMeta">选择书架中的日语 EPUB 后,可在这里设置提示词、术语表、范围和输出格式。</p>
</div>
<div class="translationHeaderActions">
<button id="translationBackBookshelfBtn" type="button">返回书架</button>
<button id="translationOpenReviewBtn" class="primary" type="button" disabled>打开输出审校</button>
</div>
</div>
<section class="translationGrid">
<form class="translationSettings" id="translationForm">
<section class="translationCard">
<div class="sectionTitle compactTitle">
<h2>输出与范围</h2>
<span id="translationSourceMeta">未读取源书信息</span>
</div>
<div class="translationOptions">
<label>
输出格式
<select id="translationOutputModeInput">
<option value="bilingual">中日双语 EPUB</option>
<option value="translated">纯中文 EPUB</option>
</select>
</label>
<label>
翻译范围
<select id="translationRangeModeInput">
<option value="limit">前 N 段试译</option>
<option value="all">全书</option>
</select>
</label>
<label>
段数
<input id="translationLimitInput" type="number" min="1" max="5000" value="20">
</label>
<label>
Temperature
<input id="translationTemperatureInput" type="number" min="0" max="1" step="0.1" value="0.2">
</label>
</div>
<div class="translationSample" id="translationSample"></div>
</section>
<section class="translationCard">
<div class="sectionTitle compactTitle">
<h2>API 与术语表</h2>
<span id="translationGptStatus">未读取配置</span>
</div>
<div class="translationOptions apiOptions">
<label>
Base URL
<input id="translationBaseUrlInput" type="url" placeholder="https://api.openai.com/v1">
</label>
<label>
Model
<input id="translationModelInput" type="text" placeholder="gpt-4o-mini">
</label>
<label>
API Key
<input id="translationApiKeyInput" type="password" placeholder="留空则保留已有密钥">
</label>
<label>
术语表路径
<input id="translationGlossaryPathInput" type="text" placeholder="mingcibiao.json 的完整路径或项目内相对路径">
</label>
</div>
<div class="translationActions">
<button id="translationSaveConfigBtn" type="button">保存 API 设置</button>
</div>
</section>
<section class="translationCard">
<div class="sectionTitle compactTitle">
<h2>提示词</h2>
<button id="translationResetPromptsBtn" type="button">恢复预设</button>
</div>
<label>
翻译内容提示词
<textarea id="translationPromptInput" rows="5"></textarea>
</label>
<label>
格式与标点提示词
<textarea id="translationFormatPromptInput" rows="5"></textarea>
</label>
<label>
角色状态与口吻提示词
<textarea id="translationCharacterPromptInput" rows="4"></textarea>
</label>
<div class="translationActions">
<button id="translationStartBtn" class="primary" type="submit">开始翻译</button>
</div>
</section>
</form>
<aside class="translationProgressCard">
<div class="sectionTitle compactTitle">
<h2>翻译进度</h2>
<span id="translationJobMeta">尚未开始</span>
</div>
<div class="translationProgress">
<div class="translationProgressBar" id="translationProgressBar"></div>
</div>
<div class="translationProgressText" id="translationProgressText">等待开始翻译。</div>
<div class="translationOutput" id="translationOutput" hidden></div>
<div class="translationLogs" id="translationLogs"></div>
</aside>
</section>
</section>
</main>
<main class="startScreen" id="startScreen" hidden>
<section class="startPanel">
<div class="startHeader">
@@ -92,8 +221,25 @@
<h2 id="readerTitle">选择章节开始阅读</h2>
</div>
<div class="readerTools">
<button id="prevPartBtn" type="button">上一章</button>
<button id="nextPartBtn" type="button">下一章</button>
<div class="appearanceControls" aria-label="阅读外观">
<div class="themeSwitch" role="group" aria-label="背景模式">
<button id="themeDefaultBtn" type="button" data-reader-theme="default">默认</button>
<button id="themeEyeBtn" type="button" data-reader-theme="eye">护眼</button>
<button id="themeNightBtn" type="button" data-reader-theme="night">夜间</button>
</div>
<label class="readerSlider">
<span>字号</span>
<input id="readerFontSizeInput" type="range" min="15" max="24" step="1" value="17">
<output id="readerFontSizeValue">17</output>
</label>
<label class="readerSlider">
<span>行距</span>
<input id="readerLineHeightInput" type="range" min="1.6" max="2.4" step="0.05" value="1.95">
<output id="readerLineHeightValue">1.95</output>
</label>
</div>
<button id="prevPartBtn" type="button">上一节</button>
<button id="nextPartBtn" type="button">下一节</button>
</div>
</div>
<article class="readingFlow" id="readingFlow"></article>
@@ -168,7 +314,7 @@
</label>
<label>
可沉淀为长期规则
<textarea id="learnNoteInput" rows="3" placeholder="例:Falchion 固定译为大砍刀,不要译成偃月刀。"></textarea>
<textarea id="learnNoteInput" rows="3" placeholder="例:某个术语只在原文实际出现时才进入重翻提示。"></textarea>
</label>
</section>
@@ -181,29 +327,81 @@
</section>
</main>
<aside class="quickEditor" id="quickEditor" hidden aria-label="精修与重翻面板">
<aside class="quickEditor" id="quickEditor" hidden aria-label="审校工具面板">
<div class="quickHeader">
<div>
<div class="rowId" id="quickRowId"></div>
<div class="rowPath" id="quickRowPath"></div>
</div>
<button id="closeQuickEditorBtn" type="button" aria-label="关闭精修">关闭</button>
<button id="closeQuickEditorBtn" type="button" aria-label="关闭审校工具">关闭</button>
</div>
<details class="quickSourceWrap" open>
<details class="toolSection quickSourceWrap" open>
<summary>日文原文</summary>
<section class="quickSource" id="quickJpText"></section>
</details>
<label class="quickField">
中文译文
<div id="quickCnEditor" class="cnEditor quickCnEditor" contenteditable="true" spellcheck="false"></div>
</label>
<section class="gptBox" id="gptBox">
<div class="sectionTitle">
<h2>GPT 重翻</h2>
<button id="toggleGptConfigBtn" type="button">提示词与 API</button>
<details class="toolSection" open>
<summary>当前段</summary>
<label class="quickField">
中文译文
<div id="quickCnEditor" class="cnEditor quickCnEditor" contenteditable="true" spellcheck="false"></div>
</label>
<div class="quickControls">
<label class="markToggle">
<input id="quickMarkedInput" type="checkbox">
标记问题
</label>
<select id="quickIssueTypeInput">
<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 id="quickSeverityInput">
<option value="">严重程度</option>
<option value="轻微">轻微</option>
<option value="中等">中等</option>
<option value="严重">严重</option>
</select>
</div>
<label class="quickField">
标签
<input id="quickTagsInput" type="text" placeholder="例:术语, 对话">
</label>
<label class="quickField">
问题备注
<textarea id="quickCommentInput" rows="3" placeholder="哪里翻得不好,或应该如何改。"></textarea>
</label>
<label class="quickField">
可沉淀为长期规则
<textarea id="quickLearnNoteInput" rows="2" placeholder="可复用的翻译偏好或禁忌。"></textarea>
</label>
<div class="quickActions">
<button id="quickMarkSelectionBtn" type="button">标记选中文本</button>
<button id="quickSaveBtn" type="button">保存当前段</button>
<button id="quickOpenFullBtn" type="button">打开完整精修</button>
</div>
</details>
<section class="gptBox toolSection" id="gptBox">
<div class="gptConfigTitle">AI 重翻</div>
<div class="gptStatus" id="gptStatus">未读取配置</div>
<div class="gptConfig" id="gptConfig" hidden>
<label class="quickField">
重翻要求
<textarea id="gptInstructionInput" rows="2" placeholder="可选:这次希望更口语、更忠实,或特别注意某个术语。"></textarea>
</label>
<div class="gptActions">
<button id="retranslateBtn" type="button">重翻本段</button>
<button id="applyRetranslationBtn" type="button" disabled>应用重翻</button>
</div>
<div class="gptCandidate" id="gptCandidate" hidden></div>
</section>
<details class="toolSection gptConfig" id="gptConfig" open>
<summary>AI 设置与术语表</summary>
<div class="gptConfigGroup">
<div class="gptConfigTitle">API</div>
<label>
@@ -251,63 +449,21 @@
</div>
<div class="gptConfigActions">
<button id="resetGptPromptsBtn" type="button">恢复预设提示词</button>
<button id="saveGptConfigBtn" type="button">保存提示词与 API</button>
<button id="saveGptConfigBtn" type="button">保存 AI 设置</button>
</div>
</details>
<details class="toolSection" open>
<summary>反馈与交付</summary>
<div class="deliveryActions">
<button id="showTouchedBtn" type="button">仅看已记录</button>
<button id="feedbackBtn" type="button">生成反馈文件</button>
<button id="applyBtn" type="button">写回 EPUB 内容</button>
<button id="exportBtn" type="button">导出审校 EPUB</button>
</div>
<label class="quickField">
重翻要求
<textarea id="gptInstructionInput" rows="2" placeholder="可选:这次希望更口语、更忠实,或特别注意某个术语。"></textarea>
</label>
<div class="gptActions">
<button id="retranslateBtn" type="button">重翻本段</button>
<button id="applyRetranslationBtn" type="button" disabled>应用重翻</button>
</div>
<div class="gptCandidate" id="gptCandidate" hidden></div>
</section>
<div class="quickControls">
<label class="markToggle">
<input id="quickMarkedInput" type="checkbox">
标记问题
</label>
<select id="quickIssueTypeInput">
<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 id="quickSeverityInput">
<option value="">严重程度</option>
<option value="轻微">轻微</option>
<option value="中等">中等</option>
<option value="严重">严重</option>
</select>
</div>
<label class="quickField">
标签
<input id="quickTagsInput" type="text" placeholder="例:术语, 对话">
</label>
<label class="quickField">
问题备注
<textarea id="quickCommentInput" rows="3" placeholder="哪里翻得不好,或应该如何改。"></textarea>
</label>
<label class="quickField">
可沉淀为长期规则
<textarea id="quickLearnNoteInput" rows="2" placeholder="可复用的翻译偏好或禁忌。"></textarea>
</label>
<div class="quickActions">
<button id="quickMarkSelectionBtn" type="button">标记选中文本</button>
<button id="quickSaveBtn" type="button">保存</button>
<button id="quickOpenFullBtn" type="button">打开完整精修</button>
</div>
</details>
</aside>
<div class="toast" id="toast" hidden></div>
<script src="/static/app.js"></script>
<script src="/static/app.js?v=0.12.2"></script>
</body>
</html>
+583 -66
View File
@@ -15,6 +15,20 @@
--source: #73777f;
--mark: #fff0a6;
--shadow: 0 18px 42px rgba(25, 34, 45, 0.18);
--hover-bg: #eef3f6;
--badge-bg: #e8edf2;
--success-soft: #e9f5ee;
--marked-soft: #fff0d5;
--toast-bg: #1f2933;
--topbar-offset: 78px;
--topbar-live-height: 78px;
--reader-bg: #f4f6f8;
--reader-paper: #ffffff;
--reader-ink: #222426;
--reader-source: #73777f;
--reader-border: #e7edf3;
--reader-font-size: 17px;
--reader-line-height: 1.95;
}
* {
@@ -30,6 +44,42 @@ body {
line-height: 1.65;
}
body[data-reader-theme="eye"] {
--reader-bg: #edf4ed;
--reader-paper: #fbfff8;
--reader-ink: #243127;
--reader-source: #6a7468;
--reader-border: #d7e4d4;
--bg: #edf4ed;
}
body[data-reader-theme="night"] {
color-scheme: dark;
--reader-bg: #171614;
--reader-paper: #211f1b;
--reader-ink: #e8e0d2;
--reader-source: #aaa192;
--reader-border: #38342d;
--bg: #171614;
--panel: #24221e;
--panel-soft: #2c2924;
--ink: #ece5d8;
--muted: #aaa192;
--line: #3c382f;
--line-soft: #332f28;
--accent: #80bfa8;
--accent-strong: #b2dbc8;
--accent-soft: #243d34;
--source: #aaa192;
--mark: #6b5b1a;
--hover-bg: #302d27;
--badge-bg: #353128;
--success-soft: #243d34;
--marked-soft: #4a381e;
--toast-bg: #11100e;
--shadow: 0 18px 42px rgba(0, 0, 0, 0.42);
}
button,
input,
select,
@@ -38,20 +88,51 @@ textarea {
}
.topbar {
height: 78px;
height: var(--topbar-offset);
padding: 10px 18px;
display: grid;
grid-template-columns: minmax(260px, 1fr) auto minmax(320px, 1.15fr);
grid-template-columns: auto minmax(240px, 1fr) auto auto;
align-items: center;
gap: 14px;
background: #ffffff;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
body:not(.startOpen) .topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 14;
transform: translateY(calc(8px - var(--topbar-offset)));
transition: transform 160ms ease, box-shadow 160ms ease;
box-shadow: 0 8px 24px rgba(25, 34, 45, 0);
}
body:not(.startOpen).chromeVisible .topbar,
body:not(.startOpen) .topbar:hover,
body:not(.startOpen) .topbar:focus-within {
transform: translateY(0);
box-shadow: var(--shadow);
}
.brandBlock {
min-width: 0;
}
.bookshelfNavButton {
font-weight: 700;
border-color: var(--accent);
color: var(--accent-strong);
background: var(--accent-soft);
}
.bookshelfNavButton.active {
background: var(--accent);
border-color: var(--accent);
color: #ffffff;
}
h1 {
margin: 0;
font-size: 19px;
@@ -65,7 +146,7 @@ h1 {
margin-left: 6px;
padding: 0 6px;
border-radius: 6px;
background: #e8edf2;
background: var(--badge-bg);
color: var(--muted);
font-size: 12px;
font-weight: 600;
@@ -87,7 +168,7 @@ h1 {
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
background: #f8fafc;
background: var(--panel-soft);
}
.modeButton {
@@ -117,7 +198,7 @@ h1 {
button {
border: 1px solid var(--line);
background: #ffffff;
background: var(--panel);
color: var(--ink);
min-height: 34px;
padding: 5px 11px;
@@ -150,13 +231,17 @@ button.primary:hover,
background: var(--accent-strong);
}
.startScreen {
min-height: calc(100vh - 78px);
.startScreen,
.bookshelfScreen,
.translationScreen {
min-height: calc(100vh - var(--topbar-offset));
padding: 28px;
background: var(--bg);
}
.startPanel {
.startPanel,
.bookshelfPanel,
.translationPanel {
max-width: 980px;
margin: 0 auto;
display: grid;
@@ -165,31 +250,311 @@ button.primary:hover,
.startHeader,
.uploadBox,
.sessionBrowser {
.sessionBrowser,
.bookshelfHeader,
.translationHeader,
.bookshelfMeta,
.emptyBookshelf {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
}
.startHeader {
.startHeader,
.bookshelfHeader,
.translationHeader {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.startHeader h2 {
.startHeader h2,
.bookshelfHeader h2,
.translationHeader h2 {
margin: 0 0 4px;
font-size: 21px;
}
.startHeader p {
.startHeader p,
.bookshelfHeader p,
.translationHeader p {
margin: 0;
color: var(--muted);
max-width: 680px;
}
.bookshelfActions,
.translationHeaderActions,
.translationActions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.bookshelfMeta {
color: var(--muted);
font-size: 13px;
}
.bookshelfGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
gap: 12px;
}
.bookshelfItem {
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
grid-template-rows: 1fr auto;
gap: 12px;
align-items: stretch;
min-height: 156px;
border: 1px solid var(--line-soft);
border-radius: 8px;
padding: 14px;
background: var(--panel);
cursor: pointer;
}
.bookshelfItem:hover,
.bookshelfItem:focus-visible {
border-color: var(--accent);
box-shadow: var(--shadow);
outline: 0;
}
.bookshelfItem.active {
border-color: var(--accent);
background: var(--accent-soft);
}
.bookSpine {
grid-row: 1 / span 2;
display: grid;
place-items: center;
border-radius: 6px;
background: linear-gradient(180deg, #45615b, #213d38);
color: #ffffff;
font-size: 20px;
font-weight: 700;
}
.bookInfo {
min-width: 0;
}
.bookInfo h3 {
margin: 0 0 8px;
font-size: 16px;
line-height: 1.45;
}
.bookInfo p {
margin: 0 0 10px;
color: var(--muted);
font-size: 13px;
}
.bookFacts {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 12px;
}
.bookActions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-self: start;
align-self: end;
}
.bookActions button {
min-width: 82px;
}
.bookActions [data-open-translation] {
border-color: var(--accent);
color: var(--accent-strong);
background: var(--accent-soft);
}
.bookshelfItem > button {
justify-self: start;
align-self: end;
}
.translationPanel {
max-width: 1180px;
}
.translationGrid {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 16px;
align-items: start;
}
.translationSettings,
.translationCard,
.translationProgressCard {
display: grid;
gap: 12px;
}
.translationCard,
.translationProgressCard {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
}
.translationOptions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.translationOptions.apiOptions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.translationCard label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.translationCard input,
.translationCard select,
.translationCard textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border-radius: 6px;
padding: 7px 9px;
background: var(--panel);
color: var(--ink);
font-weight: 400;
}
.translationCard textarea {
resize: vertical;
font-size: 13px;
line-height: 1.55;
}
.translationSample {
display: grid;
gap: 8px;
}
.translationSampleItem,
.translationEmpty,
.translationOutput {
border: 1px solid var(--line-soft);
border-radius: 6px;
padding: 10px;
background: var(--panel-soft);
}
.translationSampleItem strong {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: var(--muted);
}
.translationSampleItem p,
.translationOutput p {
margin: 0;
color: var(--ink);
overflow-wrap: anywhere;
}
.translationProgressCard {
position: sticky;
top: 18px;
}
.translationProgress {
height: 12px;
overflow: hidden;
border-radius: 999px;
background: var(--line-soft);
}
.translationProgressBar {
width: 0;
height: 100%;
border-radius: inherit;
background: var(--accent);
transition: width 180ms ease;
}
.translationProgressText {
white-space: pre-wrap;
color: var(--muted);
font-size: 13px;
}
.translationLogs {
display: grid;
gap: 8px;
max-height: 420px;
overflow: auto;
}
.translationLog {
border-left: 3px solid var(--line);
padding: 6px 8px;
background: var(--panel-soft);
}
.translationLog.warning {
border-left-color: var(--warn);
}
.translationLog.error {
border-left-color: var(--bad);
}
.translationLog span {
display: block;
color: var(--muted);
font-size: 11px;
}
.translationLog p {
margin: 2px 0 0;
overflow-wrap: anywhere;
}
.emptyBookshelf {
grid-column: 1 / -1;
display: grid;
gap: 10px;
justify-items: start;
}
.emptyBookshelf h3 {
margin: 0;
font-size: 17px;
}
.emptyBookshelf p {
margin: 0;
color: var(--muted);
}
.uploadBox {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -204,7 +569,7 @@ button.primary:hover,
border: 1px dashed #aeb8c4;
border-radius: 8px;
padding: 14px;
background: #f8fafc;
background: var(--panel-soft);
cursor: pointer;
}
@@ -299,11 +664,11 @@ button.primary:hover,
border-radius: 8px;
padding: 18px;
text-align: center;
background: #f8fafc;
background: var(--panel-soft);
}
.layout {
height: calc(100vh - 78px);
height: 100vh;
display: block;
min-width: 0;
position: relative;
@@ -311,13 +676,13 @@ button.primary:hover,
.sidebar {
position: fixed;
top: 78px;
top: 8px;
left: 0;
bottom: 0;
z-index: 18;
z-index: 24;
width: min(380px, calc(100vw - 24px));
border-right: 1px solid var(--line);
background: #f9fbfd;
background: var(--panel-soft);
min-height: 0;
display: flex;
flex-direction: column;
@@ -331,7 +696,7 @@ button.primary:hover,
gap: 12px;
padding: 12px;
border-bottom: 1px solid var(--line);
background: #ffffff;
background: var(--panel);
}
.sideHeader button {
@@ -361,7 +726,7 @@ button.primary:hover,
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 10px;
background: #ffffff;
background: var(--panel);
}
.searchScope {
@@ -370,7 +735,7 @@ button.primary:hover,
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
background: #ffffff;
background: var(--panel);
}
.searchScope button {
@@ -414,7 +779,7 @@ button.primary:hover,
min-height: 26px;
border: 1px solid var(--line);
border-radius: 6px;
background: #ffffff;
background: var(--panel);
color: var(--muted);
padding: 2px 8px;
font-size: 12px;
@@ -504,7 +869,7 @@ button.primary:hover,
.tocToggle:hover,
.tocPartButton:hover,
.tocImageButton:hover {
background: #eef3f6;
background: var(--hover-bg);
}
.tocChapterRow.active,
@@ -539,14 +904,14 @@ button.primary:hover,
display: block;
text-align: left;
border: 0;
border-bottom: 1px solid #e6e0d6;
border-bottom: 1px solid var(--line-soft);
border-radius: 0;
background: transparent;
padding: 10px 12px;
}
.rowItem:hover {
background: #eef3f6;
background: var(--hover-bg);
}
.rowItem.active {
@@ -556,12 +921,12 @@ button.primary:hover,
}
.rowItem.touched .rowBadge {
background: #e9f5ee;
background: var(--success-soft);
color: var(--accent-strong);
}
.rowItem.marked .rowBadge {
background: #fff0d5;
background: var(--marked-soft);
color: var(--warn);
}
@@ -597,7 +962,7 @@ button.primary:hover,
min-height: 20px;
padding: 0 6px;
border-radius: 999px;
background: #e8edf2;
background: var(--badge-bg);
}
.rowPreview {
@@ -615,9 +980,10 @@ button.primary:hover,
}
.readerPane {
height: calc(100vh - 78px);
height: 100vh;
padding: 0;
background: #f4f6f8;
background: var(--reader-bg);
color: var(--reader-ink);
}
.readerPane,
@@ -626,16 +992,33 @@ button.primary:hover,
}
.readerHeader {
position: sticky;
top: 0;
z-index: 5;
position: fixed;
top: var(--topbar-live-height);
left: 0;
right: 0;
z-index: 22;
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 16px 28px;
background: rgba(255, 255, 255, 0.96);
background: var(--reader-paper);
background: color-mix(in srgb, var(--reader-paper) 94%, transparent);
border-bottom: 1px solid var(--line);
transform: translateY(calc(-100% - var(--topbar-live-height) + 8px));
opacity: 0;
pointer-events: none;
transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease;
}
body.startOpen .readerHeader,
body.chromeVisible .readerHeader,
.readerHeader:hover,
.readerHeader:focus-within {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
box-shadow: 0 10px 26px rgba(25, 34, 45, 0.12);
}
.chapterKicker {
@@ -654,16 +1037,74 @@ button.primary:hover,
display: flex;
gap: 8px;
flex-shrink: 0;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
}
.appearanceControls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding-right: 8px;
border-right: 1px solid var(--line);
}
.themeSwitch {
display: inline-grid;
grid-template-columns: repeat(3, minmax(42px, auto));
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
background: var(--panel);
}
.themeSwitch button {
min-height: 30px;
border: 0;
border-radius: 0;
background: transparent;
color: var(--muted);
padding: 3px 9px;
font-size: 12px;
}
.themeSwitch button.active {
background: var(--accent);
color: #ffffff;
}
.readerSlider {
display: inline-grid;
grid-template-columns: auto 78px 34px;
gap: 6px;
align-items: center;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.readerSlider input {
width: 78px;
accent-color: var(--accent);
}
.readerSlider output {
min-width: 30px;
color: var(--ink);
text-align: right;
font-variant-numeric: tabular-nums;
}
.readingFlow {
max-width: 900px;
margin: 0 auto;
padding: 28px 42px 100px;
background: #ffffff;
min-height: calc(100vh - 156px);
border-left: 1px solid #e7edf3;
border-right: 1px solid #e7edf3;
padding: 36px 42px 100px;
background: var(--reader-paper);
min-height: 100vh;
border-left: 1px solid var(--reader-border);
border-right: 1px solid var(--reader-border);
}
.readBlock {
@@ -694,16 +1135,16 @@ button.primary:hover,
}
.readSource {
color: var(--source);
font-size: 13px;
line-height: 1.75;
color: var(--reader-source);
font-size: calc(var(--reader-font-size) - 4px);
line-height: calc(var(--reader-line-height) - 0.2);
margin-bottom: 8px;
}
.readCn {
color: var(--ink);
font-size: 17px;
line-height: 1.95;
color: var(--reader-ink);
font-size: var(--reader-font-size);
line-height: var(--reader-line-height);
letter-spacing: 0;
}
@@ -748,7 +1189,7 @@ button.primary:hover,
}
.editorPane {
height: calc(100vh - 78px);
height: 100vh;
padding: 18px;
}
@@ -814,8 +1255,8 @@ button.primary:hover,
.quickSource {
color: var(--source);
white-space: pre-wrap;
background: #f8fafc;
border: 1px solid #e3e9f0;
background: var(--panel-soft);
border: 1px solid var(--line-soft);
border-radius: 6px;
padding: 12px;
}
@@ -837,7 +1278,7 @@ button.primary:hover,
.cnEditor {
min-height: 160px;
background: #ffffff;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 6px;
padding: 12px;
@@ -874,7 +1315,7 @@ select,
textarea {
border: 1px solid var(--line);
border-radius: 6px;
background: #ffffff;
background: var(--panel);
padding: 7px 9px;
color: var(--ink);
}
@@ -897,11 +1338,11 @@ textarea {
.quickEditor {
position: fixed;
top: 78px;
top: 8px;
right: 0;
bottom: 0;
width: min(520px, calc(100vw - 36px));
z-index: 20;
z-index: 26;
display: flex;
flex-direction: column;
gap: 10px;
@@ -926,17 +1367,29 @@ textarea {
}
.quickSourceWrap {
border: 1px solid #e3e9f0;
border: 1px solid var(--line-soft);
border-radius: 6px;
background: #f8fafc;
background: var(--panel-soft);
}
.toolSection {
border: 1px solid var(--line-soft);
border-radius: 8px;
background: var(--panel);
padding: 10px;
}
.toolSection > summary,
.quickSourceWrap summary {
cursor: pointer;
padding: 8px 12px;
padding: 2px 2px 8px;
color: var(--source);
font-weight: 700;
border-bottom: 1px solid #e3e9f0;
}
.toolSection[open] > summary {
border-bottom: 1px solid var(--line-soft);
margin-bottom: 10px;
}
.quickSource {
@@ -978,7 +1431,7 @@ textarea {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #f8fafc;
background: var(--panel-soft);
}
.gptStatus {
@@ -997,11 +1450,15 @@ textarea {
display: none !important;
}
details.gptConfig {
margin-bottom: 0;
}
.gptConfigGroup {
display: grid;
gap: 8px;
padding-top: 10px;
border-top: 1px solid #e3e9f0;
border-top: 1px solid var(--line-soft);
}
.gptConfigGroup:first-child {
@@ -1072,7 +1529,7 @@ textarea {
border-radius: 6px;
color: var(--muted);
font-size: 12px;
background: #ffffff;
background: var(--panel);
}
.gptActions {
@@ -1082,12 +1539,18 @@ textarea {
margin-top: 8px;
}
.deliveryActions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.gptCandidate {
margin-top: 10px;
padding: 10px;
border: 1px dashed var(--accent);
border-radius: 6px;
background: #ffffff;
background: var(--panel);
max-height: 180px;
overflow: auto;
white-space: pre-wrap;
@@ -1096,7 +1559,7 @@ textarea {
.gptCandidateMeta {
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #e3e9f0;
border-bottom: 1px solid var(--line-soft);
color: var(--muted);
font-size: 12px;
line-height: 1.45;
@@ -1108,7 +1571,7 @@ textarea {
bottom: 16px;
max-width: min(560px, calc(100vw - 32px));
padding: 12px 14px;
background: #1f2933;
background: var(--toast-bg);
color: #ffffff;
border-radius: 8px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
@@ -1129,6 +1592,14 @@ textarea {
body.sidebarOpen .sidebar {
box-shadow: none;
}
body.sidebarOpen .readerHeader {
left: 380px;
}
body.quickOpen .readerHeader {
right: min(520px, calc(100vw - 36px));
}
}
@media (min-width: 1320px) {
@@ -1146,6 +1617,10 @@ textarea {
align-items: stretch;
}
body:not(.startOpen) .topbar {
transform: translateY(calc(-100% + 8px));
}
#sessionMeta {
white-space: normal;
}
@@ -1156,7 +1631,7 @@ textarea {
.layout {
height: auto;
min-height: calc(100vh - 78px);
min-height: 100vh;
}
.sidebar {
@@ -1178,9 +1653,18 @@ textarea {
}
.readerHeader {
top: var(--topbar-live-height);
padding: 12px 16px;
}
.appearanceControls {
width: 100%;
padding-right: 0;
padding-bottom: 8px;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.reviewFields,
.quickControls {
grid-template-columns: 1fr;
@@ -1197,13 +1681,46 @@ textarea {
border-radius: 8px;
}
.startScreen {
.startScreen,
.bookshelfScreen,
.translationScreen {
padding: 14px;
}
.startHeader,
.bookshelfHeader,
.translationHeader,
.uploadBox,
.sessionItem {
.sessionItem,
.deliveryActions {
grid-template-columns: 1fr;
}
.bookshelfActions,
.translationHeaderActions,
.bookshelfActions button,
.translationHeaderActions button {
width: 100%;
}
.bookshelfGrid {
grid-template-columns: 1fr;
}
.bookshelfItem {
grid-template-columns: 44px minmax(0, 1fr);
}
.translationGrid {
grid-template-columns: 1fr;
}
.translationProgressCard {
position: static;
}
.translationOptions,
.translationOptions.apiOptions {
grid-template-columns: 1fr;
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
version = "0.8.0"
version = "0.12.2"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",