feat(editor): improve EPUB review workflow
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
# EPUB 审校器长期维护手册
|
||||
|
||||
## 目标与边界
|
||||
|
||||
本工具服务于中日双语 EPUB 的网页审校:长篇连续阅读、逐段修改中文译文、标记翻译问题、导出反馈与修订 EPUB。翻译质量规则仍由项目根目录的 `AGENTS.md`、`rules/translation_rules.md`、当前系列的 `series-style.md` 与术语表共同约束。
|
||||
|
||||
审校器不负责整卷首译流程,也不直接改正式术语表。用户在网页中留下的编辑、问题备注、长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。
|
||||
|
||||
## 数据模型与会话目录
|
||||
|
||||
`--review-root` 是唯一需要备份的运行数据目录。典型结构:
|
||||
|
||||
```text
|
||||
epub_review_sessions/
|
||||
app_state.json
|
||||
gpt_config.json
|
||||
<session-id>/
|
||||
source.epub
|
||||
extracted/
|
||||
review_state/
|
||||
epub_entries.json
|
||||
rows.json
|
||||
session.json
|
||||
state.json
|
||||
feedback/
|
||||
translation_feedback.jsonl
|
||||
feedback_for_codex.md
|
||||
exports/
|
||||
```
|
||||
|
||||
兼容要求:
|
||||
|
||||
- `rows.json` 是初始解析结果,不应因用户编辑而重写。
|
||||
- `state.json.edits` 保存用户修改与标记,字段缺省时必须能回退到原始译文。
|
||||
- `session.json` 与 `app_state.json` 只保存会话定位信息。
|
||||
- `gpt_config.json` 只属于本地运行环境,不写入 EPUB、不写入导出文件、不通过 API 回传密钥。
|
||||
|
||||
## 目录与插图
|
||||
|
||||
结构接口 `/api/structure` 应保持向后兼容:
|
||||
|
||||
- text chapter: `kind: "text"`、`parts`、`row_count`、`touched_count`、`marked_count`
|
||||
- image chapter: `kind: "image"`、`images`、`parts: []`、`row_count: 0`
|
||||
- part 标题默认使用 `part0000` 形式,真实标题可保留在 `source_title`
|
||||
- 插图通过 `/api/asset/<entry>` 读取,必须继续使用 `extracted_path()` 防止路径穿越
|
||||
|
||||
章节来源优先级:
|
||||
|
||||
1. EPUB 内部目录页的中文条目
|
||||
2. `nav.xhtml` / `toc.xhtml`
|
||||
3. 正文标题
|
||||
4. 文件名 fallback
|
||||
|
||||
## 阅读与侧栏交互
|
||||
|
||||
验收口径:
|
||||
|
||||
- 初始进入审校时,左侧侧栏隐藏,正文占主要空间。
|
||||
- 顶部“目录”只打开目录,“检索”只打开搜索/段落列表,不合并成一个常驻面板。
|
||||
- 目录可展开/收起章节,插图章节可点击阅读,文字 part 可点击定位。
|
||||
- 阅读模式不显示每段编号、快速编辑、快速标记按钮。
|
||||
- 点击正文段落打开右侧精修与重翻面板。
|
||||
- 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。
|
||||
- 移动窄屏下,侧栏和右侧面板应以抽屉/底部面板方式显示,不遮死主要操作。
|
||||
|
||||
## 编辑、反馈与导出
|
||||
|
||||
保存本段时必须:
|
||||
|
||||
- sanitize 中文 HTML,但允许 `ruby/rb/rt/rp/mark/span` 等必要内联标签。
|
||||
- 记录 `before_cn_html`、`after_cn_html`、问题类型、严重程度、标签、备注、长期规则建议。
|
||||
- 更新 `feedback/translation_feedback.jsonl`。
|
||||
|
||||
生成反馈时必须:
|
||||
|
||||
- 写入 `feedback_for_codex.md`。
|
||||
- 只汇总已编辑、已标记或有备注的段落。
|
||||
- 保留日文原文、修改前、修改后,便于后续翻译规则复盘。
|
||||
|
||||
导出 EPUB 前必须先写回已保存的译文修改,插图文件保持原样。
|
||||
|
||||
## GPT 重翻
|
||||
|
||||
GPT 能力是可选增强,不应影响离线审校。
|
||||
|
||||
安全与配置:
|
||||
|
||||
- 支持 OpenAI 兼容的 `base_url`、`model`、`api_key`。
|
||||
- API Key 优先读取 `gpt_config.json`,也支持环境变量 `OPENAI_API_KEY`。
|
||||
- `/api/gpt/config` 不得返回 API Key。
|
||||
- 未配置 Key 时,重翻接口应返回可读错误,不影响编辑保存。
|
||||
|
||||
单段重翻流程:
|
||||
|
||||
1. 用户点击阅读段落,打开右侧面板。
|
||||
2. 用户可填写额外重翻要求。
|
||||
3. `/api/row/<row_id>/retranslate` 使用当前日文、当前中文、术语提示生成候选。
|
||||
4. 候选只显示在面板里,不自动覆盖。
|
||||
5. 用户点击“应用重翻”后写入编辑框,再点击保存才进入审校记录。
|
||||
|
||||
Prompt 硬规则:
|
||||
|
||||
- 输出简体中文轻小说文风。
|
||||
- 意义优先,不贴日语语序硬译。
|
||||
- 不合并、不拆分段落。
|
||||
- 有意义 ruby 必须保留为真实 `<ruby><rb>...</rb><rt>...</rt></ruby>`,不要改成括号。
|
||||
- Falchion / ファルシオン 固定译为“大砍刀”,不要译成“偃月刀”。
|
||||
|
||||
## 版本规则
|
||||
|
||||
版本号使用 `version = "0.1.0"` 格式:
|
||||
|
||||
| 层级 | 触发条件 |
|
||||
| --- | --- |
|
||||
| PATCH | 修复 bug 或兼容性小修 |
|
||||
| MINOR | 新增向后兼容能力、API 字段或可选配置 |
|
||||
| MAJOR | 破坏兼容的 API、配置或行为变更 |
|
||||
|
||||
发版时必须同步:
|
||||
|
||||
- `version.py`
|
||||
- `static/index.html` 顶部版本徽标 fallback
|
||||
- `README.md` 当前版本与版本说明
|
||||
|
||||
## 回归清单
|
||||
|
||||
每轮改动至少检查:
|
||||
|
||||
- `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`
|
||||
- `git diff --check`
|
||||
- `/api/version`
|
||||
- `/api/session`
|
||||
- `/api/structure`
|
||||
- `/api/gpt/config`
|
||||
- 首个插图 `/api/asset/...` 返回 200 且浏览器可显示
|
||||
- 目录按钮、检索按钮、隐藏侧栏、点击段落打开右侧面板
|
||||
- 阅读段落下方没有编号和“快速编辑/快速标记”
|
||||
- 未配置 API Key 时重翻返回清晰错误
|
||||
- 配置保存后 `/api/gpt/config` 不包含密钥字段
|
||||
@@ -1,6 +1,6 @@
|
||||
# 双语 EPUB 审校编辑器
|
||||
|
||||
当前版本:`0.2.0`
|
||||
当前版本:`0.4.0`
|
||||
|
||||
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
|
||||
本次从 `0.1.0` 升到 `0.2.0`,因为新增了独立上传、会话管理、下载接口与可选 `--host` 配置,且保持原命令行 EPUB 路径用法兼容。
|
||||
|
||||
`0.2.1` 修复目录章节识别:优先读取 EPUB 内部目录页的中文标题,并把目录锚点之间的多个正文 part 合并为同一章;左侧目录增加章节展开/收起按钮。
|
||||
|
||||
`0.3.0` 新增插图阅读能力:目录中会把 EPUB spine 里的插图页作为独立章节显示,阅读模式可直接查看图片;正文子章节标题统一使用 `part0000` 这类规整编号,方便长篇审校快速定位。
|
||||
|
||||
`0.4.0` 新增阅读型交互与 GPT 重翻:左侧目录/检索改为按需打开的独立侧栏;阅读模式去掉每段下方操作按钮,点击段落直接打开右侧精修与重翻面板,并在面板挤开正文后恢复原阅读位置;本地可配置 OpenAI 兼容 GPT API,对单段生成重翻候选,确认后再应用到编辑框。
|
||||
|
||||
## 独立安装
|
||||
|
||||
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
|
||||
@@ -72,9 +78,12 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio
|
||||
## 能做什么
|
||||
|
||||
- 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。
|
||||
- 左侧目录按 EPUB nav/toc 或 spine 文件顺序组织章节;章节下显示内部 part,点击 part 后只看该 part,点击章节则看整章连续正文。
|
||||
- 在阅读模式中点击段落即可打开快速审校面板,直接编辑中文译文或标记问题。
|
||||
- 左侧目录和检索默认隐藏,通过顶部“目录”“检索”两个按钮分别打开,不会把两种入口混在同一个常驻侧栏里。
|
||||
- 目录优先按 EPUB 内部目录页或 nav/toc 组织章节;章节下显示内部 `part0000` 形式的 part,可展开/收起,点击 part 后只看该 part,点击章节则看整章连续正文。
|
||||
- 插图页会像文字章节一样出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片。
|
||||
- 在阅读模式中点击段落即可打开右侧精修与重翻面板,直接编辑中文译文、标记问题或调用 GPT 重翻;打开/关闭面板时会尽量保持原来的阅读位置。
|
||||
- 精修模式保留原有逐段编辑界面,用于较细的逐段复审。
|
||||
- 可在网页中配置 OpenAI 兼容的 `base_url`、`model` 与 API Key,对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
|
||||
- 直接编辑中文译文。
|
||||
- 标记问题段落或选中文本。
|
||||
- 记录问题类型、严重程度、标签、备注和可沉淀的长期规则。
|
||||
@@ -113,10 +122,12 @@ epub_review_sessions\<epub-name>_<hash>\
|
||||
- 只想记录问题时:勾选“标记为翻译问题”,写备注,保存本段。
|
||||
- 想直接修正译文时:编辑中文译文后保存本段。
|
||||
- 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“Falchion 固定译为大砍刀,不要译成偃月刀”。
|
||||
- 想单段重翻时:点击段落打开右侧面板,先在“API 设置”中保存 GPT 配置,再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。
|
||||
- 审校结束后点击“生成反馈”,再在聊天里告诉 Codex 读取 `feedback_for_codex.md` 和 `translation_feedback.jsonl`。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。
|
||||
- 目录用于审校导航,不负责可视化修改 EPUB 的图片、目录和 OPF 元数据。
|
||||
- 目录用于审校导航;插图可以阅读和跳转,但当前版本不提供图片内容编辑。
|
||||
- GPT API Key 仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;服务器部署时请保护好审校目录,不要把没有鉴权的服务暴露到公网。
|
||||
- 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。
|
||||
|
||||
@@ -16,6 +16,7 @@ import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
@@ -35,21 +36,27 @@ except ImportError:
|
||||
"MINOR": "新增向后兼容能力、API 字段或可选配置",
|
||||
"MAJOR": "破坏兼容的 API、配置或行为变更",
|
||||
}
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
|
||||
|
||||
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
|
||||
TAG_RE = re.compile(r"<[^>]+>")
|
||||
IMG_TAG_RE = re.compile(r"<(?:img|image)\b[^>]*>", re.S | re.I)
|
||||
ATTR_RE = re.compile(r"([:\w-]+)\s*=\s*(['\"])(.*?)\2", re.S)
|
||||
JP_RE = re.compile(r"[\u3040-\u30ff\u3400-\u9fff々〆〤]")
|
||||
KANA_RE = re.compile(r"[\u3040-\u30ff]")
|
||||
GRAY_RE = re.compile(
|
||||
r"(?:color\s*:\s*(?:#?808080|gray|grey|rgb\(\s*128\s*,\s*128\s*,\s*128\s*\))|class\s*=\s*['\"][^'\"]*(?:gray|grey|jp|source)[^'\"]*['\"])",
|
||||
re.I,
|
||||
)
|
||||
EXTERNAL_URI_RE = re.compile(r"^(?:[a-z][a-z0-9+.-]*:|//)", re.I)
|
||||
IMAGE_EXTENSIONS = {".apng", ".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"}
|
||||
|
||||
DEFAULT_REVIEW_ROOT = "epub_review_sessions"
|
||||
STATE_VERSION = 1
|
||||
UPLOAD_DIR_NAME = "uploads"
|
||||
DEFAULT_GPT_BASE_URL = "https://api.openai.com/v1"
|
||||
DEFAULT_GPT_MODEL = "gpt-4o-mini"
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
@@ -121,6 +128,13 @@ def normalize_href(base_dir: str, href: str) -> str:
|
||||
return posixpath.normpath(posixpath.join(base_dir, href)).lstrip("./")
|
||||
|
||||
|
||||
def normalize_href_with_fragment(base_dir: str, href: str) -> tuple[str, str]:
|
||||
href = urllib.parse.unquote(href or "")
|
||||
path, fragment = (href.split("#", 1) + [""])[:2] if "#" in href else (href, "")
|
||||
path = posixpath.normpath(posixpath.join(base_dir, path)).lstrip("./") if path else ""
|
||||
return path, fragment
|
||||
|
||||
|
||||
def read_xml_root(path: Path) -> ET.Element | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -237,6 +251,161 @@ def sanitize_cn_html(value: str) -> str:
|
||||
return "".join(sanitizer.parts).strip()
|
||||
|
||||
|
||||
def normalize_gpt_base_url(value: str) -> str:
|
||||
value = (value or DEFAULT_GPT_BASE_URL).strip().rstrip("/")
|
||||
if not value:
|
||||
value = DEFAULT_GPT_BASE_URL
|
||||
parsed = urllib.parse.urlparse(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError("Base URL 必须是 http(s) 地址")
|
||||
return value
|
||||
|
||||
|
||||
def read_gpt_config(review_root: Path) -> dict[str, Any]:
|
||||
data = read_json(gpt_config_path(review_root), {})
|
||||
api_key = str(data.get("api_key") or os.environ.get("OPENAI_API_KEY") or "").strip()
|
||||
base_url = str(data.get("base_url") or os.environ.get("OPENAI_BASE_URL") or DEFAULT_GPT_BASE_URL).strip()
|
||||
model = str(data.get("model") or os.environ.get("OPENAI_MODEL") or DEFAULT_GPT_MODEL).strip()
|
||||
return {
|
||||
"base_url": normalize_gpt_base_url(base_url),
|
||||
"model": model or DEFAULT_GPT_MODEL,
|
||||
"api_key": api_key,
|
||||
"configured": bool(api_key),
|
||||
"key_source": "config" if str(data.get("api_key") or "").strip() else ("env" if os.environ.get("OPENAI_API_KEY") else ""),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
}
|
||||
|
||||
|
||||
def public_gpt_config(review_root: Path) -> dict[str, Any]:
|
||||
config = read_gpt_config(review_root)
|
||||
return {
|
||||
"configured": config["configured"],
|
||||
"base_url": config["base_url"],
|
||||
"model": config["model"],
|
||||
"key_source": config["key_source"],
|
||||
"updated_at": config.get("updated_at", ""),
|
||||
}
|
||||
|
||||
|
||||
def save_gpt_config(review_root: Path, data: dict[str, Any]) -> dict[str, Any]:
|
||||
base_url = normalize_gpt_base_url(str(data.get("base_url") or DEFAULT_GPT_BASE_URL))
|
||||
model = str(data.get("model") or DEFAULT_GPT_MODEL).strip() or DEFAULT_GPT_MODEL
|
||||
api_key = str(data.get("api_key") or "").strip()
|
||||
keep_existing = bool(data.get("keep_existing", False))
|
||||
existing = read_json(gpt_config_path(review_root), {})
|
||||
if keep_existing and not api_key:
|
||||
api_key = str(existing.get("api_key") or "").strip()
|
||||
record = {
|
||||
"base_url": base_url,
|
||||
"model": model,
|
||||
"api_key": api_key,
|
||||
"updated_at": now_iso(),
|
||||
}
|
||||
write_json(gpt_config_path(review_root), record)
|
||||
return public_gpt_config(review_root)
|
||||
|
||||
|
||||
def compact_glossary_for_prompt(row: dict[str, Any], limit: int = 36) -> list[str]:
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
glossary_path = project_root / "TRPG玩家在异世界打造最强角色" / "mingcibiao.json"
|
||||
if not glossary_path.exists():
|
||||
return ["Falchion/ファルシオン:大砍刀,不要译成偃月刀。"]
|
||||
try:
|
||||
glossary = read_json(glossary_path, {})
|
||||
except Exception:
|
||||
glossary = {}
|
||||
source = f"{row.get('jp_text', '')}\n{row.get('jp_html', '')}"
|
||||
selected: list[str] = []
|
||||
for key, value in glossary.items():
|
||||
if not isinstance(key, str) or not isinstance(value, str):
|
||||
continue
|
||||
clean_value = value.split("#", 1)[0].strip()
|
||||
if not clean_value:
|
||||
continue
|
||||
if key and key in source:
|
||||
selected.append(f"{key} => {clean_value}")
|
||||
if len(selected) >= limit:
|
||||
break
|
||||
falchion_rule = "Falchion/ファルシオン => 大砍刀,不要译成偃月刀。"
|
||||
if not any("Falchion" in item or "ファルシオン" in item for item in selected):
|
||||
selected.append(falchion_rule)
|
||||
return selected[:limit]
|
||||
|
||||
|
||||
def build_retranslate_messages(row: dict[str, Any], instruction: str = "") -> list[dict[str, str]]:
|
||||
glossary_lines = "\n".join(f"- {item}" for item in compact_glossary_for_prompt(row))
|
||||
system_prompt = (
|
||||
"你是日语轻小说到简体中文的审校重翻助手。请只输出当前这一段的中文译文 HTML 片段,"
|
||||
"不要解释,不要输出 Markdown。译文要自然流畅,符合简中轻小说文风;意义优先,不贴日语语序硬译。"
|
||||
"不得合并或拆分段落。保留必要的 HTML 内联标签,尤其是有意义的 ruby:"
|
||||
"必须使用 <ruby><rb>中文</rb><rt>读音/外语</rt></ruby>,不要改成括号。"
|
||||
"纯读音 ruby 可自然译入中文。术语优先服从给定术语表。"
|
||||
)
|
||||
user_parts = [
|
||||
"请重翻以下单段。",
|
||||
"",
|
||||
"日文原文 HTML:",
|
||||
row.get("jp_html") or row.get("jp_text") or "",
|
||||
"",
|
||||
"当前中文译文 HTML:",
|
||||
row.get("current_html") or row.get("cn_html") or row.get("cn_text") or "",
|
||||
]
|
||||
if glossary_lines:
|
||||
user_parts.extend(["", "相关术语:", glossary_lines])
|
||||
if instruction.strip():
|
||||
user_parts.extend(["", "本次额外要求:", instruction.strip()[:1200]])
|
||||
return [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": "\n".join(user_parts)},
|
||||
]
|
||||
|
||||
|
||||
def call_openai_compatible_chat(
|
||||
config: dict[str, Any],
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float = 0.2,
|
||||
timeout: int = 90,
|
||||
) -> str:
|
||||
api_key = str(config.get("api_key") or "").strip()
|
||||
if not api_key:
|
||||
raise ValueError("尚未配置 GPT API Key")
|
||||
base_url = normalize_gpt_base_url(str(config.get("base_url") or DEFAULT_GPT_BASE_URL))
|
||||
url = f"{base_url}/chat/completions"
|
||||
payload = {
|
||||
"model": str(config.get("model") or DEFAULT_GPT_MODEL),
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
result = json.loads(response.read().decode("utf-8", errors="replace"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")[:1200]
|
||||
raise RuntimeError(f"GPT 请求失败:HTTP {exc.code} {detail}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"GPT 请求失败:{exc.reason}") from exc
|
||||
choices = result.get("choices") or []
|
||||
if not choices:
|
||||
raise RuntimeError("GPT 返回为空")
|
||||
content = ((choices[0].get("message") or {}).get("content") or "").strip()
|
||||
if content.startswith("```"):
|
||||
content = re.sub(r"^```(?:html|xml)?\s*", "", content, flags=re.I).strip()
|
||||
content = re.sub(r"\s*```$", "", content).strip()
|
||||
if not content:
|
||||
raise RuntimeError("GPT 没有返回译文")
|
||||
return content
|
||||
|
||||
|
||||
def session_root_for(review_root: Path, epub_path: Path) -> Path:
|
||||
digest = hashlib.sha1(str(epub_path.resolve()).encode("utf-8")).hexdigest()[:10]
|
||||
return review_root / f"{safe_slug(epub_path.stem)}_{digest}"
|
||||
@@ -246,6 +415,10 @@ def app_manifest_path(review_root: Path) -> Path:
|
||||
return review_root / "app_state.json"
|
||||
|
||||
|
||||
def gpt_config_path(review_root: Path) -> Path:
|
||||
return review_root / "gpt_config.json"
|
||||
|
||||
|
||||
def session_manifest_path(session_root: Path) -> Path:
|
||||
return session_root / "review_state" / "session.json"
|
||||
|
||||
@@ -255,7 +428,7 @@ def session_public_id(session_root: Path) -> str:
|
||||
|
||||
|
||||
def session_root_from_id(review_root: Path, session_id: str) -> Path:
|
||||
safe_id = safe_slug(session_id, max_len=160)
|
||||
safe_id = safe_slug(session_id, max_len=240)
|
||||
if safe_id != session_id:
|
||||
raise ValueError("invalid session id")
|
||||
root = (review_root / session_id).resolve()
|
||||
@@ -514,12 +687,19 @@ def extract_heading_from_html(text: str) -> str:
|
||||
match = re.search(pattern, text, flags=re.S | re.I)
|
||||
if not match:
|
||||
continue
|
||||
title = strip_tags(match.group(1))
|
||||
title = compact_text(strip_tags(match.group(1)))
|
||||
if title:
|
||||
return title
|
||||
return ""
|
||||
|
||||
|
||||
def extract_body_heading_from_html(text: str) -> str:
|
||||
match = re.search(r"<h[1-6]\b[^>]*>(.*?)</h[1-6]>", text, flags=re.S | re.I)
|
||||
if not match:
|
||||
return ""
|
||||
return compact_text(strip_tags(match.group(1)))
|
||||
|
||||
|
||||
def read_document_title(session_root: Path, entry_name: str) -> str:
|
||||
path = extracted_path(session_root, entry_name)
|
||||
if not path.exists():
|
||||
@@ -528,6 +708,136 @@ def read_document_title(session_root: Path, entry_name: str) -> str:
|
||||
return extract_heading_from_html(text)
|
||||
|
||||
|
||||
def read_body_heading(session_root: Path, entry_name: str) -> str:
|
||||
path = extracted_path(session_root, entry_name)
|
||||
if not path.exists():
|
||||
return ""
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
return extract_body_heading_from_html(text)
|
||||
|
||||
|
||||
def first_content_text(session_root: Path, entry_name: str) -> str:
|
||||
path = extracted_path(session_root, entry_name)
|
||||
if not path.exists():
|
||||
return ""
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
for match in P_RE.finditer(text):
|
||||
inner = match.group(2)
|
||||
if re.search(r"<img\b", inner, flags=re.I):
|
||||
continue
|
||||
label = compact_text(strip_tags(inner))
|
||||
if label:
|
||||
return label
|
||||
return ""
|
||||
|
||||
|
||||
def tag_attrs(tag: str) -> dict[str, str]:
|
||||
attrs: dict[str, str] = {}
|
||||
for key, _quote, value in ATTR_RE.findall(tag):
|
||||
attrs[key.lower()] = html.unescape(value or "")
|
||||
return attrs
|
||||
|
||||
|
||||
def part_display_label(entry_name: str, fallback_index: int) -> str:
|
||||
stem = Path(entry_name).stem
|
||||
for pattern in (r"part[-_]?(\d+)$", r"p[-_]?(\d+)$", r"^(\d+)$"):
|
||||
match = re.search(pattern, stem, flags=re.I)
|
||||
if match:
|
||||
return f"part{int(match.group(1)):04d}"
|
||||
if stem and stem.lower() not in {"titlepage", "cover"}:
|
||||
return stem
|
||||
return f"part{fallback_index:04d}"
|
||||
|
||||
|
||||
def is_image_asset(entry_name: str) -> bool:
|
||||
suffix = posixpath.splitext(entry_name.split("?", 1)[0])[1].lower()
|
||||
return suffix in IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
def asset_url(entry_name: str) -> str:
|
||||
return f"/api/asset/{urllib.parse.quote(entry_name, safe='/')}"
|
||||
|
||||
|
||||
def extract_images_from_html(session_root: Path, entry_name: str) -> list[dict[str, Any]]:
|
||||
path = extracted_path(session_root, entry_name)
|
||||
if not path.exists():
|
||||
return []
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
images: list[dict[str, Any]] = []
|
||||
for index, match in enumerate(IMG_TAG_RE.finditer(text), start=1):
|
||||
attrs = tag_attrs(match.group(0))
|
||||
href = attrs.get("src") or attrs.get("href") or attrs.get("xlink:href")
|
||||
if not href or EXTERNAL_URI_RE.match(href):
|
||||
continue
|
||||
image_entry = normalize_href(entry_dir(entry_name), href)
|
||||
if not image_entry or not is_image_asset(image_entry):
|
||||
continue
|
||||
images.append(
|
||||
{
|
||||
"index": index,
|
||||
"file": entry_name,
|
||||
"file_label": Path(entry_name).name,
|
||||
"asset_path": image_entry,
|
||||
"asset_name": Path(image_entry).name,
|
||||
"asset_url": asset_url(image_entry),
|
||||
"alt": compact_text(attrs.get("alt") or attrs.get("title") or ""),
|
||||
}
|
||||
)
|
||||
return images
|
||||
|
||||
|
||||
def nav_target_key(base_dir: str, href: str) -> tuple[str, str]:
|
||||
path, fragment = normalize_href_with_fragment(base_dir, href)
|
||||
return path, fragment
|
||||
|
||||
|
||||
def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for entry_name in html_entries_in_reading_order(session_root):
|
||||
path = extracted_path(session_root, entry_name)
|
||||
if not path.exists():
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if not re.search(r"<a\b[^>]*href=", text, flags=re.I):
|
||||
continue
|
||||
matches = list(P_RE.finditer(text))
|
||||
base_dir = entry_dir(entry_name)
|
||||
i = 0
|
||||
while i < len(matches):
|
||||
source_inner = matches[i].group(2)
|
||||
link_match = re.search(r"<a\b[^>]*href=['\"]([^'\"]+)['\"][^>]*>(.*?)</a>", source_inner, flags=re.S | re.I)
|
||||
if not link_match:
|
||||
i += 1
|
||||
continue
|
||||
target_file, target_fragment = nav_target_key(base_dir, link_match.group(1))
|
||||
if not target_file:
|
||||
i += 1
|
||||
continue
|
||||
source_title = compact_text(strip_tags(link_match.group(2))) or compact_text(strip_tags(source_inner))
|
||||
title = source_title
|
||||
if i + 1 < len(matches):
|
||||
next_inner = matches[i + 1].group(2)
|
||||
if not is_gray_source(matches[i + 1].group(1)[2:-1], next_inner):
|
||||
next_title = compact_text(strip_tags(next_inner))
|
||||
if next_title:
|
||||
title = next_title
|
||||
i += 1
|
||||
if title:
|
||||
items.append(
|
||||
{
|
||||
"title": title,
|
||||
"href": target_file,
|
||||
"fragment": target_fragment,
|
||||
"children": [],
|
||||
"source_title": source_title,
|
||||
}
|
||||
)
|
||||
i += 1
|
||||
if items:
|
||||
return items
|
||||
return []
|
||||
|
||||
|
||||
def parse_nav_toc(session_root: Path) -> list[dict[str, Any]]:
|
||||
entries = read_json(session_root / "review_state" / "epub_entries.json", [])
|
||||
nav_entries = [
|
||||
@@ -578,19 +888,20 @@ def parse_nav_ol(ol: ET.Element, base_dir: str) -> list[dict[str, Any]]:
|
||||
continue
|
||||
label = ""
|
||||
href = ""
|
||||
fragment = ""
|
||||
children: list[dict[str, Any]] = []
|
||||
for child in list(li):
|
||||
name = local_name(child.tag)
|
||||
if name == "a":
|
||||
label = element_text(child)
|
||||
href = normalize_href(base_dir, child.attrib.get("href", ""))
|
||||
href, fragment = normalize_href_with_fragment(base_dir, child.attrib.get("href", ""))
|
||||
elif name == "span" and not label:
|
||||
label = element_text(child)
|
||||
elif name == "ol":
|
||||
children = parse_nav_ol(child, base_dir)
|
||||
if not label:
|
||||
label = element_text(li)
|
||||
items.append({"title": label, "href": href, "children": children})
|
||||
items.append({"title": label, "href": href, "fragment": fragment, "children": children})
|
||||
return items
|
||||
|
||||
|
||||
@@ -614,10 +925,20 @@ def build_structure(session_root: Path) -> dict[str, Any]:
|
||||
for row in rows:
|
||||
rows_by_file.setdefault(row["file"], []).append(row)
|
||||
|
||||
ordered_files = [file for file in html_entries_in_reading_order(session_root) if file in rows_by_file]
|
||||
all_ordered_files = html_entries_in_reading_order(session_root)
|
||||
image_by_file = {
|
||||
entry_name: images
|
||||
for entry_name in all_ordered_files
|
||||
if (images := extract_images_from_html(session_root, entry_name))
|
||||
}
|
||||
ordered_files = [file for file in all_ordered_files if file in rows_by_file]
|
||||
for file in rows_by_file:
|
||||
if file not in ordered_files:
|
||||
ordered_files.append(file)
|
||||
content_files = [file for file in all_ordered_files if file in rows_by_file or file in image_by_file]
|
||||
for file in sorted(set(rows_by_file) | set(image_by_file), key=file_sort_key):
|
||||
if file not in content_files:
|
||||
content_files.append(file)
|
||||
|
||||
nav_tree = parse_nav_toc(session_root)
|
||||
nav_flat = flatten_nav_items(nav_tree)
|
||||
@@ -628,7 +949,57 @@ def build_structure(session_root: Path) -> dict[str, Any]:
|
||||
if href and title and href not in nav_titles:
|
||||
nav_titles[href] = title
|
||||
|
||||
start_titles: dict[str, str] = {}
|
||||
chapter_specs: list[dict[str, Any]] = []
|
||||
toc_items = parse_internal_toc(session_root)
|
||||
ordered_index = {entry_name: index for index, entry_name in enumerate(all_ordered_files)}
|
||||
content_file_set = set(content_files)
|
||||
|
||||
def first_content_file_at_or_after(entry_name: str) -> str:
|
||||
start_pos = ordered_index.get(entry_name, -1)
|
||||
if start_pos < 0:
|
||||
return ""
|
||||
for candidate in all_ordered_files[start_pos:]:
|
||||
if candidate in content_file_set:
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
for item in toc_items:
|
||||
href = item.get("href", "")
|
||||
if href not in ordered_index:
|
||||
continue
|
||||
start = first_content_file_at_or_after(href)
|
||||
if start and item.get("title") and start not in start_titles:
|
||||
start_titles[start] = item["title"]
|
||||
|
||||
for entry_name in ordered_files:
|
||||
heading = read_body_heading(session_root, entry_name)
|
||||
if heading and entry_name not in start_titles:
|
||||
start_titles[entry_name] = heading
|
||||
|
||||
if start_titles:
|
||||
starts = sorted(start_titles, key=lambda name: ordered_index.get(name, 10**9))
|
||||
for index, start in enumerate(starts):
|
||||
start_pos = ordered_index.get(start, -1)
|
||||
next_pos = ordered_index.get(starts[index + 1], len(all_ordered_files)) if index + 1 < len(starts) else len(all_ordered_files)
|
||||
if start_pos < 0:
|
||||
continue
|
||||
files = [
|
||||
entry_name
|
||||
for entry_name in all_ordered_files[start_pos:next_pos]
|
||||
if entry_name in rows_by_file or entry_name in image_by_file
|
||||
]
|
||||
if files:
|
||||
chapter_specs.append({"title": start_titles.get(start, ""), "files": files, "start": files[0]})
|
||||
|
||||
grouped_files = {entry_name for spec in chapter_specs for entry_name in spec["files"]}
|
||||
for entry_name in content_files:
|
||||
if entry_name not in grouped_files:
|
||||
chapter_specs.append({"title": "", "files": [entry_name], "start": entry_name})
|
||||
chapter_specs.sort(key=lambda spec: ordered_index.get(spec["start"], 10**9))
|
||||
|
||||
part_serial = 0
|
||||
image_serial = 0
|
||||
|
||||
def file_counts(file_rows: list[dict[str, Any]]) -> tuple[int, int]:
|
||||
touched_count = sum(
|
||||
@@ -649,16 +1020,18 @@ def build_structure(session_root: Path) -> dict[str, Any]:
|
||||
nonlocal part_serial
|
||||
part_serial += 1
|
||||
file_rows = rows_by_file[entry_name]
|
||||
title = (
|
||||
title_override
|
||||
or nav_titles.get(entry_name)
|
||||
or read_document_title(session_root, entry_name)
|
||||
or Path(entry_name).stem
|
||||
)
|
||||
title = part_display_label(entry_name, part_serial - 1)
|
||||
touched_count, marked_count = file_counts(file_rows)
|
||||
return {
|
||||
"id": f"PT{part_serial:04d}",
|
||||
"kind": "text",
|
||||
"title": title,
|
||||
"source_title": title_override
|
||||
or nav_titles.get(entry_name)
|
||||
or read_body_heading(session_root, entry_name)
|
||||
or first_content_text(session_root, entry_name)
|
||||
or read_document_title(session_root, entry_name)
|
||||
or Path(entry_name).stem,
|
||||
"file": entry_name,
|
||||
"file_label": Path(entry_name).name,
|
||||
"row_ids": [row["id"] for row in file_rows],
|
||||
@@ -669,20 +1042,58 @@ def build_structure(session_root: Path) -> dict[str, Any]:
|
||||
"last_row_id": file_rows[-1]["id"] if file_rows else "",
|
||||
}
|
||||
|
||||
def make_image_chapter(
|
||||
entry_name: str,
|
||||
image: dict[str, Any],
|
||||
title_override: str = "",
|
||||
image_index: int = 1,
|
||||
image_total: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
nonlocal chapter_serial, image_serial
|
||||
chapter_serial += 1
|
||||
image_serial += 1
|
||||
suffix = f"-{image_index:02d}" if image_total > 1 else ""
|
||||
label = part_display_label(entry_name, image_serial - 1)
|
||||
default_title = "封面" if Path(entry_name).stem.lower() in {"titlepage", "cover"} else f"插图 {label}{suffix}"
|
||||
base_title = title_override or nav_titles.get(entry_name) or read_body_heading(session_root, entry_name)
|
||||
if title_override and base_title:
|
||||
title = f"{base_title} · 插图{suffix}"
|
||||
else:
|
||||
title = base_title or default_title
|
||||
image_item = {
|
||||
**image,
|
||||
"id": f"IMG{image_serial:04d}-{image['index']:02d}",
|
||||
}
|
||||
return {
|
||||
"id": f"C{chapter_serial:04d}",
|
||||
"kind": "image",
|
||||
"title": title,
|
||||
"file": entry_name,
|
||||
"file_label": Path(entry_name).name,
|
||||
"images": [image_item],
|
||||
"row_count": 0,
|
||||
"touched_count": 0,
|
||||
"marked_count": 0,
|
||||
"parts": [],
|
||||
"_sort": ordered_index.get(entry_name, 10**9) + image_index / 100,
|
||||
}
|
||||
|
||||
def chapter_from_parts(chapter_id: str, title: str, parts: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": chapter_id,
|
||||
"kind": "text",
|
||||
"title": title,
|
||||
"row_count": sum(part["row_count"] for part in parts),
|
||||
"touched_count": sum(part["touched_count"] for part in parts),
|
||||
"marked_count": sum(part["marked_count"] for part in parts),
|
||||
"parts": parts,
|
||||
"_sort": min((ordered_index.get(part["file"], 10**9) for part in parts), default=10**9),
|
||||
}
|
||||
|
||||
def flatten_node_files(node: dict[str, Any]) -> list[dict[str, str]]:
|
||||
items: list[dict[str, str]] = []
|
||||
href = node.get("href", "")
|
||||
if href in rows_by_file:
|
||||
if href in rows_by_file or href in image_by_file:
|
||||
items.append({"href": href, "title": node.get("title") or ""})
|
||||
for child in node.get("children", []):
|
||||
items.extend(flatten_node_files(child))
|
||||
@@ -692,6 +1103,44 @@ def build_structure(session_root: Path) -> dict[str, Any]:
|
||||
assigned_files: set[str] = set()
|
||||
chapter_serial = 0
|
||||
|
||||
def append_file_as_chapter(entry_name: str, title_override: str = "") -> None:
|
||||
nonlocal chapter_serial
|
||||
if entry_name in image_by_file:
|
||||
images = image_by_file.get(entry_name, [])
|
||||
for image_index, image in enumerate(images, start=1):
|
||||
chapters.append(make_image_chapter(entry_name, image, title_override, image_index, len(images)))
|
||||
return
|
||||
if entry_name in rows_by_file:
|
||||
chapter_serial += 1
|
||||
part = make_part(entry_name, title_override)
|
||||
chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", title_override or part["source_title"] or part["title"], [part]))
|
||||
|
||||
def append_grouped_files(title: str, files: list[str]) -> None:
|
||||
nonlocal chapter_serial
|
||||
text_parts: list[dict[str, Any]] = []
|
||||
text_title = title
|
||||
for file_index, entry_name in enumerate(files):
|
||||
if entry_name in image_by_file:
|
||||
images = image_by_file.get(entry_name, [])
|
||||
for image_index, image in enumerate(images, start=1):
|
||||
image_title = title if file_index == 0 and not text_parts else ""
|
||||
chapters.append(make_image_chapter(entry_name, image, image_title, image_index, len(images)))
|
||||
elif entry_name in rows_by_file:
|
||||
text_parts.append(make_part(entry_name))
|
||||
if text_parts:
|
||||
chapter_serial += 1
|
||||
chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", text_title or text_parts[0]["source_title"] or text_parts[0]["title"], text_parts))
|
||||
|
||||
for spec in chapter_specs:
|
||||
node_files = [entry_name for entry_name in spec.get("files", []) if entry_name not in assigned_files]
|
||||
if not node_files:
|
||||
continue
|
||||
if len(node_files) == 1 and node_files[0] in image_by_file:
|
||||
append_file_as_chapter(node_files[0], spec.get("title") or "")
|
||||
else:
|
||||
append_grouped_files(spec.get("title") or "", node_files)
|
||||
assigned_files.update(node_files)
|
||||
|
||||
chapter_nodes = nav_tree
|
||||
while (
|
||||
len(chapter_nodes) == 1
|
||||
@@ -700,34 +1149,36 @@ def build_structure(session_root: Path) -> dict[str, Any]:
|
||||
):
|
||||
chapter_nodes = chapter_nodes[0]["children"]
|
||||
|
||||
for node in chapter_nodes:
|
||||
node_files = []
|
||||
seen_in_node: set[str] = set()
|
||||
for item in flatten_node_files(node):
|
||||
href = item["href"]
|
||||
if href in assigned_files or href in seen_in_node:
|
||||
if not chapters:
|
||||
for node in chapter_nodes:
|
||||
node_files = []
|
||||
seen_in_node: set[str] = set()
|
||||
for item in flatten_node_files(node):
|
||||
href = item["href"]
|
||||
if href in assigned_files or href in seen_in_node:
|
||||
continue
|
||||
seen_in_node.add(href)
|
||||
node_files.append(item)
|
||||
if not node_files:
|
||||
continue
|
||||
seen_in_node.add(href)
|
||||
node_files.append(item)
|
||||
if not node_files:
|
||||
continue
|
||||
chapter_serial += 1
|
||||
parts = [make_part(item["href"], item.get("title", "")) for item in node_files]
|
||||
assigned_files.update(item["href"] for item in node_files)
|
||||
chapter_title = node.get("title") or parts[0]["title"]
|
||||
chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", chapter_title, parts))
|
||||
append_grouped_files(node.get("title") or "", [item["href"] for item in node_files])
|
||||
assigned_files.update(item["href"] for item in node_files)
|
||||
|
||||
for entry_name in ordered_files:
|
||||
for entry_name in content_files:
|
||||
if entry_name in assigned_files:
|
||||
continue
|
||||
chapter_serial += 1
|
||||
part = make_part(entry_name)
|
||||
chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", part["title"], [part]))
|
||||
append_file_as_chapter(entry_name)
|
||||
|
||||
chapters.sort(key=lambda chapter: chapter.get("_sort", 10**9))
|
||||
for index, chapter in enumerate(chapters, start=1):
|
||||
chapter["id"] = f"C{index:04d}"
|
||||
chapter.pop("_sort", None)
|
||||
|
||||
return {
|
||||
"chapters": chapters,
|
||||
"chapter_count": len(chapters),
|
||||
"part_count": sum(len(chapter["parts"]) for chapter in chapters),
|
||||
"image_count": sum(len(chapter.get("images", [])) for chapter in chapters),
|
||||
"row_count": len(rows),
|
||||
}
|
||||
|
||||
@@ -968,6 +1419,15 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
epub_path = app.config.get("EPUB_PATH")
|
||||
if isinstance(session_root, Path):
|
||||
return session_root, epub_path if isinstance(epub_path, Path) else session_epub_path(session_root)
|
||||
active_id = read_json(app_manifest_path(review_root), {}).get("active_session_id", "")
|
||||
if active_id:
|
||||
try:
|
||||
manifest_root = session_root_from_id(review_root, active_id)
|
||||
except ValueError:
|
||||
return None, None
|
||||
if (manifest_root / "review_state" / "state.json").exists():
|
||||
set_active_session(manifest_root)
|
||||
return app.config["SESSION_ROOT"], app.config["EPUB_PATH"]
|
||||
return None, None
|
||||
|
||||
def no_active_response():
|
||||
@@ -1097,6 +1557,79 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
return no_active_response()
|
||||
return jsonify(build_structure(session_root))
|
||||
|
||||
@app.route("/api/asset/<path:entry_name>")
|
||||
def api_asset(entry_name: str):
|
||||
session_root, _epub_path = active_session()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
entry_name = urllib.parse.unquote(entry_name)
|
||||
try:
|
||||
path = extracted_path(session_root, entry_name)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if not path.exists() or not path.is_file():
|
||||
return jsonify({"error": "asset not found"}), 404
|
||||
return send_from_directory(path.parent, path.name)
|
||||
|
||||
@app.route("/api/gpt/config", methods=["GET", "POST"])
|
||||
def api_gpt_config():
|
||||
if request.method == "GET":
|
||||
return jsonify(public_gpt_config(review_root))
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
return jsonify(save_gpt_config(review_root, data))
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.route("/api/row/<row_id>/retranslate", methods=["POST"])
|
||||
def api_retranslate_row(row_id: str):
|
||||
session_root, _epub_path = active_session()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
rows = merge_rows(session_root)
|
||||
row = next((item for item in rows if item["id"] == row_id), None)
|
||||
if row is None:
|
||||
return jsonify({"error": "row not found"}), 404
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
config = read_gpt_config(review_root)
|
||||
if data.get("base_url"):
|
||||
config["base_url"] = normalize_gpt_base_url(str(data.get("base_url")))
|
||||
if data.get("model"):
|
||||
config["model"] = str(data.get("model")).strip()
|
||||
instruction = str(data.get("instruction") or "")
|
||||
raw = call_openai_compatible_chat(config, build_retranslate_messages(row, instruction))
|
||||
candidate_html = sanitize_cn_html(raw)
|
||||
if not candidate_html:
|
||||
return jsonify({"error": "GPT 返回内容为空或无法作为译文保存"}), 502
|
||||
candidate_text = strip_tags(candidate_html)
|
||||
record = {
|
||||
"ts": now_iso(),
|
||||
"event": "row_retranslated",
|
||||
"row_id": row_id,
|
||||
"file": row["file"],
|
||||
"model": config["model"],
|
||||
"base_url": config["base_url"],
|
||||
"jp_text": row["jp_text"],
|
||||
"before_cn_text": strip_tags(row.get("current_html") or row.get("cn_html") or ""),
|
||||
"candidate_cn_text": candidate_text,
|
||||
}
|
||||
append_jsonl(session_root / "feedback" / "translation_feedback.jsonl", record)
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"row_id": row_id,
|
||||
"candidate_html": candidate_html,
|
||||
"candidate_text": candidate_text,
|
||||
"model": config["model"],
|
||||
"updated_at": record["ts"],
|
||||
}
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
except RuntimeError as exc:
|
||||
return jsonify({"error": str(exc)}), 502
|
||||
|
||||
@app.route("/api/row/<row_id>", methods=["POST"])
|
||||
def api_save_row(row_id: str):
|
||||
session_root, _epub_path = active_session()
|
||||
|
||||
@@ -10,6 +10,10 @@ let showTouchedOnly = false;
|
||||
let dirty = false;
|
||||
let quickDirty = false;
|
||||
let hasSession = false;
|
||||
let expandedChapters = new Set();
|
||||
let sidePanelMode = null;
|
||||
let gptConfigLoaded = false;
|
||||
let gptCandidateHtml = "";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
@@ -25,8 +29,17 @@ const dom = {
|
||||
feedbackBtn: $("feedbackBtn"),
|
||||
applyBtn: $("applyBtn"),
|
||||
exportBtn: $("exportBtn"),
|
||||
tocPanelBtn: $("tocPanelBtn"),
|
||||
searchPanelBtn: $("searchPanelBtn"),
|
||||
sidebar: $("sidebar"),
|
||||
sideTitle: $("sideTitle"),
|
||||
closeSidebarBtn: $("closeSidebarBtn"),
|
||||
tocPanel: $("tocPanel"),
|
||||
searchPanel: $("searchPanel"),
|
||||
searchInput: $("searchInput"),
|
||||
stats: $("stats"),
|
||||
expandTocBtn: $("expandTocBtn"),
|
||||
collapseTocBtn: $("collapseTocBtn"),
|
||||
toc: $("toc"),
|
||||
rowList: $("rowList"),
|
||||
readerPane: $("readerPane"),
|
||||
@@ -69,6 +82,17 @@ const dom = {
|
||||
quickMarkSelectionBtn: $("quickMarkSelectionBtn"),
|
||||
quickSaveBtn: $("quickSaveBtn"),
|
||||
quickOpenFullBtn: $("quickOpenFullBtn"),
|
||||
toggleGptConfigBtn: $("toggleGptConfigBtn"),
|
||||
gptStatus: $("gptStatus"),
|
||||
gptConfig: $("gptConfig"),
|
||||
gptBaseUrlInput: $("gptBaseUrlInput"),
|
||||
gptModelInput: $("gptModelInput"),
|
||||
gptApiKeyInput: $("gptApiKeyInput"),
|
||||
saveGptConfigBtn: $("saveGptConfigBtn"),
|
||||
gptInstructionInput: $("gptInstructionInput"),
|
||||
retranslateBtn: $("retranslateBtn"),
|
||||
applyRetranslationBtn: $("applyRetranslationBtn"),
|
||||
gptCandidate: $("gptCandidate"),
|
||||
uploadForm: $("uploadForm"),
|
||||
epubFileInput: $("epubFileInput"),
|
||||
selectedFileName: $("selectedFileName"),
|
||||
@@ -130,6 +154,82 @@ function rowTouched(row) {
|
||||
return Boolean(row.edited || row.marked || row.comment || row.learn_note || row.issue_type || row.severity || row.tags);
|
||||
}
|
||||
|
||||
function readerAnchor() {
|
||||
if (currentMode !== "reading" || !dom.readerPane || dom.readerPane.hidden) return null;
|
||||
const paneRect = dom.readerPane.getBoundingClientRect();
|
||||
const headerRect = document.querySelector(".readerHeader")?.getBoundingClientRect();
|
||||
const top = headerRect ? headerRect.bottom + 8 : paneRect.top + 8;
|
||||
const blocks = Array.from(dom.readingFlow.querySelectorAll("[data-row-id]"));
|
||||
if (!blocks.length) {
|
||||
return { rowId: "", offset: 0, scrollTop: dom.readerPane.scrollTop };
|
||||
}
|
||||
let target = blocks[0];
|
||||
for (const block of blocks) {
|
||||
const rect = block.getBoundingClientRect();
|
||||
if (rect.bottom >= top) {
|
||||
target = block;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
rowId: target.dataset.rowId || "",
|
||||
offset: target.getBoundingClientRect().top - paneRect.top,
|
||||
scrollTop: dom.readerPane.scrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreReaderAnchor(anchor) {
|
||||
if (!anchor || currentMode !== "reading") return;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!anchor.rowId) {
|
||||
dom.readerPane.scrollTop = anchor.scrollTop || 0;
|
||||
return;
|
||||
}
|
||||
const target = dom.readingFlow.querySelector(`[data-row-id="${CSS.escape(anchor.rowId)}"]`);
|
||||
if (!target) {
|
||||
dom.readerPane.scrollTop = anchor.scrollTop || 0;
|
||||
return;
|
||||
}
|
||||
const paneRect = dom.readerPane.getBoundingClientRect();
|
||||
const nextOffset = target.getBoundingClientRect().top - paneRect.top;
|
||||
dom.readerPane.scrollTop += nextOffset - anchor.offset;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function withReaderAnchor(action) {
|
||||
const anchor = readerAnchor();
|
||||
const result = action();
|
||||
restoreReaderAnchor(anchor);
|
||||
return result;
|
||||
}
|
||||
|
||||
function openSidePanel(mode) {
|
||||
if (!hasSession) return;
|
||||
sidePanelMode = mode;
|
||||
dom.sidebar.hidden = false;
|
||||
dom.tocPanel.hidden = mode !== "toc";
|
||||
dom.searchPanel.hidden = mode !== "search";
|
||||
dom.sideTitle.textContent = mode === "search" ? "检索" : "目录";
|
||||
dom.tocPanelBtn.classList.toggle("active", mode === "toc");
|
||||
dom.searchPanelBtn.classList.toggle("active", mode === "search");
|
||||
document.body.classList.add("sidebarOpen");
|
||||
if (mode === "search") {
|
||||
requestAnimationFrame(() => dom.searchInput.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function closeSidePanel() {
|
||||
sidePanelMode = null;
|
||||
if (dom.sidebar) dom.sidebar.hidden = true;
|
||||
if (dom.tocPanel) dom.tocPanel.hidden = true;
|
||||
if (dom.searchPanel) dom.searchPanel.hidden = true;
|
||||
document.body.classList.remove("sidebarOpen");
|
||||
if (dom.tocPanelBtn) dom.tocPanelBtn.classList.remove("active");
|
||||
if (dom.searchPanelBtn) dom.searchPanelBtn.classList.remove("active");
|
||||
}
|
||||
|
||||
function resetReviewState() {
|
||||
rows = [];
|
||||
filteredRows = [];
|
||||
@@ -141,8 +241,17 @@ function resetReviewState() {
|
||||
showTouchedOnly = false;
|
||||
dirty = false;
|
||||
quickDirty = false;
|
||||
expandedChapters = new Set();
|
||||
sidePanelMode = null;
|
||||
gptCandidateHtml = "";
|
||||
gptConfigLoaded = false;
|
||||
dom.showTouchedBtn.classList.remove("primary");
|
||||
dom.searchInput.value = "";
|
||||
dom.sidebar.hidden = true;
|
||||
dom.tocPanel.hidden = true;
|
||||
dom.searchPanel.hidden = true;
|
||||
dom.tocPanelBtn.classList.remove("active");
|
||||
dom.searchPanelBtn.classList.remove("active");
|
||||
if (dom.quickEditor) {
|
||||
dom.quickEditor.hidden = true;
|
||||
}
|
||||
@@ -160,7 +269,13 @@ function setShellMode(active) {
|
||||
dom.feedbackBtn.disabled = !active;
|
||||
dom.applyBtn.disabled = !active;
|
||||
dom.exportBtn.disabled = !active;
|
||||
dom.tocPanelBtn.disabled = !active;
|
||||
dom.searchPanelBtn.disabled = !active;
|
||||
dom.closeSidebarBtn.disabled = !active;
|
||||
dom.expandTocBtn.disabled = !active;
|
||||
dom.collapseTocBtn.disabled = !active;
|
||||
document.body.classList.toggle("startOpen", !active);
|
||||
if (!active) closeSidePanel();
|
||||
if (!active) {
|
||||
dom.sessionMeta.textContent = "未打开 EPUB";
|
||||
}
|
||||
@@ -249,6 +364,32 @@ function allChapterScopes() {
|
||||
}));
|
||||
}
|
||||
|
||||
function chapterIsExpanded(chapter) {
|
||||
if (!chapter) return false;
|
||||
return expandedChapters.has(chapter.id);
|
||||
}
|
||||
|
||||
function toggleChapterExpanded(chapter) {
|
||||
if (!chapter) return;
|
||||
if (expandedChapters.has(chapter.id)) {
|
||||
expandedChapters.delete(chapter.id);
|
||||
} else {
|
||||
expandedChapters.add(chapter.id);
|
||||
}
|
||||
renderToc();
|
||||
}
|
||||
|
||||
function expandAllChapters() {
|
||||
expandedChapters = new Set((structure.chapters || []).map((chapter) => chapter.id));
|
||||
renderToc();
|
||||
}
|
||||
|
||||
function collapseAllChapters() {
|
||||
expandedChapters = new Set();
|
||||
if (currentChapter) expandedChapters.add(currentChapter.id);
|
||||
renderToc();
|
||||
}
|
||||
|
||||
function partById(partId) {
|
||||
return allParts().find((part) => part.id === partId) || null;
|
||||
}
|
||||
@@ -257,6 +398,14 @@ function chapterById(chapterId) {
|
||||
return (structure.chapters || []).find((chapter) => chapter.id === chapterId) || null;
|
||||
}
|
||||
|
||||
function chapterKind(chapter) {
|
||||
return chapter && chapter.kind === "image" ? "image" : "text";
|
||||
}
|
||||
|
||||
function isImageChapter(chapter) {
|
||||
return chapterKind(chapter) === "image";
|
||||
}
|
||||
|
||||
function partRows(part) {
|
||||
if (!part) return [];
|
||||
const ids = new Set(part.row_ids || []);
|
||||
@@ -279,6 +428,31 @@ function chapterRows(chapter) {
|
||||
return rows.filter((row) => ids.has(row.id));
|
||||
}
|
||||
|
||||
function scopeCoversRow(row) {
|
||||
if (!row) return false;
|
||||
if (currentPart) return (currentPart.row_ids || []).includes(row.id);
|
||||
if (currentChapter) {
|
||||
return (currentChapter.parts || []).some((part) => (part.row_ids || []).includes(row.id));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncScopeToRow(row, forcePart = false) {
|
||||
const nextPart = partForRow(row);
|
||||
if (!nextPart) return;
|
||||
const nextChapter = chapterForPart(nextPart);
|
||||
if (!nextChapter) return;
|
||||
if (forcePart || !currentChapter || currentChapter.id !== nextChapter.id || !scopeCoversRow(row)) {
|
||||
currentChapter = nextChapter;
|
||||
currentPart = nextPart;
|
||||
expandedChapters.add(nextChapter.id);
|
||||
return;
|
||||
}
|
||||
if (currentPart && currentPart.id !== nextPart.id) {
|
||||
currentPart = nextPart;
|
||||
}
|
||||
}
|
||||
|
||||
function partVisibleRows(part) {
|
||||
const ids = new Set((part && part.row_ids) || []);
|
||||
return filteredRows.filter((row) => ids.has(row.id));
|
||||
@@ -302,6 +476,22 @@ function scopeVisibleRows() {
|
||||
return filteredRows;
|
||||
}
|
||||
|
||||
function imageChapterMatchesSearch(chapter) {
|
||||
if (!isImageChapter(chapter)) return false;
|
||||
if (showTouchedOnly) return false;
|
||||
const q = dom.searchInput.value.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const haystack = [
|
||||
chapter.id,
|
||||
chapter.title,
|
||||
chapter.file_label,
|
||||
...(chapter.images || []).flatMap((image) => [image.asset_name, image.alt, image.asset_path]),
|
||||
]
|
||||
.join("\n")
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
}
|
||||
|
||||
async function loadSession() {
|
||||
const session = await api("/api/session");
|
||||
setVersionBadge(session.version);
|
||||
@@ -312,8 +502,9 @@ async function loadSession() {
|
||||
setShellMode(true);
|
||||
const chapterCount = structure.chapter_count || (structure.chapters || []).length;
|
||||
const partCount = structure.part_count || allParts().length;
|
||||
const imageCount = structure.image_count || 0;
|
||||
const sourceName = session.source_name || session.source_epub;
|
||||
dom.sessionMeta.textContent = `${sourceName} · ${session.row_count} 段 · ${chapterCount} 章 / ${partCount} part · 已记录 ${session.touched_count} 段`;
|
||||
dom.sessionMeta.textContent = `${sourceName} · ${session.row_count} 段 · ${chapterCount} 章 / ${partCount} part / ${imageCount} 张图 · 已记录 ${session.touched_count} 段`;
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -333,7 +524,7 @@ async function loadRows(keepSelection = false) {
|
||||
currentRow = nextRow;
|
||||
currentIndex = filteredRows.findIndex((row) => row.id === nextRow.id);
|
||||
if (currentPart && !(currentPart.row_ids || []).includes(nextRow.id)) {
|
||||
currentPart = partForRow(nextRow);
|
||||
syncScopeToRow(nextRow);
|
||||
}
|
||||
renderAll();
|
||||
return;
|
||||
@@ -342,7 +533,7 @@ async function loadRows(keepSelection = false) {
|
||||
|
||||
if (!currentPart) {
|
||||
currentChapter = (structure.chapters || [])[0] || null;
|
||||
currentPart = currentChapter && (currentChapter.parts || []).length === 1 ? currentChapter.parts[0] : null;
|
||||
currentPart = currentChapter && !isImageChapter(currentChapter) && (currentChapter.parts || []).length === 1 ? currentChapter.parts[0] : null;
|
||||
}
|
||||
if (filteredRows.length > 0 && currentIndex < 0) {
|
||||
currentRow = filteredRows[0];
|
||||
@@ -405,6 +596,7 @@ function updateStructureCountsFromRows() {
|
||||
chapter.row_count = 0;
|
||||
chapter.touched_count = 0;
|
||||
chapter.marked_count = 0;
|
||||
if (isImageChapter(chapter)) continue;
|
||||
for (const part of chapter.parts || []) {
|
||||
const partItems = partRows(part);
|
||||
part.row_count = partItems.length;
|
||||
@@ -464,35 +656,69 @@ function renderMode() {
|
||||
dom.editorPane.hidden = currentMode !== "polish";
|
||||
dom.readingModeBtn.classList.toggle("active", currentMode === "reading");
|
||||
dom.polishModeBtn.classList.toggle("active", currentMode === "polish");
|
||||
dom.polishModeBtn.disabled = !hasSession || (isImageChapter(currentChapter) && !currentPart);
|
||||
document.body.classList.toggle("quickOpen", currentMode === "reading" && !dom.quickEditor.hidden);
|
||||
if (sidePanelMode) {
|
||||
dom.tocPanel.hidden = sidePanelMode !== "toc";
|
||||
dom.searchPanel.hidden = sidePanelMode !== "search";
|
||||
}
|
||||
}
|
||||
|
||||
function renderToc() {
|
||||
dom.toc.innerHTML = "";
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const chapter of structure.chapters || []) {
|
||||
const parts = chapter.parts || [];
|
||||
const chapterIsImage = isImageChapter(chapter);
|
||||
const isCurrentChapter = currentChapter && chapter.id === currentChapter.id;
|
||||
const containsCurrentPart = parts.some((part) => currentPart && part.id === currentPart.id);
|
||||
const expanded = !chapterIsImage && (chapterIsExpanded(chapter) || containsCurrentPart);
|
||||
const group = document.createElement("section");
|
||||
group.className = "tocChapter";
|
||||
if (chapterIsImage) group.classList.add("imageChapter");
|
||||
if (expanded) group.classList.add("expanded");
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "tocChapterRow";
|
||||
if ((isCurrentChapter && !currentPart) || containsCurrentPart) {
|
||||
row.classList.add("active");
|
||||
}
|
||||
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "tocToggle";
|
||||
toggle.textContent = chapterIsImage ? "图" : expanded ? "▾" : "▸";
|
||||
toggle.disabled = chapterIsImage || parts.length === 0;
|
||||
toggle.setAttribute("aria-label", chapterIsImage ? "插图章节" : expanded ? "收起章节" : "展开章节");
|
||||
toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
|
||||
toggle.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
if (chapterIsImage) return;
|
||||
toggleChapterExpanded(chapter);
|
||||
});
|
||||
row.appendChild(toggle);
|
||||
|
||||
const title = document.createElement("button");
|
||||
title.type = "button";
|
||||
title.className = "tocChapterButton";
|
||||
if (
|
||||
(currentChapter && chapter.id === currentChapter.id && !currentPart)
|
||||
|| (chapter.parts || []).some((part) => currentPart && part.id === currentPart.id)
|
||||
(isCurrentChapter && !currentPart)
|
||||
|| containsCurrentPart
|
||||
) {
|
||||
title.classList.add("active");
|
||||
}
|
||||
title.innerHTML = `
|
||||
<span class="tocTitle">${escapeHtml(chapter.title || chapter.id)}</span>
|
||||
<span class="tocCount">${chapter.row_count || 0} 段</span>
|
||||
<span class="tocCount">${chapterIsImage ? `${(chapter.images || []).length || 1} 图` : `${chapter.row_count || 0} 段`}</span>
|
||||
`;
|
||||
title.addEventListener("click", () => selectChapter(chapter));
|
||||
group.appendChild(title);
|
||||
row.appendChild(title);
|
||||
group.appendChild(row);
|
||||
|
||||
const partList = document.createElement("div");
|
||||
partList.className = "tocParts";
|
||||
for (const part of chapter.parts || []) {
|
||||
partList.hidden = !expanded;
|
||||
if (!chapterIsImage) for (const part of parts) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "tocPartButton";
|
||||
@@ -517,10 +743,28 @@ function renderList() {
|
||||
const touched = rows.filter(rowTouched).length;
|
||||
const marked = rows.filter((row) => row.marked).length;
|
||||
const visibleInScope = scopeVisibleRows();
|
||||
dom.stats.textContent = `显示 ${filteredRows.length} / ${rows.length} 段 · 当前 ${visibleInScope.length} 段 · 已记录 ${touched} · 标记 ${marked}`;
|
||||
const imageCount = structure.image_count || 0;
|
||||
const isCurrentImage = isImageChapter(currentChapter) && !currentPart;
|
||||
dom.stats.textContent = `显示 ${filteredRows.length} / ${rows.length} 段 · 当前 ${isCurrentImage ? `${(currentChapter.images || []).length} 图` : `${visibleInScope.length} 段`} · 插图 ${imageCount} · 已记录 ${touched} · 标记 ${marked}`;
|
||||
dom.rowList.innerHTML = "";
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
if (isCurrentImage) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "rowItem imageRowItem active";
|
||||
const images = currentChapter.images || [];
|
||||
item.innerHTML = `
|
||||
<div class="rowMeta">
|
||||
<span>${escapeHtml(currentChapter.id)}</span>
|
||||
<span class="rowBadge">插图</span>
|
||||
<span>${escapeHtml(currentChapter.file_label || "")}</span>
|
||||
</div>
|
||||
<div class="rowPreview">${escapeHtml(images.map((image) => image.asset_name || image.asset_path).join(" / ") || "插图章节")}</div>
|
||||
`;
|
||||
frag.appendChild(item);
|
||||
dom.rowList.appendChild(frag);
|
||||
return;
|
||||
}
|
||||
visibleInScope.forEach((row) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
@@ -538,12 +782,37 @@ function renderList() {
|
||||
</div>
|
||||
<div class="rowPreview">${escapeHtml(rowCurrentText(row))}</div>
|
||||
`;
|
||||
button.addEventListener("click", () => maybeSelectRow(row, { openEditor: currentMode === "polish", scroll: true }));
|
||||
button.addEventListener("click", () => maybeSelectRow(row, { openEditor: currentMode === "polish", openQuick: currentMode === "reading", scroll: true }));
|
||||
frag.appendChild(button);
|
||||
});
|
||||
dom.rowList.appendChild(frag);
|
||||
}
|
||||
|
||||
function renderImageChapter(chapter) {
|
||||
const images = chapter.images || [];
|
||||
dom.chapterKicker.textContent = chapter.file_label || "插图";
|
||||
dom.readerTitle.textContent = chapter.title || "插图";
|
||||
dom.readingFlow.innerHTML = "";
|
||||
if (!imageChapterMatchesSearch(chapter)) {
|
||||
dom.readingFlow.innerHTML = '<div class="emptyState">当前筛选条件下没有插图。</div>';
|
||||
return;
|
||||
}
|
||||
const frag = document.createDocumentFragment();
|
||||
images.forEach((image, index) => {
|
||||
const figure = document.createElement("figure");
|
||||
figure.className = "imageChapterFigure";
|
||||
figure.innerHTML = `
|
||||
<img src="${escapeHtml(image.asset_url || "")}" alt="${escapeHtml(image.alt || chapter.title || "")}" loading="lazy">
|
||||
<figcaption>
|
||||
<span>${escapeHtml(chapter.title || `插图 ${index + 1}`)}</span>
|
||||
<span>${escapeHtml(image.asset_name || image.asset_path || "")}</span>
|
||||
</figcaption>
|
||||
`;
|
||||
frag.appendChild(figure);
|
||||
});
|
||||
dom.readingFlow.appendChild(frag);
|
||||
}
|
||||
|
||||
function renderReading() {
|
||||
const selectedChapter = currentChapter || chapterForPart(currentPart);
|
||||
if (!currentPart && !selectedChapter) {
|
||||
@@ -554,6 +823,10 @@ function renderReading() {
|
||||
}
|
||||
|
||||
const selectedPart = currentPart;
|
||||
if (!selectedPart && isImageChapter(selectedChapter)) {
|
||||
renderImageChapter(selectedChapter);
|
||||
return;
|
||||
}
|
||||
dom.chapterKicker.textContent = selectedChapter ? selectedChapter.title : selectedPart.file_label || "";
|
||||
dom.readerTitle.textContent = selectedPart
|
||||
? selectedPart.title || selectedPart.file_label || "未命名 part"
|
||||
@@ -587,31 +860,13 @@ function renderReading() {
|
||||
if (rowTouched(row)) block.classList.add("touched");
|
||||
if (row.marked) block.classList.add("marked");
|
||||
|
||||
const status = row.marked ? "已标记" : row.edited ? "已编辑" : row.comment ? "有备注" : "";
|
||||
block.innerHTML = `
|
||||
<div class="readText">
|
||||
<div class="readSource">${row.jp_html || escapeHtml(row.jp_text)}</div>
|
||||
<div class="readCn">${row.current_html || row.cn_html || escapeHtml(row.cn_text)}</div>
|
||||
</div>
|
||||
<div class="readMeta">
|
||||
<span>${escapeHtml(row.id)} · #${row.file_row_index || row.cn_p_index}</span>
|
||||
${status ? `<span class="readStatus">${escapeHtml(status)}</span>` : ""}
|
||||
<button class="miniButton" type="button" data-action="quick">快速编辑</button>
|
||||
<button class="miniButton" type="button" data-action="mark">标记问题</button>
|
||||
</div>
|
||||
`;
|
||||
block.addEventListener("click", (event) => {
|
||||
const action = event.target && event.target.dataset && event.target.dataset.action;
|
||||
if (action === "quick") {
|
||||
event.stopPropagation();
|
||||
openQuickEditor(row);
|
||||
return;
|
||||
}
|
||||
if (action === "mark") {
|
||||
event.stopPropagation();
|
||||
markRowFromReading(row);
|
||||
return;
|
||||
}
|
||||
block.addEventListener("click", () => {
|
||||
maybeSelectRow(row, { openQuick: true, scroll: false });
|
||||
});
|
||||
frag.appendChild(block);
|
||||
@@ -620,8 +875,20 @@ function renderReading() {
|
||||
}
|
||||
|
||||
function renderEditor() {
|
||||
if (isImageChapter(currentChapter) && !currentPart) {
|
||||
dom.emptyState.hidden = false;
|
||||
dom.emptyState.textContent = "插图章节没有可编辑译文,请切回阅读模式查看图片。";
|
||||
dom.editorCard.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (currentRow && !scopeCoversRow(currentRow)) {
|
||||
const visible = scopeVisibleRows();
|
||||
const fallback = scopeRows();
|
||||
currentRow = visible[0] || fallback[0] || null;
|
||||
}
|
||||
if (!currentRow) {
|
||||
dom.emptyState.hidden = false;
|
||||
dom.emptyState.textContent = "选择左侧段落开始审校。";
|
||||
dom.editorCard.hidden = true;
|
||||
return;
|
||||
}
|
||||
@@ -654,6 +921,10 @@ function fillQuickEditor(row) {
|
||||
dom.quickTagsInput.value = row.tags || "";
|
||||
dom.quickCommentInput.value = row.comment || "";
|
||||
dom.quickLearnNoteInput.value = row.learn_note || "";
|
||||
gptCandidateHtml = "";
|
||||
dom.gptCandidate.hidden = true;
|
||||
dom.gptCandidate.innerHTML = "";
|
||||
dom.applyRetranslationBtn.disabled = true;
|
||||
quickDirty = false;
|
||||
}
|
||||
|
||||
@@ -714,7 +985,7 @@ async function saveRow(row, data, silent = false) {
|
||||
dirty = false;
|
||||
quickDirty = false;
|
||||
applyFilter(false);
|
||||
renderAll();
|
||||
withReaderAnchor(() => renderAll());
|
||||
await loadSession();
|
||||
if (!silent) toast("已保存本段记录。");
|
||||
return true;
|
||||
@@ -726,6 +997,7 @@ async function saveRow(row, data, silent = false) {
|
||||
|
||||
async function saveCurrent(silent = false) {
|
||||
if (!hasSession) return true;
|
||||
if (isImageChapter(currentChapter) && !currentPart) return true;
|
||||
if (!currentRow) return true;
|
||||
return saveRow(currentRow, collectCurrent(), silent);
|
||||
}
|
||||
@@ -750,11 +1022,7 @@ function selectRow(row, options = {}) {
|
||||
if (!row) return;
|
||||
currentRow = row;
|
||||
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
|
||||
const nextPart = partForRow(row);
|
||||
if (nextPart) {
|
||||
currentChapter = chapterForPart(nextPart);
|
||||
currentPart = nextPart;
|
||||
}
|
||||
syncScopeToRow(row, Boolean(options.forcePart));
|
||||
dirty = false;
|
||||
|
||||
if (options.openEditor) {
|
||||
@@ -773,15 +1041,25 @@ async function selectChapter(chapter) {
|
||||
const ok = await maybeSaveBeforeSwitch();
|
||||
if (!ok) return;
|
||||
currentChapter = chapter;
|
||||
currentPart = (chapter.parts || []).length === 1 ? chapter.parts[0] : null;
|
||||
const visible = scopeVisibleRows();
|
||||
const fallback = scopeRows();
|
||||
currentRow = visible[0] || fallback[0] || currentRow;
|
||||
currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1;
|
||||
currentPart = null;
|
||||
expandedChapters.add(chapter.id);
|
||||
if (isImageChapter(chapter)) {
|
||||
currentRow = null;
|
||||
currentIndex = -1;
|
||||
closeQuickEditor(false);
|
||||
currentMode = "reading";
|
||||
} else {
|
||||
const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || []));
|
||||
const visible = filteredRows.filter((row) => ids.has(row.id));
|
||||
const fallback = rows.filter((row) => ids.has(row.id));
|
||||
currentRow = visible[0] || fallback[0] || currentRow;
|
||||
currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1;
|
||||
}
|
||||
renderAll();
|
||||
if (currentMode === "reading") {
|
||||
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
if (sidePanelMode === "toc") closeSidePanel();
|
||||
}
|
||||
|
||||
async function selectPart(part) {
|
||||
@@ -790,6 +1068,7 @@ async function selectPart(part) {
|
||||
if (!ok) return;
|
||||
currentChapter = chapterForPart(part);
|
||||
currentPart = part;
|
||||
if (currentChapter) expandedChapters.add(currentChapter.id);
|
||||
const visible = partVisibleRows(part);
|
||||
const fallback = partRows(part);
|
||||
currentRow = visible[0] || fallback[0] || currentRow;
|
||||
@@ -798,6 +1077,7 @@ async function selectPart(part) {
|
||||
if (currentMode === "reading") {
|
||||
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
if (sidePanelMode === "toc") closeSidePanel();
|
||||
}
|
||||
|
||||
function scrollRowIntoView(rowId) {
|
||||
@@ -814,27 +1094,34 @@ async function setMode(mode, saveBefore = true) {
|
||||
const ok = await maybeSaveBeforeSwitch();
|
||||
if (!ok) return;
|
||||
}
|
||||
if (mode === "polish" && isImageChapter(currentChapter) && !currentPart) {
|
||||
toast("插图章节没有可编辑译文,已保持阅读模式。");
|
||||
mode = "reading";
|
||||
}
|
||||
currentMode = mode;
|
||||
if (mode === "reading" && currentPart && isImageChapter(currentChapter)) {
|
||||
currentPart = null;
|
||||
}
|
||||
if (mode === "polish") {
|
||||
closeQuickEditor(false);
|
||||
if (!currentRow && filteredRows.length) currentRow = filteredRows[0];
|
||||
if (!scopeCoversRow(currentRow)) currentRow = null;
|
||||
if (!currentRow) currentRow = scopeVisibleRows()[0] || scopeRows()[0] || filteredRows[0] || null;
|
||||
}
|
||||
renderAll();
|
||||
withReaderAnchor(() => renderAll());
|
||||
if (mode === "reading" && currentRow) scrollRowIntoView(currentRow.id);
|
||||
}
|
||||
|
||||
function openQuickEditor(row) {
|
||||
currentRow = row;
|
||||
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
|
||||
const nextPart = partForRow(row);
|
||||
if (nextPart) {
|
||||
currentChapter = chapterForPart(nextPart);
|
||||
currentPart = nextPart;
|
||||
}
|
||||
fillQuickEditor(row);
|
||||
dom.quickEditor.hidden = false;
|
||||
document.body.classList.add("quickOpen");
|
||||
renderAll();
|
||||
withReaderAnchor(() => {
|
||||
currentRow = row;
|
||||
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
|
||||
syncScopeToRow(row);
|
||||
fillQuickEditor(row);
|
||||
dom.quickEditor.hidden = false;
|
||||
document.body.classList.add("quickOpen");
|
||||
renderAll();
|
||||
});
|
||||
loadGptConfig().catch(() => {});
|
||||
}
|
||||
|
||||
function closeQuickEditor(save = false) {
|
||||
@@ -842,25 +1129,12 @@ function closeQuickEditor(save = false) {
|
||||
saveCurrent(true);
|
||||
return;
|
||||
}
|
||||
dom.quickEditor.hidden = true;
|
||||
document.body.classList.remove("quickOpen");
|
||||
quickDirty = false;
|
||||
}
|
||||
|
||||
async function markRowFromReading(row) {
|
||||
const ok = await maybeSaveBeforeSwitch();
|
||||
if (!ok) return;
|
||||
const data = {
|
||||
current_html: row.current_html || row.cn_html || "",
|
||||
marked: true,
|
||||
issue_type: row.issue_type || "翻译腔",
|
||||
severity: row.severity || "",
|
||||
tags: row.tags || "",
|
||||
comment: row.comment || "",
|
||||
learn_note: row.learn_note || "",
|
||||
};
|
||||
await saveRow(row, data, false);
|
||||
openQuickEditor(rowById(row.id) || row);
|
||||
withReaderAnchor(() => {
|
||||
dom.quickEditor.hidden = true;
|
||||
document.body.classList.remove("quickOpen");
|
||||
quickDirty = false;
|
||||
renderAll();
|
||||
});
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
@@ -871,6 +1145,85 @@ function markQuickDirty() {
|
||||
quickDirty = true;
|
||||
}
|
||||
|
||||
function renderGptConfig(config) {
|
||||
dom.gptBaseUrlInput.value = config.base_url || "https://api.openai.com/v1";
|
||||
dom.gptModelInput.value = config.model || "gpt-4o-mini";
|
||||
dom.gptApiKeyInput.value = "";
|
||||
const source = config.key_source === "env" ? "环境变量" : config.key_source === "config" ? "本地配置" : "未配置";
|
||||
dom.gptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key,保存设置后可重翻。";
|
||||
}
|
||||
|
||||
async function loadGptConfig(force = false) {
|
||||
if (gptConfigLoaded && !force) return;
|
||||
try {
|
||||
const config = await api("/api/gpt/config");
|
||||
renderGptConfig(config);
|
||||
gptConfigLoaded = true;
|
||||
} catch (error) {
|
||||
dom.gptStatus.textContent = `读取 GPT 配置失败:${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGptConfig() {
|
||||
try {
|
||||
const config = await api("/api/gpt/config", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
base_url: dom.gptBaseUrlInput.value,
|
||||
model: dom.gptModelInput.value,
|
||||
api_key: dom.gptApiKeyInput.value,
|
||||
keep_existing: true,
|
||||
}),
|
||||
});
|
||||
renderGptConfig(config);
|
||||
gptConfigLoaded = true;
|
||||
toast("GPT API 设置已保存。");
|
||||
} catch (error) {
|
||||
toast(`保存 GPT 设置失败:${error.message}`, 7000);
|
||||
}
|
||||
}
|
||||
|
||||
async function retranslateCurrentRow() {
|
||||
if (!currentRow) return;
|
||||
if (quickDirty) {
|
||||
const ok = await saveCurrent(true);
|
||||
if (!ok) return;
|
||||
}
|
||||
dom.retranslateBtn.disabled = true;
|
||||
dom.retranslateBtn.textContent = "重翻中……";
|
||||
dom.applyRetranslationBtn.disabled = true;
|
||||
dom.gptCandidate.hidden = false;
|
||||
dom.gptCandidate.textContent = "正在请求 GPT……";
|
||||
try {
|
||||
const data = await api(`/api/row/${encodeURIComponent(currentRow.id)}/retranslate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
instruction: dom.gptInstructionInput.value,
|
||||
base_url: dom.gptBaseUrlInput.value,
|
||||
model: dom.gptModelInput.value,
|
||||
}),
|
||||
});
|
||||
gptCandidateHtml = data.candidate_html || "";
|
||||
dom.gptCandidate.innerHTML = gptCandidateHtml || escapeHtml(data.candidate_text || "");
|
||||
dom.applyRetranslationBtn.disabled = !gptCandidateHtml;
|
||||
toast("已生成重翻候选,可确认后应用。");
|
||||
} catch (error) {
|
||||
dom.gptCandidate.textContent = `重翻失败:${error.message}`;
|
||||
toast(`重翻失败:${error.message}`, 9000);
|
||||
} finally {
|
||||
dom.retranslateBtn.disabled = false;
|
||||
dom.retranslateBtn.textContent = "重翻本段";
|
||||
}
|
||||
}
|
||||
|
||||
function applyRetranslation() {
|
||||
if (!gptCandidateHtml) return;
|
||||
dom.quickCnEditor.innerHTML = gptCandidateHtml;
|
||||
quickDirty = true;
|
||||
dom.applyRetranslationBtn.disabled = true;
|
||||
toast("已应用到编辑框,保存后才会写入审校记录。");
|
||||
}
|
||||
|
||||
function resetCurrentText() {
|
||||
if (!currentRow) return;
|
||||
dom.cnEditor.innerHTML = currentRow.cn_html || "";
|
||||
@@ -993,11 +1346,28 @@ function initEvents() {
|
||||
currentIndex = -1;
|
||||
applyFilter(true);
|
||||
});
|
||||
dom.tocPanelBtn.addEventListener("click", () => {
|
||||
if (sidePanelMode === "toc") {
|
||||
closeSidePanel();
|
||||
} else {
|
||||
openSidePanel("toc");
|
||||
}
|
||||
});
|
||||
dom.searchPanelBtn.addEventListener("click", () => {
|
||||
if (sidePanelMode === "search") {
|
||||
closeSidePanel();
|
||||
} else {
|
||||
openSidePanel("search");
|
||||
}
|
||||
});
|
||||
dom.closeSidebarBtn.addEventListener("click", closeSidePanel);
|
||||
dom.showTouchedBtn.addEventListener("click", () => {
|
||||
showTouchedOnly = !showTouchedOnly;
|
||||
dom.showTouchedBtn.classList.toggle("primary", showTouchedOnly);
|
||||
applyFilter(true);
|
||||
});
|
||||
dom.expandTocBtn.addEventListener("click", expandAllChapters);
|
||||
dom.collapseTocBtn.addEventListener("click", collapseAllChapters);
|
||||
dom.readingModeBtn.addEventListener("click", () => setMode("reading"));
|
||||
dom.polishModeBtn.addEventListener("click", () => setMode("polish"));
|
||||
dom.openLibraryBtn.addEventListener("click", async () => {
|
||||
@@ -1026,6 +1396,13 @@ function initEvents() {
|
||||
dom.closeQuickEditorBtn.addEventListener("click", () => closeQuickEditor(false));
|
||||
dom.quickSaveBtn.addEventListener("click", () => saveCurrent(false));
|
||||
dom.quickMarkSelectionBtn.addEventListener("click", markQuickSelection);
|
||||
dom.toggleGptConfigBtn.addEventListener("click", () => {
|
||||
dom.gptConfig.hidden = !dom.gptConfig.hidden;
|
||||
if (!dom.gptConfig.hidden) loadGptConfig(true).catch(() => {});
|
||||
});
|
||||
dom.saveGptConfigBtn.addEventListener("click", saveGptConfig);
|
||||
dom.retranslateBtn.addEventListener("click", retranslateCurrentRow);
|
||||
dom.applyRetranslationBtn.addEventListener("click", applyRetranslation);
|
||||
dom.quickOpenFullBtn.addEventListener("click", async () => {
|
||||
if (quickDirty) {
|
||||
const ok = await saveCurrent(true);
|
||||
@@ -1060,6 +1437,11 @@ function initEvents() {
|
||||
if (event.key === "Escape" && !dom.quickEditor.hidden) {
|
||||
event.preventDefault();
|
||||
closeQuickEditor(false);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && sidePanelMode) {
|
||||
event.preventDefault();
|
||||
closeSidePanel();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1077,7 +1459,7 @@ async function main() {
|
||||
if (!session || !session.has_session) {
|
||||
return;
|
||||
}
|
||||
await loadActiveSession({ message: "阅读模式已就绪。可连续阅读,点击段落即可快速编辑或标记。" });
|
||||
await loadActiveSession({ message: "阅读模式已就绪。可连续阅读,点击段落即可打开精修与重翻面板。" });
|
||||
} catch (error) {
|
||||
await showStartScreen();
|
||||
toast(`启动时未载入 EPUB:${error.message}`, 7000);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brandBlock">
|
||||
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.2.0</span></h1>
|
||||
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.4.0</span></h1>
|
||||
<p id="sessionMeta">正在载入……</p>
|
||||
</div>
|
||||
<div class="modeTabs" role="tablist" aria-label="审校模式">
|
||||
@@ -17,6 +17,8 @@
|
||||
<button id="polishModeBtn" class="modeButton" type="button">精修模式</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<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>
|
||||
@@ -55,13 +57,28 @@
|
||||
</main>
|
||||
|
||||
<main class="layout" id="reviewLayout" hidden>
|
||||
<aside class="sidebar">
|
||||
<div class="searchbox">
|
||||
<input id="searchInput" type="search" placeholder="搜索原文、译文、备注">
|
||||
<aside class="sidebar" id="sidebar" hidden>
|
||||
<div class="sideHeader">
|
||||
<strong id="sideTitle">目录</strong>
|
||||
<button id="closeSidebarBtn" type="button" aria-label="隐藏侧栏">隐藏</button>
|
||||
</div>
|
||||
<div class="stats" id="stats"></div>
|
||||
<nav class="toc" id="toc" aria-label="章节目录"></nav>
|
||||
<div class="rowList" id="rowList"></div>
|
||||
<section class="tocPanel" id="tocPanel">
|
||||
<div class="tocToolbar" aria-label="目录操作">
|
||||
<strong>章节目录</strong>
|
||||
<div>
|
||||
<button id="expandTocBtn" type="button">展开全部</button>
|
||||
<button id="collapseTocBtn" type="button">收起</button>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="toc" id="toc" aria-label="章节目录"></nav>
|
||||
</section>
|
||||
<section class="searchPanel" id="searchPanel" hidden>
|
||||
<div class="searchbox">
|
||||
<input id="searchInput" type="search" placeholder="搜索原文、译文、备注">
|
||||
</div>
|
||||
<div class="stats" id="stats"></div>
|
||||
<div class="rowList" id="rowList"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="readerPane" id="readerPane">
|
||||
@@ -160,13 +177,13 @@
|
||||
</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>
|
||||
<summary>日文原文</summary>
|
||||
@@ -176,6 +193,37 @@
|
||||
中文译文
|
||||
<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>
|
||||
</div>
|
||||
<div class="gptStatus" id="gptStatus">未读取配置</div>
|
||||
<div class="gptConfig" id="gptConfig" hidden>
|
||||
<label>
|
||||
Base URL
|
||||
<input id="gptBaseUrlInput" type="url" placeholder="https://api.openai.com/v1">
|
||||
</label>
|
||||
<label>
|
||||
Model
|
||||
<input id="gptModelInput" type="text" placeholder="gpt-4o-mini">
|
||||
</label>
|
||||
<label>
|
||||
API Key
|
||||
<input id="gptApiKeyInput" type="password" placeholder="留空则保留已有密钥">
|
||||
</label>
|
||||
<button id="saveGptConfigBtn" type="button">保存 API 设置</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">
|
||||
@@ -215,7 +263,7 @@
|
||||
<div class="quickActions">
|
||||
<button id="quickMarkSelectionBtn" type="button">标记选中文本</button>
|
||||
<button id="quickSaveBtn" type="button">保存</button>
|
||||
<button id="quickOpenFullBtn" type="button">进入精修</button>
|
||||
<button id="quickOpenFullBtn" type="button">打开完整精修</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -102,6 +102,12 @@ h1 {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toolbar button.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -298,17 +304,48 @@ button.primary:hover,
|
||||
|
||||
.layout {
|
||||
height: calc(100vh - 78px);
|
||||
display: grid;
|
||||
grid-template-columns: 340px minmax(0, 1fr);
|
||||
display: block;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 78px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 18;
|
||||
width: min(380px, calc(100vw - 24px));
|
||||
border-right: 1px solid var(--line);
|
||||
background: #f9fbfd;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.sideHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.sideHeader button {
|
||||
min-height: 28px;
|
||||
padding: 2px 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tocPanel,
|
||||
.searchPanel {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.searchbox {
|
||||
@@ -332,17 +369,68 @@ button.primary:hover,
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tocToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 4px;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tocToolbar div {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tocToolbar button {
|
||||
min-height: 26px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--muted);
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toc {
|
||||
max-height: 42%;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 8px 0;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
.tocChapter {
|
||||
margin: 0 8px 6px;
|
||||
}
|
||||
|
||||
.tocChapterRow {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.tocToggle {
|
||||
width: 24px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tocToggle:disabled {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tocChapterButton,
|
||||
.tocPartButton {
|
||||
width: 100%;
|
||||
@@ -361,16 +449,25 @@ button.primary:hover,
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tocChapter.imageChapter .tocChapterButton {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tocPartButton {
|
||||
padding-left: 18px;
|
||||
margin-left: 24px;
|
||||
width: calc(100% - 24px);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tocChapterRow:hover,
|
||||
.tocChapterRow.active,
|
||||
.tocChapterButton:hover,
|
||||
.tocToggle:hover,
|
||||
.tocPartButton:hover {
|
||||
background: #eef3f6;
|
||||
}
|
||||
|
||||
.tocChapterRow.active,
|
||||
.tocChapterButton.active,
|
||||
.tocPartButton.active {
|
||||
background: var(--accent-soft);
|
||||
@@ -427,6 +524,14 @@ button.primary:hover,
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.imageRowItem {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.imageRowItem:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.rowMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -460,6 +565,7 @@ button.primary:hover,
|
||||
}
|
||||
|
||||
.readerPane {
|
||||
height: calc(100vh - 78px);
|
||||
padding: 0;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
@@ -496,9 +602,9 @@ button.primary:hover,
|
||||
}
|
||||
|
||||
.readingFlow {
|
||||
max-width: 920px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 38px 90px;
|
||||
padding: 28px 42px 100px;
|
||||
background: #ffffff;
|
||||
min-height: calc(100vh - 156px);
|
||||
border-left: 1px solid #e7edf3;
|
||||
@@ -507,8 +613,9 @@ button.primary:hover,
|
||||
|
||||
.readBlock {
|
||||
position: relative;
|
||||
padding: 10px 0 14px;
|
||||
border-bottom: 1px solid #e7edf3;
|
||||
padding: 18px 0 22px 12px;
|
||||
border-left: 4px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.partDivider {
|
||||
@@ -520,30 +627,28 @@ button.primary:hover,
|
||||
}
|
||||
|
||||
.readBlock:hover {
|
||||
background: linear-gradient(90deg, rgba(29, 111, 95, 0.06), transparent 38%);
|
||||
background: linear-gradient(90deg, rgba(29, 111, 95, 0.055), transparent 42%);
|
||||
}
|
||||
|
||||
.readBlock.active {
|
||||
box-shadow: inset 4px 0 0 var(--accent);
|
||||
padding-left: 12px;
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.readBlock.marked {
|
||||
box-shadow: inset 4px 0 0 var(--warn);
|
||||
padding-left: 12px;
|
||||
border-left-color: var(--warn);
|
||||
}
|
||||
|
||||
.readSource {
|
||||
color: var(--source);
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
margin-bottom: 5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.readCn {
|
||||
color: var(--ink);
|
||||
font-size: 17px;
|
||||
line-height: 1.9;
|
||||
line-height: 1.95;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
@@ -555,37 +660,35 @@ button.primary:hover,
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.readMeta {
|
||||
.imageChapterFigure {
|
||||
margin: 0 auto 28px;
|
||||
padding: 0 0 22px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.imageChapterFigure img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 190px);
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.imageChapterFigure figcaption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.readBlock:hover .readMeta,
|
||||
.readBlock.active .readMeta,
|
||||
.readBlock.touched .readMeta,
|
||||
.readBlock.marked .readMeta {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.readStatus {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.miniButton {
|
||||
min-height: 26px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.editorPane {
|
||||
height: calc(100vh - 78px);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
@@ -734,9 +837,9 @@ textarea {
|
||||
|
||||
.quickEditor {
|
||||
position: fixed;
|
||||
top: 92px;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
top: 78px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(520px, calc(100vw - 36px));
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
@@ -745,7 +848,10 @@ textarea {
|
||||
overflow: auto;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-top: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 14px;
|
||||
}
|
||||
@@ -808,6 +914,47 @@ textarea {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.gptBox {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.gptStatus {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.gptConfig {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.gptConfig[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.gptActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.gptCandidate {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--accent);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
@@ -826,9 +973,9 @@ textarea {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1180px) {
|
||||
@media (min-width: 1320px) {
|
||||
body.quickOpen .readingFlow {
|
||||
margin-left: max(40px, calc((100% - 1320px) / 2));
|
||||
margin-left: 56px;
|
||||
margin-right: 560px;
|
||||
max-width: 840px;
|
||||
}
|
||||
@@ -852,21 +999,18 @@ textarea {
|
||||
.layout {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 78px);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
max-height: 48vh;
|
||||
top: 0;
|
||||
width: min(360px, calc(100vw - 18px));
|
||||
max-height: none;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.toc {
|
||||
max-height: 24vh;
|
||||
}
|
||||
|
||||
.readerPane,
|
||||
.editorPane {
|
||||
height: auto;
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
@@ -891,6 +1035,8 @@ textarea {
|
||||
bottom: 10px;
|
||||
width: auto;
|
||||
max-height: 72vh;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.startScreen {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
|
||||
VERSION_RULES = {
|
||||
"PATCH": "修复 bug 或兼容性小修",
|
||||
|
||||
Reference in New Issue
Block a user