Add standalone EPUB upload workflow
This commit is contained in:
@@ -2,8 +2,33 @@
|
||||
|
||||
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
|
||||
|
||||
## 独立安装
|
||||
|
||||
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
./.venv/bin/python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 启动
|
||||
|
||||
不预先指定 EPUB,直接打开上传界面:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONIOENCODING='utf-8'
|
||||
python 'C:\Users\xin61\Documents\轻小说翻译持续改进\tools\epub_review_editor\server.py'
|
||||
```
|
||||
|
||||
也可以像原来一样直接打开某个 EPUB:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONIOENCODING='utf-8'
|
||||
python 'C:\Users\xin61\Documents\轻小说翻译持续改进\tools\epub_review_editor\server.py' '<中日双语版.epub>' --daemon
|
||||
@@ -15,6 +40,21 @@ python 'C:\Users\xin61\Documents\轻小说翻译持续改进\tools\epub_review_e
|
||||
http://localhost:5177
|
||||
```
|
||||
|
||||
局域网或服务器部署时,把监听地址改为 `0.0.0.0`,并把审校数据目录固定到一个你会备份的位置:
|
||||
|
||||
```powershell
|
||||
python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessions
|
||||
```
|
||||
|
||||
当前版本没有登录鉴权,不要直接暴露到公网。需要远程使用时,优先放在可信局域网、VPN、反向代理鉴权或 SSH 隧道后面。
|
||||
|
||||
## 浏览器上传 EPUB
|
||||
|
||||
- 启动时不带 EPUB 路径,会进入开始页。
|
||||
- 点击“选择 EPUB 文件”,上传中日双语 EPUB 后会自动创建审校会话。
|
||||
- 开始页会列出已有会话,换电脑或重启服务后可以继续打开之前的审校记录。
|
||||
- 上传的 EPUB、解包副本、反馈与导出文件都会保存在 `--review-root` 指定目录下。
|
||||
|
||||
## 能做什么
|
||||
|
||||
- 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。
|
||||
@@ -30,7 +70,13 @@ http://localhost:5177
|
||||
|
||||
## 重要输出
|
||||
|
||||
每个 EPUB 会建立独立审校 session,默认位于:
|
||||
每个 EPUB 会建立独立审校 session。通过浏览器上传时,默认位于:
|
||||
|
||||
```text
|
||||
epub_review_sessions\<timestamp>_<epub-name>_<hash>\
|
||||
```
|
||||
|
||||
通过命令行直接指定 EPUB 时,仍沿用固定路径:
|
||||
|
||||
```text
|
||||
epub_review_sessions\<epub-name>_<hash>\
|
||||
@@ -44,6 +90,8 @@ epub_review_sessions\<epub-name>_<hash>\
|
||||
- `review_state/state.json`:网页编辑器当前状态。
|
||||
- `extracted/`:EPUB 解包后的工作副本。
|
||||
|
||||
在服务器上使用时,“生成反馈”和“导出审校 EPUB”会返回浏览器可访问的下载地址,不需要登录服务器找文件。
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 正式长篇 EPUB 优先使用“阅读模式”:先在左侧点章节/part 连续读,看到问题再点段落快速编辑或标记。
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Flask>=3.0,<4.0
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import copy
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
@@ -40,6 +39,7 @@ GRAY_RE = re.compile(
|
||||
|
||||
DEFAULT_REVIEW_ROOT = "epub_review_sessions"
|
||||
STATE_VERSION = 1
|
||||
UPLOAD_DIR_NAME = "uploads"
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
@@ -52,18 +52,22 @@ def safe_slug(value: str, max_len: int = 80) -> str:
|
||||
return value[:max_len] or "epub"
|
||||
|
||||
|
||||
def find_free_port(start: int) -> int:
|
||||
def find_free_port(start: int, host: str = "127.0.0.1") -> int:
|
||||
for port in range(start, start + 100):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
sock.bind(("127.0.0.1", port))
|
||||
sock.bind((host, port))
|
||||
except OSError:
|
||||
continue
|
||||
return port
|
||||
raise RuntimeError(f"no free port found from {start}")
|
||||
|
||||
|
||||
def display_host(host: str) -> str:
|
||||
return "localhost" if host in {"127.0.0.1", "0.0.0.0", "::"} else host
|
||||
|
||||
|
||||
def read_json(path: Path, default: Any) -> Any:
|
||||
if not path.exists():
|
||||
return copy.deepcopy(default)
|
||||
@@ -228,6 +232,147 @@ def session_root_for(review_root: Path, epub_path: Path) -> Path:
|
||||
return review_root / f"{safe_slug(epub_path.stem)}_{digest}"
|
||||
|
||||
|
||||
def app_manifest_path(review_root: Path) -> Path:
|
||||
return review_root / "app_state.json"
|
||||
|
||||
|
||||
def session_manifest_path(session_root: Path) -> Path:
|
||||
return session_root / "review_state" / "session.json"
|
||||
|
||||
|
||||
def session_public_id(session_root: Path) -> str:
|
||||
return session_root.name
|
||||
|
||||
|
||||
def session_root_from_id(review_root: Path, session_id: str) -> Path:
|
||||
safe_id = safe_slug(session_id, max_len=160)
|
||||
if safe_id != session_id:
|
||||
raise ValueError("invalid session id")
|
||||
root = (review_root / session_id).resolve()
|
||||
try:
|
||||
root.relative_to(review_root.resolve())
|
||||
except ValueError as exc:
|
||||
raise ValueError("invalid session id") from exc
|
||||
return root
|
||||
|
||||
|
||||
def write_session_manifest(session_root: Path, epub_path: Path, source_name: str = "") -> None:
|
||||
existing = read_json(session_manifest_path(session_root), {})
|
||||
record = {
|
||||
"id": session_public_id(session_root),
|
||||
"source_name": source_name or epub_path.name,
|
||||
"source_epub": str(epub_path),
|
||||
"session_root": str(session_root),
|
||||
"created_at": existing.get("created_at") or now_iso(),
|
||||
"updated_at": now_iso(),
|
||||
}
|
||||
write_json(session_manifest_path(session_root), record)
|
||||
|
||||
|
||||
def update_app_manifest(review_root: Path, session_root: Path | None) -> None:
|
||||
data = read_json(app_manifest_path(review_root), {})
|
||||
data["active_session_id"] = session_public_id(session_root) if session_root else ""
|
||||
data["updated_at"] = now_iso()
|
||||
write_json(app_manifest_path(review_root), data)
|
||||
|
||||
|
||||
def session_epub_path(session_root: Path) -> Path:
|
||||
manifest = read_json(session_manifest_path(session_root), {})
|
||||
source_epub = manifest.get("source_epub") or read_json(
|
||||
session_root / "review_state" / "state.json", {}
|
||||
).get("source_epub")
|
||||
if source_epub:
|
||||
path = Path(source_epub)
|
||||
if path.exists():
|
||||
return path
|
||||
fallback = session_root / "source.epub"
|
||||
if fallback.exists():
|
||||
return fallback
|
||||
return Path(source_epub or fallback)
|
||||
|
||||
|
||||
def summarize_session(session_root: Path) -> dict[str, Any]:
|
||||
manifest = read_json(session_manifest_path(session_root), {})
|
||||
state = read_json(session_root / "review_state" / "state.json", {})
|
||||
rows = merge_rows(session_root) if (session_root / "review_state" / "rows.json").exists() else []
|
||||
touched = [row for row in rows if rowTouched_py(row)]
|
||||
source_epub = manifest.get("source_epub") or state.get("source_epub", "")
|
||||
source_name = manifest.get("source_name") or (Path(source_epub).name if source_epub else session_root.name)
|
||||
exports_dir = session_root / "exports"
|
||||
latest_export = ""
|
||||
if exports_dir.exists():
|
||||
exports = sorted(exports_dir.glob("*.epub"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
latest_export = str(exports[0]) if exports else ""
|
||||
updated_at = state.get("updated_at") or manifest.get("updated_at") or ""
|
||||
return {
|
||||
"id": session_public_id(session_root),
|
||||
"source_name": source_name,
|
||||
"source_epub": source_epub,
|
||||
"session_root": str(session_root),
|
||||
"row_count": len(rows),
|
||||
"touched_count": len(touched),
|
||||
"marked_count": sum(1 for row in rows if row.get("marked")),
|
||||
"updated_at": updated_at,
|
||||
"created_at": state.get("created_at") or manifest.get("created_at") or "",
|
||||
"feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"),
|
||||
"feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"),
|
||||
"latest_export": latest_export,
|
||||
}
|
||||
|
||||
|
||||
def rowTouched_py(row: dict[str, Any]) -> bool:
|
||||
return bool(
|
||||
row.get("edited")
|
||||
or row.get("marked")
|
||||
or row.get("comment")
|
||||
or row.get("learn_note")
|
||||
or row.get("issue_type")
|
||||
or row.get("severity")
|
||||
or row.get("tags")
|
||||
)
|
||||
|
||||
|
||||
def list_review_sessions(review_root: Path) -> list[dict[str, Any]]:
|
||||
if not review_root.exists():
|
||||
return []
|
||||
sessions = []
|
||||
for child in review_root.iterdir():
|
||||
if not child.is_dir() or child.name == UPLOAD_DIR_NAME:
|
||||
continue
|
||||
if not (child / "review_state" / "state.json").exists():
|
||||
continue
|
||||
try:
|
||||
sessions.append(summarize_session(child))
|
||||
except Exception:
|
||||
continue
|
||||
sessions.sort(key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True)
|
||||
return sessions
|
||||
|
||||
|
||||
def validate_epub_file(path: Path) -> None:
|
||||
if path.suffix.lower() != ".epub":
|
||||
raise ValueError("只支持 .epub 文件")
|
||||
if not zipfile.is_zipfile(path):
|
||||
raise ValueError("文件不是有效的 EPUB/ZIP 包")
|
||||
|
||||
|
||||
def copy_upload_to_review_root(file_storage: Any, review_root: Path) -> Path:
|
||||
filename = safe_slug(Path(file_storage.filename or "uploaded.epub").name, max_len=140)
|
||||
if not filename.lower().endswith(".epub"):
|
||||
filename = f"{filename}.epub"
|
||||
upload_dir = review_root / UPLOAD_DIR_NAME
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = safe_slug(Path(filename).stem, max_len=100)
|
||||
target = upload_dir / f"{dt.datetime.now().strftime('%Y%m%d_%H%M%S')}_{stem}.epub"
|
||||
try:
|
||||
file_storage.save(target)
|
||||
validate_epub_file(target)
|
||||
return target
|
||||
except Exception:
|
||||
target.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def extracted_path(session_root: Path, entry_name: str) -> Path:
|
||||
root = (session_root / "extracted").resolve()
|
||||
target = (root / entry_name).resolve()
|
||||
@@ -640,7 +785,9 @@ def ensure_state(session_root: Path, epub_path: Path, reset: bool = False) -> di
|
||||
},
|
||||
)
|
||||
state.setdefault("edits", {})
|
||||
state["source_epub"] = str(epub_path)
|
||||
write_json(state_path, state)
|
||||
write_session_manifest(session_root, epub_path)
|
||||
return state
|
||||
|
||||
|
||||
@@ -752,11 +899,13 @@ def apply_edits_to_xhtml(session_root: Path) -> list[str]:
|
||||
def export_epub(session_root: Path) -> Path:
|
||||
apply_edits_to_xhtml(session_root)
|
||||
entries = read_json(session_root / "review_state" / "epub_entries.json", [])
|
||||
source_epub = Path(read_json(session_root / "review_state" / "state.json", {}).get("source_epub", "reviewed.epub"))
|
||||
manifest = read_json(session_manifest_path(session_root), {})
|
||||
state_source = read_json(session_root / "review_state" / "state.json", {}).get("source_epub", "reviewed.epub")
|
||||
source_name = manifest.get("source_name") or Path(state_source).name
|
||||
ts = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_dir = session_root / "exports"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = out_dir / f"{safe_slug(source_epub.stem)}_网页审校版_{ts}.epub"
|
||||
out_path = out_dir / f"{safe_slug(Path(source_name).stem)}_网页审校版_{ts}.epub"
|
||||
with zipfile.ZipFile(out_path, "w") as zf:
|
||||
names_written: set[str] = set()
|
||||
mimetype = extracted_path(session_root, "mimetype")
|
||||
@@ -784,46 +933,151 @@ def export_epub(session_root: Path) -> Path:
|
||||
return out_path
|
||||
|
||||
|
||||
def create_app(session_root: Path, epub_path: Path) -> Flask:
|
||||
def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: bool = False) -> Flask:
|
||||
app = Flask(
|
||||
__name__,
|
||||
root_path=str(Path(__file__).resolve().parent),
|
||||
static_folder="static",
|
||||
static_url_path="/static",
|
||||
)
|
||||
app.config["SESSION_ROOT"] = session_root
|
||||
app.config["EPUB_PATH"] = epub_path
|
||||
review_root.mkdir(parents=True, exist_ok=True)
|
||||
app.config["REVIEW_ROOT"] = review_root
|
||||
app.config["SESSION_ROOT"] = None
|
||||
app.config["EPUB_PATH"] = None
|
||||
app.config["LOCK"] = threading.Lock()
|
||||
|
||||
def set_active_session(session_root: Path, epub_path: Path | None = None) -> None:
|
||||
app.config["SESSION_ROOT"] = session_root
|
||||
app.config["EPUB_PATH"] = epub_path or session_epub_path(session_root)
|
||||
update_app_manifest(review_root, session_root)
|
||||
|
||||
def active_session() -> tuple[Path | None, Path | None]:
|
||||
session_root = app.config.get("SESSION_ROOT")
|
||||
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)
|
||||
return None, None
|
||||
|
||||
def no_active_response():
|
||||
return jsonify(
|
||||
{
|
||||
"has_session": False,
|
||||
"error": "尚未打开 EPUB。请先上传中日双语 EPUB,或从已有会话中选择一个。",
|
||||
"review_root": str(review_root),
|
||||
}
|
||||
), 409
|
||||
|
||||
if initial_epub_path is not None:
|
||||
session_root = session_root_for(review_root, initial_epub_path)
|
||||
ensure_state(session_root, initial_epub_path, reset=reset)
|
||||
set_active_session(session_root, initial_epub_path)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return send_from_directory(app.static_folder, "index.html")
|
||||
|
||||
@app.route("/api/session")
|
||||
def api_session():
|
||||
session_root, epub_path = active_session()
|
||||
if session_root is None or epub_path is None:
|
||||
return jsonify(
|
||||
{
|
||||
"has_session": False,
|
||||
"review_root": str(review_root),
|
||||
"sessions": list_review_sessions(review_root),
|
||||
}
|
||||
)
|
||||
rows = merge_rows(session_root)
|
||||
touched = [row for row in rows if row["edited"] or row["marked"] or row["comment"] or row["learn_note"]]
|
||||
touched = [row for row in rows if rowTouched_py(row)]
|
||||
manifest = read_json(session_manifest_path(session_root), {})
|
||||
return jsonify(
|
||||
{
|
||||
"has_session": True,
|
||||
"id": session_public_id(session_root),
|
||||
"source_name": manifest.get("source_name") or epub_path.name,
|
||||
"source_epub": str(epub_path),
|
||||
"session_root": str(session_root),
|
||||
"row_count": len(rows),
|
||||
"touched_count": len(touched),
|
||||
"marked_count": sum(1 for row in rows if row.get("marked")),
|
||||
"feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"),
|
||||
"feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/sessions")
|
||||
def api_sessions():
|
||||
session_root, _epub_path = active_session()
|
||||
return jsonify(
|
||||
{
|
||||
"review_root": str(review_root),
|
||||
"active_session_id": session_public_id(session_root) if session_root else "",
|
||||
"sessions": list_review_sessions(review_root),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/upload", methods=["POST"])
|
||||
def api_upload():
|
||||
file_storage = request.files.get("epub")
|
||||
if file_storage is None or not file_storage.filename:
|
||||
return jsonify({"error": "请选择一个 .epub 文件"}), 400
|
||||
reset_upload = str(request.form.get("reset", "")).lower() in {"1", "true", "yes", "on"}
|
||||
try:
|
||||
epub_path = copy_upload_to_review_root(file_storage, review_root)
|
||||
session_root = session_root_for(review_root, epub_path)
|
||||
with app.config["LOCK"]:
|
||||
if reset_upload and session_root.exists():
|
||||
shutil.rmtree(session_root)
|
||||
ensure_state(session_root, epub_path, reset=reset_upload)
|
||||
write_session_manifest(session_root, epub_path, source_name=file_storage.filename)
|
||||
set_active_session(session_root, epub_path)
|
||||
summary = summarize_session(session_root)
|
||||
summary["has_session"] = True
|
||||
return jsonify(summary)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
except zipfile.BadZipFile:
|
||||
return jsonify({"error": "文件不是有效的 EPUB/ZIP 包"}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({"error": f"EPUB 解析失败:{exc}"}), 500
|
||||
|
||||
@app.route("/api/session/open", methods=["POST"])
|
||||
def api_open_session():
|
||||
data = request.get_json(silent=True) or {}
|
||||
session_id = str(data.get("session_id", ""))
|
||||
if not session_id:
|
||||
return jsonify({"error": "缺少 session_id"}), 400
|
||||
try:
|
||||
session_root = session_root_from_id(review_root, session_id)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if not (session_root / "review_state" / "state.json").exists():
|
||||
return jsonify({"error": "会话不存在或不完整"}), 404
|
||||
with app.config["LOCK"]:
|
||||
set_active_session(session_root)
|
||||
summary = summarize_session(session_root)
|
||||
summary["has_session"] = True
|
||||
return jsonify(summary)
|
||||
|
||||
@app.route("/api/rows")
|
||||
def api_rows():
|
||||
session_root, _epub_path = active_session()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
return jsonify({"rows": merge_rows(session_root)})
|
||||
|
||||
@app.route("/api/structure")
|
||||
def api_structure():
|
||||
session_root, _epub_path = active_session()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
return jsonify(build_structure(session_root))
|
||||
|
||||
@app.route("/api/row/<row_id>", methods=["POST"])
|
||||
def api_save_row(row_id: str):
|
||||
session_root, _epub_path = active_session()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
data = request.get_json(silent=True) or {}
|
||||
rows = read_json(session_root / "review_state" / "rows.json", [])
|
||||
base = next((row for row in rows if row["id"] == row_id), None)
|
||||
@@ -872,6 +1126,9 @@ def create_app(session_root: Path, epub_path: Path) -> Flask:
|
||||
|
||||
@app.route("/api/apply", methods=["POST"])
|
||||
def api_apply():
|
||||
session_root, _epub_path = active_session()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
with app.config["LOCK"]:
|
||||
modified = apply_edits_to_xhtml(session_root)
|
||||
return jsonify(
|
||||
@@ -884,21 +1141,51 @@ def create_app(session_root: Path, epub_path: Path) -> Flask:
|
||||
|
||||
@app.route("/api/export", methods=["POST"])
|
||||
def api_export():
|
||||
session_root, _epub_path = active_session()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
with app.config["LOCK"]:
|
||||
out_path = export_epub(session_root)
|
||||
return jsonify({"status": "ok", "output": str(out_path)})
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"output": str(out_path),
|
||||
"download_url": f"/downloads/export/{urllib.parse.quote(out_path.name)}",
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/feedback")
|
||||
def api_feedback():
|
||||
session_root, _epub_path = active_session()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
path = write_feedback_summary(session_root)
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"feedback_md": str(path),
|
||||
"feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"),
|
||||
"feedback_md_url": "/downloads/feedback/feedback_for_codex.md",
|
||||
"feedback_jsonl_url": "/downloads/feedback/translation_feedback.jsonl",
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/downloads/export/<path:filename>")
|
||||
def download_export(filename: str):
|
||||
session_root, _epub_path = active_session()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
return send_from_directory(session_root / "exports", filename, as_attachment=True)
|
||||
|
||||
@app.route("/downloads/feedback/<path:filename>")
|
||||
def download_feedback(filename: str):
|
||||
session_root, _epub_path = active_session()
|
||||
if session_root is None:
|
||||
return no_active_response()
|
||||
if filename not in {"feedback_for_codex.md", "translation_feedback.jsonl"}:
|
||||
return jsonify({"error": "unknown feedback file"}), 404
|
||||
return send_from_directory(session_root / "feedback", filename, as_attachment=True)
|
||||
|
||||
@app.route("/api/shutdown", methods=["POST"])
|
||||
def api_shutdown():
|
||||
def stop() -> None:
|
||||
@@ -926,25 +1213,43 @@ def wait_until_ready(url: str, proc: subprocess.Popen, timeout: int = 15) -> boo
|
||||
|
||||
|
||||
def run_daemon(args: argparse.Namespace) -> int:
|
||||
port = find_free_port(args.port)
|
||||
bind_host = display_host(args.host)
|
||||
port = find_free_port(args.port, bind_host)
|
||||
review_root = Path(args.review_root).resolve()
|
||||
epub_path = Path(args.epub).resolve()
|
||||
session_root = session_root_for(review_root, epub_path)
|
||||
if args.reset and session_root.exists():
|
||||
shutil.rmtree(session_root)
|
||||
session_root.mkdir(parents=True, exist_ok=True)
|
||||
log_path = session_root / "server.log"
|
||||
review_root.mkdir(parents=True, exist_ok=True)
|
||||
if args.epub:
|
||||
epub_path = Path(args.epub).resolve()
|
||||
if not epub_path.exists():
|
||||
print(f"EPUB not found: {epub_path}", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
validate_epub_file(epub_path)
|
||||
except ValueError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
session_root = session_root_for(review_root, epub_path)
|
||||
if args.reset and session_root.exists():
|
||||
shutil.rmtree(session_root)
|
||||
session_root.mkdir(parents=True, exist_ok=True)
|
||||
log_path = session_root / "server.log"
|
||||
else:
|
||||
epub_path = None
|
||||
log_path = review_root / "server.log"
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(Path(__file__).resolve()),
|
||||
str(epub_path),
|
||||
"--review-root",
|
||||
str(review_root),
|
||||
"--host",
|
||||
args.host,
|
||||
"--port",
|
||||
str(port),
|
||||
]
|
||||
if args.no_browser:
|
||||
cmd.append("--no-browser")
|
||||
if epub_path is not None:
|
||||
cmd.insert(2, str(epub_path))
|
||||
cmd.append("--no-browser")
|
||||
if args.reset:
|
||||
cmd.append("--reset")
|
||||
creationflags = 0
|
||||
popen_kwargs: dict[str, Any] = {}
|
||||
if os.name == "nt":
|
||||
@@ -960,7 +1265,7 @@ def run_daemon(args: argparse.Namespace) -> int:
|
||||
creationflags=creationflags,
|
||||
**popen_kwargs,
|
||||
)
|
||||
url = f"http://localhost:{port}"
|
||||
url = f"http://{display_host(args.host)}:{port}"
|
||||
if not wait_until_ready(url, proc):
|
||||
print(f"review editor failed to start; see {log_path}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -970,15 +1275,19 @@ def run_daemon(args: argparse.Namespace) -> int:
|
||||
except Exception:
|
||||
pass
|
||||
print(url)
|
||||
print(f"session: {session_root}")
|
||||
if epub_path is not None:
|
||||
print(f"session: {session_root}")
|
||||
else:
|
||||
print(f"review root: {review_root}")
|
||||
print(f"log: {log_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Open a bilingual EPUB in a browser review/editor.")
|
||||
parser.add_argument("epub", help="Path to the bilingual EPUB.")
|
||||
parser.add_argument("epub", nargs="?", help="Optional path to a bilingual EPUB. Omit it to upload EPUBs in the browser.")
|
||||
parser.add_argument("--review-root", default=DEFAULT_REVIEW_ROOT, help="Directory for review sessions.")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Use 0.0.0.0 for trusted LAN/server access.")
|
||||
parser.add_argument("--port", type=int, default=5177, help="Port, auto-advances if busy.")
|
||||
parser.add_argument("--daemon", action="store_true", help="Start in background and print URL.")
|
||||
parser.add_argument("--no-browser", action="store_true", help="Do not auto-open the browser.")
|
||||
@@ -988,20 +1297,24 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
epub_path = Path(args.epub).resolve()
|
||||
if not epub_path.exists():
|
||||
print(f"EPUB not found: {epub_path}", file=sys.stderr)
|
||||
return 1
|
||||
epub_path = Path(args.epub).resolve() if args.epub else None
|
||||
if epub_path is not None:
|
||||
if not epub_path.exists():
|
||||
print(f"EPUB not found: {epub_path}", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
validate_epub_file(epub_path)
|
||||
except ValueError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
if args.daemon:
|
||||
return run_daemon(args)
|
||||
|
||||
review_root = Path(args.review_root).resolve()
|
||||
session_root = session_root_for(review_root, epub_path)
|
||||
ensure_state(session_root, epub_path, reset=args.reset)
|
||||
|
||||
app = create_app(session_root, epub_path)
|
||||
port = find_free_port(args.port)
|
||||
url = f"http://localhost:{port}"
|
||||
app = create_app(review_root, epub_path, reset=args.reset)
|
||||
bind_host = display_host(args.host)
|
||||
port = find_free_port(args.port, bind_host)
|
||||
url = f"http://{display_host(args.host)}:{port}"
|
||||
if not args.no_browser:
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
@@ -1016,9 +1329,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
except Exception:
|
||||
pass
|
||||
print(f"EPUB review editor: {url}")
|
||||
print(f"Session: {session_root}")
|
||||
print(f"Rows: {len(read_json(session_root / 'review_state' / 'rows.json', []))}")
|
||||
app.run(host="127.0.0.1", port=port, debug=False, threaded=True)
|
||||
print(f"Review root: {review_root}")
|
||||
session_root = app.config.get("SESSION_ROOT")
|
||||
if isinstance(session_root, Path):
|
||||
print(f"Session: {session_root}")
|
||||
print(f"Rows: {len(read_json(session_root / 'review_state' / 'rows.json', []))}")
|
||||
else:
|
||||
print("No EPUB loaded yet. Upload one in the browser.")
|
||||
app.run(host=args.host, port=port, debug=False, threaded=True)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -9,13 +9,17 @@ let currentMode = "reading";
|
||||
let showTouchedOnly = false;
|
||||
let dirty = false;
|
||||
let quickDirty = false;
|
||||
let hasSession = false;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const dom = {
|
||||
startScreen: $("startScreen"),
|
||||
reviewLayout: $("reviewLayout"),
|
||||
sessionMeta: $("sessionMeta"),
|
||||
readingModeBtn: $("readingModeBtn"),
|
||||
polishModeBtn: $("polishModeBtn"),
|
||||
openLibraryBtn: $("openLibraryBtn"),
|
||||
showTouchedBtn: $("showTouchedBtn"),
|
||||
feedbackBtn: $("feedbackBtn"),
|
||||
applyBtn: $("applyBtn"),
|
||||
@@ -64,6 +68,13 @@ const dom = {
|
||||
quickMarkSelectionBtn: $("quickMarkSelectionBtn"),
|
||||
quickSaveBtn: $("quickSaveBtn"),
|
||||
quickOpenFullBtn: $("quickOpenFullBtn"),
|
||||
uploadForm: $("uploadForm"),
|
||||
epubFileInput: $("epubFileInput"),
|
||||
selectedFileName: $("selectedFileName"),
|
||||
uploadBtn: $("uploadBtn"),
|
||||
refreshSessionsBtn: $("refreshSessionsBtn"),
|
||||
sessionList: $("sessionList"),
|
||||
sessionListMeta: $("sessionListMeta"),
|
||||
toast: $("toast"),
|
||||
};
|
||||
|
||||
@@ -102,10 +113,115 @@ async function api(path, options = {}) {
|
||||
return data;
|
||||
}
|
||||
|
||||
async function apiForm(path, formData) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function rowTouched(row) {
|
||||
return Boolean(row.edited || row.marked || row.comment || row.learn_note || row.issue_type || row.severity || row.tags);
|
||||
}
|
||||
|
||||
function resetReviewState() {
|
||||
rows = [];
|
||||
filteredRows = [];
|
||||
structure = { chapters: [] };
|
||||
currentIndex = -1;
|
||||
currentRow = null;
|
||||
currentChapter = null;
|
||||
currentPart = null;
|
||||
showTouchedOnly = false;
|
||||
dirty = false;
|
||||
quickDirty = false;
|
||||
dom.showTouchedBtn.classList.remove("primary");
|
||||
dom.searchInput.value = "";
|
||||
if (dom.quickEditor) {
|
||||
dom.quickEditor.hidden = true;
|
||||
}
|
||||
document.body.classList.remove("quickOpen");
|
||||
}
|
||||
|
||||
function setShellMode(active) {
|
||||
hasSession = active;
|
||||
dom.startScreen.hidden = active;
|
||||
dom.reviewLayout.hidden = !active;
|
||||
dom.readingModeBtn.disabled = !active;
|
||||
dom.polishModeBtn.disabled = !active;
|
||||
dom.openLibraryBtn.disabled = false;
|
||||
dom.showTouchedBtn.disabled = !active;
|
||||
dom.feedbackBtn.disabled = !active;
|
||||
dom.applyBtn.disabled = !active;
|
||||
dom.exportBtn.disabled = !active;
|
||||
document.body.classList.toggle("startOpen", !active);
|
||||
if (!active) {
|
||||
dom.sessionMeta.textContent = "未打开 EPUB";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function renderSessions(sessions = [], activeId = "") {
|
||||
dom.sessionList.innerHTML = "";
|
||||
dom.sessionListMeta.textContent = sessions.length ? `${sessions.length} 个会话` : "暂无会话";
|
||||
if (!sessions.length) {
|
||||
dom.sessionList.innerHTML = '<div class="emptySession">还没有审校会话。上传 EPUB 后会自动创建。</div>';
|
||||
return;
|
||||
}
|
||||
const frag = document.createDocumentFragment();
|
||||
sessions.forEach((session) => {
|
||||
const card = document.createElement("article");
|
||||
card.className = "sessionItem";
|
||||
if (session.id === activeId) card.classList.add("active");
|
||||
card.innerHTML = `
|
||||
<div class="sessionInfo">
|
||||
<h3>${escapeHtml(session.source_name || session.id)}</h3>
|
||||
<p>${escapeHtml(session.session_root || "")}</p>
|
||||
<div class="sessionFacts">
|
||||
<span>${session.row_count || 0} 段</span>
|
||||
<span>记录 ${session.touched_count || 0}</span>
|
||||
<span>标记 ${session.marked_count || 0}</span>
|
||||
${session.updated_at ? `<span>${escapeHtml(formatDateTime(session.updated_at))}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" data-session-id="${escapeHtml(session.id)}">${session.id === activeId ? "继续审校" : "打开"}</button>
|
||||
`;
|
||||
card.querySelector("button").addEventListener("click", () => openSession(session.id));
|
||||
frag.appendChild(card);
|
||||
});
|
||||
dom.sessionList.appendChild(frag);
|
||||
}
|
||||
|
||||
async function refreshSessions() {
|
||||
const data = await api("/api/sessions");
|
||||
renderSessions(data.sessions || [], data.active_session_id || "");
|
||||
}
|
||||
|
||||
async function showStartScreen(sessionPayload = null) {
|
||||
resetReviewState();
|
||||
setShellMode(false);
|
||||
if (sessionPayload && Array.isArray(sessionPayload.sessions)) {
|
||||
renderSessions(sessionPayload.sessions, "");
|
||||
} else {
|
||||
try {
|
||||
await refreshSessions();
|
||||
} catch (error) {
|
||||
dom.sessionList.innerHTML = `<div class="emptySession">读取会话失败:${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rowCurrentText(row) {
|
||||
return stripTags(row.current_html || row.cn_html || row.cn_text || "");
|
||||
}
|
||||
@@ -181,9 +297,16 @@ function scopeVisibleRows() {
|
||||
|
||||
async function loadSession() {
|
||||
const session = await api("/api/session");
|
||||
if (!session.has_session) {
|
||||
await showStartScreen(session);
|
||||
return session;
|
||||
}
|
||||
setShellMode(true);
|
||||
const chapterCount = structure.chapter_count || (structure.chapters || []).length;
|
||||
const partCount = structure.part_count || allParts().length;
|
||||
dom.sessionMeta.textContent = `${session.source_epub} · ${session.row_count} 段 · ${chapterCount} 章 / ${partCount} part · 已记录 ${session.touched_count} 段`;
|
||||
const sourceName = session.source_name || session.source_epub;
|
||||
dom.sessionMeta.textContent = `${sourceName} · ${session.row_count} 段 · ${chapterCount} 章 / ${partCount} part · 已记录 ${session.touched_count} 段`;
|
||||
return session;
|
||||
}
|
||||
|
||||
async function loadStructure() {
|
||||
@@ -220,6 +343,55 @@ async function loadRows(keepSelection = false) {
|
||||
renderAll();
|
||||
}
|
||||
|
||||
async function loadActiveSession(options = {}) {
|
||||
resetReviewState();
|
||||
currentMode = "reading";
|
||||
setShellMode(true);
|
||||
await loadStructure();
|
||||
await loadRows(Boolean(options.keepSelection));
|
||||
await loadSession();
|
||||
await refreshSessions().catch(() => {});
|
||||
if (options.message) toast(options.message, 4200);
|
||||
}
|
||||
|
||||
async function uploadEpub(event) {
|
||||
event.preventDefault();
|
||||
const file = dom.epubFileInput.files && dom.epubFileInput.files[0];
|
||||
if (!file) {
|
||||
toast("请先选择一个 EPUB 文件。");
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("epub", file);
|
||||
dom.uploadBtn.disabled = true;
|
||||
dom.uploadBtn.textContent = "正在解析……";
|
||||
try {
|
||||
await apiForm("/api/upload", formData);
|
||||
dom.epubFileInput.value = "";
|
||||
dom.selectedFileName.textContent = "尚未选择文件";
|
||||
await loadActiveSession({ message: "EPUB 已载入,可以开始审校。" });
|
||||
} catch (error) {
|
||||
toast(`上传失败:${error.message}`, 9000);
|
||||
} finally {
|
||||
dom.uploadBtn.disabled = false;
|
||||
dom.uploadBtn.textContent = "上传并开始审校";
|
||||
}
|
||||
}
|
||||
|
||||
async function openSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
if (hasSession && !(await maybeSaveBeforeSwitch())) return;
|
||||
try {
|
||||
await api("/api/session/open", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
});
|
||||
await loadActiveSession({ message: "已打开审校会话。" });
|
||||
} catch (error) {
|
||||
toast(`打开会话失败:${error.message}`, 9000);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStructureCountsFromRows() {
|
||||
for (const chapter of structure.chapters || []) {
|
||||
chapter.row_count = 0;
|
||||
@@ -267,6 +439,7 @@ function applyFilter(shouldRender = true) {
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
if (!hasSession) return;
|
||||
updateStructureCountsFromRows();
|
||||
renderToc();
|
||||
renderList();
|
||||
@@ -544,6 +717,7 @@ async function saveRow(row, data, silent = false) {
|
||||
}
|
||||
|
||||
async function saveCurrent(silent = false) {
|
||||
if (!hasSession) return true;
|
||||
if (!currentRow) return true;
|
||||
return saveRow(currentRow, collectCurrent(), silent);
|
||||
}
|
||||
@@ -759,21 +933,25 @@ async function goPart(delta) {
|
||||
}
|
||||
|
||||
async function generateFeedback() {
|
||||
if (!hasSession) return;
|
||||
if (!(await maybeSaveBeforeSwitch())) return;
|
||||
const data = await api("/api/feedback");
|
||||
toast(`已生成反馈文件:\n${data.feedback_md}\n${data.feedback_jsonl}`, 9000);
|
||||
const downloadText = data.feedback_md_url ? `\n可在当前浏览器下载:${data.feedback_md_url}` : "";
|
||||
toast(`已生成反馈文件:\n${data.feedback_md}\n${data.feedback_jsonl}${downloadText}`, 9000);
|
||||
}
|
||||
|
||||
async function applyBack() {
|
||||
if (!hasSession) return;
|
||||
if (!(await maybeSaveBeforeSwitch())) return;
|
||||
const data = await api("/api/apply", { method: "POST", body: "{}" });
|
||||
toast(`已写回 EPUB 解包内容。\n修改文件数:${data.modified_files.length}\n反馈:${data.feedback_md}`, 9000);
|
||||
}
|
||||
|
||||
async function exportEpub() {
|
||||
if (!hasSession) return;
|
||||
if (!(await maybeSaveBeforeSwitch())) return;
|
||||
const data = await api("/api/export", { method: "POST", body: "{}" });
|
||||
toast(`已导出审校 EPUB:\n${data.output}`, 10000);
|
||||
toast(`已导出审校 EPUB:\n${data.output}${data.download_url ? `\n可在当前浏览器下载:${data.download_url}` : ""}`, 10000);
|
||||
}
|
||||
|
||||
function initEvents() {
|
||||
@@ -814,6 +992,10 @@ function initEvents() {
|
||||
});
|
||||
dom.readingModeBtn.addEventListener("click", () => setMode("reading"));
|
||||
dom.polishModeBtn.addEventListener("click", () => setMode("polish"));
|
||||
dom.openLibraryBtn.addEventListener("click", async () => {
|
||||
if (hasSession && !(await maybeSaveBeforeSwitch())) return;
|
||||
await showStartScreen();
|
||||
});
|
||||
dom.prevPartBtn.addEventListener("click", () => goPart(-1));
|
||||
dom.nextPartBtn.addEventListener("click", () => goPart(1));
|
||||
dom.resetTextBtn.addEventListener("click", resetCurrentText);
|
||||
@@ -825,6 +1007,14 @@ function initEvents() {
|
||||
dom.feedbackBtn.addEventListener("click", generateFeedback);
|
||||
dom.applyBtn.addEventListener("click", applyBack);
|
||||
dom.exportBtn.addEventListener("click", exportEpub);
|
||||
dom.uploadForm.addEventListener("submit", uploadEpub);
|
||||
dom.epubFileInput.addEventListener("change", () => {
|
||||
const file = dom.epubFileInput.files && dom.epubFileInput.files[0];
|
||||
dom.selectedFileName.textContent = file ? file.name : "尚未选择文件";
|
||||
});
|
||||
dom.refreshSessionsBtn.addEventListener("click", () => {
|
||||
refreshSessions().catch((error) => toast(`刷新失败:${error.message}`, 7000));
|
||||
});
|
||||
dom.closeQuickEditorBtn.addEventListener("click", () => closeQuickEditor(false));
|
||||
dom.quickSaveBtn.addEventListener("click", () => saveCurrent(false));
|
||||
dom.quickMarkSelectionBtn.addEventListener("click", markQuickSelection);
|
||||
@@ -875,12 +1065,14 @@ function initEvents() {
|
||||
async function main() {
|
||||
initEvents();
|
||||
try {
|
||||
await loadStructure();
|
||||
await loadRows();
|
||||
await loadSession();
|
||||
toast("阅读模式已就绪。可连续阅读,点击段落即可快速编辑或标记。", 4200);
|
||||
const session = await loadSession();
|
||||
if (!session || !session.has_session) {
|
||||
return;
|
||||
}
|
||||
await loadActiveSession({ message: "阅读模式已就绪。可连续阅读,点击段落即可快速编辑或标记。" });
|
||||
} catch (error) {
|
||||
toast(`启动失败:${error.message}`, 10000);
|
||||
await showStartScreen();
|
||||
toast(`启动时未载入 EPUB:${error.message}`, 7000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<button id="polishModeBtn" class="modeButton" type="button">精修模式</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button id="openLibraryBtn" type="button">打开 EPUB</button>
|
||||
<button id="showTouchedBtn" type="button">仅看已记录</button>
|
||||
<button id="feedbackBtn" type="button">生成反馈</button>
|
||||
<button id="applyBtn" type="button">写回 EPUB 内容</button>
|
||||
@@ -24,7 +25,36 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<main class="startScreen" id="startScreen" hidden>
|
||||
<section class="startPanel">
|
||||
<div class="startHeader">
|
||||
<div>
|
||||
<h2>打开中日双语 EPUB</h2>
|
||||
<p>上传一个“日文灰字 + 中文译文紧随其后”的双语 EPUB,审校记录会保存在本机或服务器的审校目录中。</p>
|
||||
</div>
|
||||
<button id="refreshSessionsBtn" type="button">刷新会话</button>
|
||||
</div>
|
||||
|
||||
<form class="uploadBox" id="uploadForm">
|
||||
<label class="fileDrop" for="epubFileInput">
|
||||
<span class="fileDropTitle">选择 EPUB 文件</span>
|
||||
<span class="fileDropName" id="selectedFileName">尚未选择文件</span>
|
||||
<input id="epubFileInput" type="file" accept=".epub,application/epub+zip">
|
||||
</label>
|
||||
<button id="uploadBtn" class="primary" type="submit">上传并开始审校</button>
|
||||
</form>
|
||||
|
||||
<section class="sessionBrowser">
|
||||
<div class="sectionTitle compactTitle">
|
||||
<h2>已有审校会话</h2>
|
||||
<span id="sessionListMeta"></span>
|
||||
</div>
|
||||
<div class="sessionList" id="sessionList"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="layout" id="reviewLayout" hidden>
|
||||
<aside class="sidebar">
|
||||
<div class="searchbox">
|
||||
<input id="searchInput" type="search" placeholder="搜索原文、译文、备注">
|
||||
|
||||
@@ -109,6 +109,11 @@ button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
button.primary,
|
||||
#saveBtn,
|
||||
#quickSaveBtn,
|
||||
@@ -125,6 +130,158 @@ button.primary:hover,
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.startScreen {
|
||||
min-height: calc(100vh - 78px);
|
||||
padding: 28px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.startPanel {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.startHeader,
|
||||
.uploadBox,
|
||||
.sessionBrowser {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.startHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.startHeader h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.startHeader p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.uploadBox {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.fileDrop {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border: 1px dashed #aeb8c4;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileDrop:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.fileDrop input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fileDrop:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(29, 111, 95, 0.12);
|
||||
}
|
||||
|
||||
.fileDropTitle {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fileDropName {
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compactTitle {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.compactTitle span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sessionList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sessionItem {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.sessionItem.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.sessionInfo {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sessionInfo h3 {
|
||||
margin: 0 0 2px;
|
||||
font-size: 15px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sessionInfo p {
|
||||
margin: 0 0 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sessionFacts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.emptySession {
|
||||
color: var(--muted);
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.layout {
|
||||
height: calc(100vh - 78px);
|
||||
display: grid;
|
||||
@@ -721,4 +878,14 @@ textarea {
|
||||
width: auto;
|
||||
max-height: 72vh;
|
||||
}
|
||||
|
||||
.startScreen {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.startHeader,
|
||||
.uploadBox,
|
||||
.sessionItem {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user