feat(editor): improve EPUB review workflow

This commit is contained in:
xin61
2026-07-07 13:24:22 +08:00
parent c14e3cb3d8
commit 193047b3dc
7 changed files with 1434 additions and 174 deletions
+140
View File
@@ -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` 不包含密钥字段
+15 -4
View File
@@ -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”。
+565 -32
View File
@@ -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()
+455 -73
View File
@@ -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);
+58 -10
View File
@@ -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>
+200 -54
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
version = "0.2.0"
version = "0.4.0"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",