forked from admin/PlantAI-rag-new
feat(review): paper auto-reformat P1 (.tex rule engine + diff panel)
- Rule engine: font/margin/line-spacing/bib-style transformers, tighten-only + structural guard + idempotency - POST/GET /api/review/reformat with 200-envelope errors and job store - ReformatPanel: journal dropdown (featured + directory search), source/diff tabs; compile tab is a dev placeholder for now - journals.json format_rules_default kept as generic fallback
This commit is contained in:
@@ -48,5 +48,8 @@ review_service/review.db
|
||||
review_service/review.db-wal
|
||||
review_service/review.db-shm
|
||||
review_service/review.log
|
||||
review_service/data/reformat_jobs/
|
||||
# 期刊种子 journals.json 是源码:顶层 **/data/ 会误伤 review_service/data/,显式放行
|
||||
!review_service/data/
|
||||
review_service/tools/tectonic
|
||||
review_service/data/tectonic-cache/
|
||||
|
||||
@@ -47,6 +47,18 @@ class Settings(BaseSettings):
|
||||
recommend_query_chars: int = 3000 # 送 vstore 的检索 query 截断(防嵌入模型超长)
|
||||
recommend_scope_top_k: int = 10 # search-chunks top_k(vstore 上限 20)
|
||||
|
||||
# ---- 论文重排版(reformat,P1:.tex 源码级变换,不编译)----
|
||||
max_tex_bytes: int = 2 * 1024 * 1024 # 上传 .tex 上限 2MB(PDF 的 50MB 不适用)
|
||||
reformat_dir: str = "data/reformat_jobs" # 产物落盘(相对 review_service/)
|
||||
|
||||
# ---- LaTeX 编译 PDF(compile,P3:tectonic 单文件二进制)----
|
||||
tectonic_path: str = "tools/tectonic" # 相对 review_service/;也可绝对路径
|
||||
tectonic_cache_dir: str = "data/tectonic-cache" # 宏包缓存(部署时预热,运行期只读命中)
|
||||
compile_timeout_s: float = 60.0 # 单次编译墙钟上限,超时整进程组 kill
|
||||
max_compile_pdf_bytes: int = 50 * 1024 * 1024 # 产物 PDF 上限 50MB
|
||||
compile_queue_limit: int = 5 # 等待槽位上限(>5 → QUEUE_FULL 429)
|
||||
compile_retention_days: int = 7 # 编译产物保留天数(后台清扫)
|
||||
|
||||
@property
|
||||
def _service_root(self) -> "Path":
|
||||
# review_service/app/config.py → review_service/(相对本文件定位,不依赖 cwd)
|
||||
@@ -61,6 +73,21 @@ class Settings(BaseSettings):
|
||||
def journals_seed_abs(self) -> "Path":
|
||||
return self._service_root / self.journals_seed_path
|
||||
|
||||
@property
|
||||
def reformat_dir_abs(self) -> "Path":
|
||||
p = Path(self.reformat_dir)
|
||||
return p if p.is_absolute() else self._service_root / p
|
||||
|
||||
@property
|
||||
def tectonic_abs(self) -> "Path":
|
||||
p = Path(self.tectonic_path)
|
||||
return p if p.is_absolute() else self._service_root / p
|
||||
|
||||
@property
|
||||
def tectonic_cache_abs(self) -> "Path":
|
||||
p = Path(self.tectonic_cache_dir)
|
||||
return p if p.is_absolute() else self._service_root / p
|
||||
|
||||
@property
|
||||
def vstore_base(self) -> str:
|
||||
return self.vstore_base_url.rstrip("/")
|
||||
|
||||
@@ -15,7 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import get_settings
|
||||
from .db import close_db, init_db
|
||||
from .routers import conferences, format_check, journal_directory, journals, presubmit
|
||||
from .routers import conferences, format_check, journal_directory, journals, presubmit, reformat
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -68,6 +68,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(format_check.router)
|
||||
app.include_router(conferences.router)
|
||||
app.include_router(journal_directory.router)
|
||||
app.include_router(reformat.router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""routers/reformat.py —— 论文自动排版 P1(.tex 源码级重排版,不编译)。
|
||||
|
||||
端点:
|
||||
POST /api/review/reformat multipart 上传 .tex + target_journal →
|
||||
修改后源码 + 逐行 diff 报告(job 落盘)
|
||||
GET /api/review/reformat/{id} 取完整报告(重启后仍在)
|
||||
GET /api/review/reformat/{id}/file 下载修改后的 .tex
|
||||
|
||||
信封与其余 review 端点一致:业务错误 = HTTP 200 + {ok:false,error:{code,message,degraded:false}},
|
||||
前端经 review.js unwrap 归一为 ApiError。本次改动无上游依赖(零 LLM/零 vstore),
|
||||
degraded 恒 false。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import get_settings
|
||||
from ..services.reformat import ReformatError, reformat_tex, resolve_rules
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review", tags=["review-reformat"])
|
||||
|
||||
_JOB_ID_RE = re.compile(r"^[0-9a-f]{12}$")
|
||||
_ENGINE = "reformat-p1/1.0"
|
||||
|
||||
|
||||
class ReformatResponse(BaseModel):
|
||||
ok: bool
|
||||
job_id: str | None = None
|
||||
modified_tex: str | None = None
|
||||
changes: list[dict[str, Any]] | None = None
|
||||
warnings: list[dict[str, Any]] | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _error(code: str, message: str) -> ReformatResponse:
|
||||
return ReformatResponse(
|
||||
ok=False,
|
||||
error={"code": code, "message": message, "degraded": False},
|
||||
)
|
||||
|
||||
|
||||
def _jobs_dir() -> Path:
|
||||
d = get_settings().reformat_dir_abs
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
@router.post("/reformat", response_model=ReformatResponse)
|
||||
async def reformat(
|
||||
file: UploadFile = File(..., description="LaTeX 源码(.tex 单文件)"),
|
||||
target_journal: str = Form("", description="目标期刊 id/名称,或 default=通用规则"),
|
||||
) -> ReformatResponse:
|
||||
settings = get_settings()
|
||||
|
||||
# ---- 入参校验(拍板 5:只收 .tex,docx/pdf 走 format-check 审核不重排)----
|
||||
filename = file.filename or "paper.tex"
|
||||
if not filename.lower().endswith(".tex"):
|
||||
return _error("VALIDATION", "仅支持 .tex 文件(docx/pdf 请走格式审核)")
|
||||
target = (target_journal or "").strip()
|
||||
if not target:
|
||||
return _error("VALIDATION", "必须指定目标期刊(或选择「通用规则」)")
|
||||
|
||||
content = await file.read()
|
||||
if not content:
|
||||
return _error("VALIDATION", "上传文件为空")
|
||||
if len(content) > settings.max_tex_bytes:
|
||||
return _error("VALIDATION", "源码超过 2MB 上限")
|
||||
try:
|
||||
source = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return _error("VALIDATION", "源码必须为 UTF-8 编码")
|
||||
|
||||
# ---- 规则解析(journals.json / format_cache / journal_directory 只读回链)----
|
||||
resolved = await resolve_rules(target)
|
||||
|
||||
# ---- 变换(纯规则引擎;结构畸形 → PARSER_FAILED)----
|
||||
try:
|
||||
result = reformat_tex(source, resolved["rules"])
|
||||
except ReformatError as exc:
|
||||
logger.warning("reformat: 结构守卫拒收 %s: %s", filename, exc)
|
||||
return _error(exc.code, str(exc))
|
||||
|
||||
# ---- 落盘(.tex + .json 报告;重启后 GET 仍可用)----
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
base = _jobs_dir()
|
||||
(base / f"{job_id}.tex").write_text(result["modified_tex"], encoding="utf-8")
|
||||
|
||||
meta: dict[str, Any] = {
|
||||
"journal_id": resolved["journal_id"],
|
||||
"journal_name": resolved["journal_name"],
|
||||
"rules": resolved["rules"],
|
||||
"rules_source": resolved["rules_source"],
|
||||
"rules_note": resolved["rules_note"],
|
||||
"filename": filename,
|
||||
"original_sha256": hashlib.sha256(content).hexdigest(),
|
||||
"engine": _ENGINE,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
payload = {
|
||||
"ok": True,
|
||||
"job_id": job_id,
|
||||
"modified_tex": result["modified_tex"],
|
||||
"changes": result["changes"],
|
||||
"warnings": result["warnings"],
|
||||
"meta": meta,
|
||||
}
|
||||
(base / f"{job_id}.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
logger.info(
|
||||
"reformat: %s → job %s(%d 处修改, %d 条提示, 规则 %s)",
|
||||
filename, job_id,
|
||||
sum(1 for c in result["changes"] if c["action"] == "modified"),
|
||||
sum(1 for c in result["changes"] if c["action"] == "warning"),
|
||||
resolved["rules_source"],
|
||||
)
|
||||
return ReformatResponse(**payload)
|
||||
|
||||
|
||||
def _load_job(job_id: str) -> dict[str, Any] | None:
|
||||
if not _JOB_ID_RE.fullmatch(job_id or ""):
|
||||
return None
|
||||
path = _jobs_dir() / f"{job_id}.json"
|
||||
try:
|
||||
payload = json.loads(path.read_text("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return payload if isinstance(payload, dict) and payload.get("ok") else None
|
||||
|
||||
|
||||
_NOT_FOUND = {"ok": False, "error": {"code": "NOT_FOUND",
|
||||
"message": "任务不存在或已过期,请重新上传源码",
|
||||
"degraded": False}}
|
||||
|
||||
|
||||
@router.get("/reformat/{job_id}", response_model=ReformatResponse)
|
||||
async def get_reformat_job(job_id: str) -> ReformatResponse:
|
||||
payload = _load_job(job_id)
|
||||
if payload is None:
|
||||
return _error("NOT_FOUND", "任务不存在或已过期,请重新上传源码")
|
||||
return ReformatResponse(**payload)
|
||||
|
||||
|
||||
@router.get("/reformat/{job_id}/file", response_model=None)
|
||||
async def download_reformat_file(job_id: str) -> FileResponse | JSONResponse:
|
||||
"""下载修改后的 .tex;缺件/非法 id → 200 信封(前端单一错误口径)。"""
|
||||
valid = bool(_JOB_ID_RE.fullmatch(job_id or ""))
|
||||
tex = _jobs_dir() / f"{job_id}.tex" if valid else None
|
||||
if tex is None or not tex.is_file():
|
||||
return JSONResponse(status_code=200, content=_NOT_FOUND)
|
||||
return FileResponse(tex, media_type="application/x-tex", filename=f"{job_id}.tex")
|
||||
@@ -0,0 +1,553 @@
|
||||
r"""services/reformat.py —— P1 论文重排版(.tex 源码级规则变换,不编译)。
|
||||
|
||||
链路:resolve_rules(三级规则解析)→ reformat_tex(4 变换器 + 结构守卫)
|
||||
→ 路由层落盘 data/reformat_jobs/{job_id}.tex/.json。
|
||||
纯规则、零 LLM、零执行用户内容、不碰 docx/pdf(那两个只在 FormatPanel 审核)。
|
||||
|
||||
拍板(用户 4 点确认,2026-10-10):
|
||||
1. changes[] 每条带 rule_key ∈ {font_pt, margin, line_spacing, bib_style}、
|
||||
action ∈ {modified, inserted, warning}(P1 只产生 modified/warning,
|
||||
inserted 留给后续阶段);前端按 rule_key 着色、按 action 计数。
|
||||
2. 保守插入策略:命令/选项缺失一律【不改源码】→ changes 里 action=warning,
|
||||
reason 附"建议手动添加"的代码片段;warning 不计入"已修改"计数。
|
||||
(warnings[] 顶层数组只放结构级提示,如多处 \documentclass。)
|
||||
3. target_journal 必填;"default"= 通用规则(min_font_pt=11, min_margin_mm=20,
|
||||
min_line_spacing=1.5, refs_style=apa),前端下拉首项即通用。
|
||||
4. P1 仅 .tex;结构畸形宁拒不猜(PARSER_FAILED)。
|
||||
|
||||
铁律:
|
||||
① 只在违反规则时改(合规零触碰 → 重跑幂等,changes=[])
|
||||
② 只收紧不放松:字号/行距/边距只调大、绝不调小
|
||||
③ 每条修改必须能回答"为什么":reason 带原值/目标值/规则
|
||||
|
||||
规则解析链(resolve_rules):
|
||||
"default" → 内置通用规则
|
||||
journals.json id/name 命中 → data/format_cache/{id}.json ok → fetched
|
||||
→ 否则 format_rules 非 placeholder → official
|
||||
→ 否则通用规则 + 占位说明
|
||||
形如 "en:xxx"/"zh:xxx"(journal_directory)→ 只读查目录拿 name → 回链上一步
|
||||
其他字符串(期刊名) → 回链 journals.json → 未中 → 通用 + 未匹配说明
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# review_service/app/services/ → parents[2] = review_service/
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SEED_PATH = _ROOT / "data" / "journals" / "journals.json"
|
||||
_CACHE_DIR = _ROOT / "data" / "format_cache"
|
||||
|
||||
_EPS = 1e-9
|
||||
|
||||
# 通用规则(拍板 3:与 journals.json format_rules_default 的 9/15/1.15 不同,
|
||||
# 这是重排版自己的"通用规则"口径,勿混用)
|
||||
RULES_DEFAULT: dict[str, Any] = {
|
||||
"rules_source": "default",
|
||||
"max_pages": 10,
|
||||
"min_font_pt": 11,
|
||||
"min_margin_mm": 20.0,
|
||||
"min_line_spacing": 1.5,
|
||||
"min_figures": 1,
|
||||
"max_fonts": 6,
|
||||
"refs_style": "apa",
|
||||
"anonymous": None,
|
||||
}
|
||||
|
||||
# 参考文献样式 → 经典 .bst(均为发行版基础件)。
|
||||
# 修正(2026-10-10):author_year 旧映射 plain 是错的——plain.bst 是编号制 [1];
|
||||
# author-year 制应映射 apalike.bst(Author, 2020)。apa 同理。
|
||||
REFS_BST_MAP: dict[str, str] = {
|
||||
"apa": "apalike",
|
||||
"author_year": "apalike",
|
||||
"numbered": "unsrt",
|
||||
"vancouver": "unsrt",
|
||||
"gb7714": "gb7714",
|
||||
}
|
||||
# biblatex 工作流的 style= 建议名(与 .bst 是两套体系)
|
||||
BIBLATEX_STYLE_MAP: dict[str, str] = {
|
||||
"apa": "apa",
|
||||
"author_year": "authoryear",
|
||||
"numbered": "numeric",
|
||||
"vancouver": "numeric",
|
||||
"gb7714": "gb7714",
|
||||
}
|
||||
|
||||
_DOCCLASS_RE = re.compile(r"(\\documentclass)\s*(\[[^\]]*\])?\s*\{([^}]*)\}")
|
||||
_PKG_RE = re.compile(r"\\(?:usepackage|RequirePackage)\s*(\[[^\]]*\])?\s*\{([^}]*)\}")
|
||||
_SPACING_RE = re.compile(r"\\(?:linespread|setstretch)\s*\{\s*(\d+(?:\.\d+)?)\s*\}")
|
||||
_BIBSTYLE_RE = re.compile(r"\\bibliographystyle\s*\{\s*([^}]+?)\s*\}")
|
||||
_PT_RE = re.compile(r"(\d+(?:\.\d+)?)\s*pt\b")
|
||||
_BEGIN_DOC_RE = re.compile(r"\\begin\s*\{\s*document\s*\}")
|
||||
_BIBLATEX_RE = re.compile(r"\\(?:printbibliography|addbibresource)")
|
||||
|
||||
_MM_FACTORS = {"mm": 1.0, "cm": 10.0, "in": 25.4, "inch": 25.4, "pt": 25.4 / 72.0}
|
||||
_SIDE_KEYS = ("left", "right", "top", "bottom")
|
||||
_MARGIN_ATTRS = ("margin", "hmargin", "vmargin", "left", "right", "top", "bottom",
|
||||
"inner", "outer")
|
||||
|
||||
|
||||
class ReformatError(Exception):
|
||||
"""结构畸形无法安全处理(code: PARSER_FAILED)。"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
# ================================================================ 规则解析链
|
||||
|
||||
def _seed_entries() -> list[dict[str, Any]]:
|
||||
try:
|
||||
data = json.loads(_SEED_PATH.read_text("utf-8"))
|
||||
entries = data.get("journals")
|
||||
return [e for e in entries if isinstance(e, dict)] if isinstance(entries, list) else []
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _find_seed(text: str) -> dict[str, Any] | None:
|
||||
low = text.strip().casefold()
|
||||
if not low:
|
||||
return None
|
||||
for e in _seed_entries():
|
||||
if str(e.get("id", "")).casefold() == low or str(e.get("name", "")).casefold() == low:
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
def _cache_rules(journal_id: str) -> dict[str, Any] | None:
|
||||
"""data/format_cache/{id}.json(抓取+LLM 抽取产物);ok=false/缺失 → None。"""
|
||||
try:
|
||||
payload = json.loads((_CACHE_DIR / f"{journal_id}.json").read_text("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(payload, dict) or not payload.get("ok"):
|
||||
return None
|
||||
rules = payload.get("format_rules")
|
||||
return rules if isinstance(rules, dict) and rules.get("rules_source") else None
|
||||
|
||||
|
||||
def _merged(rules: dict[str, Any]) -> dict[str, Any]:
|
||||
"""专属规则叠加在通用规则上(缺失键/显式 null 保留——fetched 的 null 是诚实未知)。"""
|
||||
return {**RULES_DEFAULT, **rules}
|
||||
|
||||
|
||||
async def _directory_name(journal_id: str) -> str | None:
|
||||
"""journal_directory id → name(只读;表未连/不存在 → None,绝不抛错)。"""
|
||||
try:
|
||||
from ..db import db # 惰性导入:resolver 可在无 lifespan 的测试中工作
|
||||
row = await db.fetchone(
|
||||
"SELECT name FROM journal_directory WHERE id = ?", (journal_id,)
|
||||
)
|
||||
return str(row["name"]) if row and row.get("name") else None
|
||||
except Exception: # noqa: BLE001 —— 查不到/未连接都按未匹配处理
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_rules(target: str) -> dict[str, Any]:
|
||||
"""→ {journal_id, journal_name, rules, rules_source, rules_note}(永不失败)。"""
|
||||
target = (target or "").strip()
|
||||
|
||||
if target == "default":
|
||||
return {
|
||||
"journal_id": "default",
|
||||
"journal_name": "通用规则",
|
||||
"rules": dict(RULES_DEFAULT),
|
||||
"rules_source": "default",
|
||||
"rules_note": "通用规则(APA + 通用论文规范)",
|
||||
}
|
||||
|
||||
display_name = target
|
||||
entry = _find_seed(target)
|
||||
if entry is None and ":" in target:
|
||||
# journal_directory id(en:0028-0836 / zh:slug)→ 目录查 name → 再回链种子
|
||||
name = await _directory_name(target)
|
||||
if name:
|
||||
display_name = name
|
||||
entry = _find_seed(name)
|
||||
elif entry is None:
|
||||
display_name = target # 直接当期刊名回链
|
||||
|
||||
if entry is not None:
|
||||
jid = str(entry.get("id") or "")
|
||||
jname = str(entry.get("name") or display_name)
|
||||
cached = _cache_rules(jid)
|
||||
if cached:
|
||||
return {
|
||||
"journal_id": jid, "journal_name": jname, "rules": _merged(cached),
|
||||
"rules_source": "fetched",
|
||||
"rules_note": "该刊规则来自官网抓取 + LLM 抽取(data/format_cache,待人工核验)",
|
||||
}
|
||||
fr = entry.get("format_rules") if isinstance(entry.get("format_rules"), dict) else {}
|
||||
if fr and fr.get("rules_source") not in (None, "placeholder", "default"):
|
||||
return {
|
||||
"journal_id": jid, "journal_name": jname, "rules": _merged(fr),
|
||||
"rules_source": str(fr.get("rules_source")),
|
||||
"rules_note": "journals.json 期刊专属规则",
|
||||
}
|
||||
return {
|
||||
"journal_id": jid, "journal_name": jname, "rules": dict(RULES_DEFAULT),
|
||||
"rules_source": "default",
|
||||
"rules_note": f"「{jname}」在 journals.json 中仅有占位规则(placeholder),已用通用规则",
|
||||
}
|
||||
|
||||
return {
|
||||
"journal_id": None,
|
||||
"journal_name": display_name or target,
|
||||
"rules": dict(RULES_DEFAULT),
|
||||
"rules_source": "default",
|
||||
"rules_note": f"未匹配到「{display_name or target}」的专属格式规则,已用通用规则",
|
||||
}
|
||||
|
||||
|
||||
# ================================================================ 变换器工具
|
||||
|
||||
def _change(line_no: int, original: str, modified: str, reason: str,
|
||||
rule_key: str, action: str) -> dict[str, Any]:
|
||||
return {
|
||||
"line": line_no,
|
||||
"original": original.rstrip("\n"),
|
||||
"modified": modified.rstrip("\n"),
|
||||
"reason": reason,
|
||||
"rule_key": rule_key,
|
||||
"action": action,
|
||||
}
|
||||
|
||||
|
||||
def _warning_change(line_no: int, original: str, reason: str, rule_key: str) -> dict[str, Any]:
|
||||
return _change(line_no, original, "", reason, rule_key, "warning")
|
||||
|
||||
|
||||
def _parse_mm(raw: str) -> float | None:
|
||||
"""geometry 长度值 → mm;裸数字按 TeX 惯例 pt(同 format_check 先例);未知 → None。"""
|
||||
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([A-Za-z]*)\s*", raw or "")
|
||||
if not m:
|
||||
return None
|
||||
unit = (m.group(2) or "pt").lower()
|
||||
factor = _MM_FACTORS.get(unit)
|
||||
if factor is None:
|
||||
return None
|
||||
return float(m.group(1)) * factor
|
||||
|
||||
|
||||
def _fmt(v: float) -> str:
|
||||
return f"{v:g}"
|
||||
|
||||
|
||||
# ================================================================ ① 字号 font_pt
|
||||
|
||||
def _transform_font(lines: list[str], orig: list[str], rules: dict[str, Any],
|
||||
doc_idx: int) -> list[dict[str, Any]]:
|
||||
min_pt = rules.get("min_font_pt")
|
||||
if min_pt is None:
|
||||
return []
|
||||
min_pt = float(min_pt)
|
||||
line = lines[doc_idx]
|
||||
m = _DOCCLASS_RE.search(line)
|
||||
if not m: # 守卫已保证,理论不可达
|
||||
return []
|
||||
opts = m.group(2) # "[...]" 或 None
|
||||
cls = m.group(3).strip()
|
||||
opts_inner = opts[1:-1] if opts else ""
|
||||
pt_m = _PT_RE.search(opts_inner)
|
||||
|
||||
if pt_m is None:
|
||||
# 拍板 2:选项缺失不自动加 → warning + 建议片段
|
||||
if opts_inner:
|
||||
suggestion = f"\\documentclass[{opts_inner}, {_fmt(min_pt)}pt]{{{cls}}}"
|
||||
else:
|
||||
suggestion = f"\\documentclass[{_fmt(min_pt)}pt]{{{cls}}}"
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
f"未检测到字号选项,无法核验正文字号(目标下限 {_fmt(min_pt)}pt),"
|
||||
f"建议手动声明:{suggestion}",
|
||||
"font_pt",
|
||||
)]
|
||||
|
||||
cur = float(pt_m.group(1))
|
||||
if cur >= min_pt - _EPS:
|
||||
return []
|
||||
target = next((s for s in (10, 11, 12) if s >= min_pt - _EPS), min_pt)
|
||||
new_inner = opts_inner[:pt_m.start(1)] + _fmt(target) + opts_inner[pt_m.end(1):]
|
||||
new_line = line[:m.start(2) + 1] + new_inner + line[m.start(2) + 1 + len(opts_inner):]
|
||||
lines[doc_idx] = new_line
|
||||
return [_change(
|
||||
doc_idx + 1, orig[doc_idx], new_line,
|
||||
f"正文字号 {_fmt(cur)}pt 低于目标下限 {_fmt(min_pt)}pt,改为 {_fmt(target)}pt",
|
||||
"font_pt", "modified",
|
||||
)]
|
||||
|
||||
|
||||
# ================================================================ ② 页边距 margin
|
||||
|
||||
def _find_geometry(lines: list[str]) -> tuple[int, re.Match] | None:
|
||||
for i, line in enumerate(lines):
|
||||
for m in _PKG_RE.finditer(line):
|
||||
pkgs = [p.strip().lower() for p in m.group(2).split(",")]
|
||||
if "geometry" in pkgs:
|
||||
return i, m
|
||||
return None
|
||||
|
||||
|
||||
def _patch_attr(line: str, key: str, new_val: str) -> str:
|
||||
"""geometry 选项里 key=old → key=new(首个匹配)。
|
||||
|
||||
值止于 `,` 或 `]`——绝不能吞掉 `]{geometry}` 尾巴(否则下一轮就找不到
|
||||
geometry 宏包,破坏幂等)。key 带 \\b 防 hmargin/margin 互撞。
|
||||
"""
|
||||
return re.sub(rf"(\b{key}\s*=\s*)([^,\]]+)", lambda m: m.group(1) + new_val,
|
||||
line, count=1)
|
||||
|
||||
|
||||
def _transform_margin(lines: list[str], orig: list[str], rules: dict[str, Any],
|
||||
doc_idx: int) -> list[dict[str, Any]]:
|
||||
min_mm = rules.get("min_margin_mm")
|
||||
if min_mm is None:
|
||||
return []
|
||||
min_mm = float(min_mm)
|
||||
out: list[dict[str, Any]] = []
|
||||
found = _find_geometry(lines)
|
||||
|
||||
if found is None:
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
f"未检测到 geometry 宏包,无法核验页边距(目标下限 {_fmt(min_mm)}mm),"
|
||||
f"建议手动添加:\\usepackage[margin={_fmt(min_mm)}mm]{{geometry}}"
|
||||
f"(置于 \\documentclass 之后)",
|
||||
"margin",
|
||||
)]
|
||||
|
||||
gi, gm = found
|
||||
opts = gm.group(1)
|
||||
if opts is None:
|
||||
# \usepackage{geometry} 裸用 → 建议加选项(拍板 2 不自动改)
|
||||
return [_warning_change(
|
||||
gi + 1, orig[gi],
|
||||
f"geometry 未设置边距选项,无法核验页边距(目标下限 {_fmt(min_mm)}mm),"
|
||||
f"建议手动改为:\\usepackage[margin={_fmt(min_mm)}mm]{{geometry}}",
|
||||
"margin",
|
||||
)]
|
||||
opts_inner = opts[1:-1]
|
||||
attrs: dict[str, str] = {}
|
||||
for part in opts_inner.split(","):
|
||||
if "=" in part:
|
||||
k, _, v = part.partition("=")
|
||||
attrs[k.strip().lower()] = v.strip()
|
||||
|
||||
present = [k for k in _MARGIN_ATTRS if k in attrs]
|
||||
if not present:
|
||||
return [_warning_change(
|
||||
gi + 1, orig[gi],
|
||||
f"geometry 未设置边距相关选项(目标下限 {_fmt(min_mm)}mm),"
|
||||
f"建议在现有选项中添加:margin={_fmt(min_mm)}mm",
|
||||
"margin",
|
||||
)]
|
||||
|
||||
# 逐边解析提供者(side > h/vmargin > margin;inner/outer 单独按自身长度评)
|
||||
def provider_for(side: str) -> str | None:
|
||||
if side in attrs:
|
||||
return side
|
||||
if side in ("left", "right") and "hmargin" in attrs:
|
||||
return "hmargin"
|
||||
if side in ("top", "bottom") and "vmargin" in attrs:
|
||||
return "vmargin"
|
||||
if "margin" in attrs:
|
||||
return "margin"
|
||||
if side in ("left", "right") and "inner" in attrs:
|
||||
return "inner" # 见下:inner/outer 单独评估,此处不按边处理
|
||||
if side in ("left", "right") and "outer" in attrs:
|
||||
return "outer"
|
||||
return None
|
||||
|
||||
patch_keys: dict[str, str] = {} # attr → 新值(同键只抬一次)
|
||||
reasons: list[str] = []
|
||||
unresolved: list[str] = []
|
||||
|
||||
for side in _SIDE_KEYS:
|
||||
pkey = provider_for(side)
|
||||
if pkey in ("inner", "outer"):
|
||||
continue # 由下方 inner/outer 评估负责
|
||||
if pkey is None:
|
||||
unresolved.append(side)
|
||||
continue
|
||||
mm = _parse_mm(attrs.get(pkey, ""))
|
||||
if mm is None:
|
||||
unresolved.append(side)
|
||||
continue
|
||||
if mm < min_mm - _EPS and pkey not in patch_keys:
|
||||
patch_keys[pkey] = f"{_fmt(min_mm)}mm"
|
||||
reasons.append(f"页边距 {side}({_fmt(mm)}mm) 低于下限 {_fmt(min_mm)}mm → {_fmt(min_mm)}mm")
|
||||
|
||||
for key in ("inner", "outer"):
|
||||
if key in attrs:
|
||||
mm = _parse_mm(attrs[key])
|
||||
if mm is None:
|
||||
unresolved.append(key)
|
||||
elif mm < min_mm - _EPS and key not in patch_keys:
|
||||
patch_keys[key] = f"{_fmt(min_mm)}mm"
|
||||
reasons.append(f"页边距 {key}({_fmt(mm)}mm) 低于下限 {_fmt(min_mm)}mm → {_fmt(min_mm)}mm")
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
if patch_keys:
|
||||
new_line = lines[gi]
|
||||
for key, val in patch_keys.items():
|
||||
new_line = _patch_attr(new_line, key, val)
|
||||
lines[gi] = new_line
|
||||
out.append(_change(
|
||||
gi + 1, orig[gi], new_line, ";".join(reasons), "margin", "modified",
|
||||
))
|
||||
|
||||
if unresolved:
|
||||
uniq = "、".join(dict.fromkeys(unresolved))
|
||||
out.append(_warning_change(
|
||||
gi + 1, orig[gi],
|
||||
f"geometry 未设置/无法解析 {uniq} 边(沿用类默认,无法核验),"
|
||||
f"建议显式声明:{uniq}={_fmt(min_mm)}mm",
|
||||
"margin",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
# ================================================================ ③ 行距 line_spacing
|
||||
|
||||
def _transform_spacing(lines: list[str], orig: list[str], rules: dict[str, Any],
|
||||
doc_idx: int) -> list[dict[str, Any]]:
|
||||
min_ls = rules.get("min_line_spacing")
|
||||
if min_ls is None:
|
||||
return []
|
||||
min_ls = float(min_ls)
|
||||
|
||||
# 只看导言区(\begin{document} 之前);正文中的 \setstretch 是局部覆盖,不碰
|
||||
end = len(lines)
|
||||
for i, line in enumerate(lines):
|
||||
if _BEGIN_DOC_RE.search(line):
|
||||
end = i
|
||||
break
|
||||
records: list[tuple[int, re.Match]] = []
|
||||
for i in range(end):
|
||||
for m in _SPACING_RE.finditer(lines[i]):
|
||||
records.append((i, m))
|
||||
|
||||
if not records:
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
f"未在导言区检测到 \\linespread 或 \\setstretch,无法核验行距"
|
||||
f"(目标下限 {_fmt(min_ls)}),建议手动添加到导言区:"
|
||||
f"\\linespread{{{_fmt(min_ls)}}}",
|
||||
"line_spacing",
|
||||
)]
|
||||
|
||||
i, m = records[-1] # 最后一次出现者生效(LaTeX 后设覆盖前设)
|
||||
cur = float(m.group(1))
|
||||
if cur >= min_ls - _EPS:
|
||||
return []
|
||||
new_line = lines[i][:m.start(1)] + _fmt(min_ls) + lines[i][m.end(1):]
|
||||
lines[i] = new_line
|
||||
return [_change(
|
||||
i + 1, orig[i], new_line,
|
||||
f"行距 {_fmt(cur)} 低于目标下限 {_fmt(min_ls)},改为 {_fmt(min_ls)}",
|
||||
"line_spacing", "modified",
|
||||
)]
|
||||
|
||||
|
||||
# ================================================================ ④ 参考文献 bib_style
|
||||
|
||||
def _transform_bib(lines: list[str], orig: list[str], rules: dict[str, Any],
|
||||
doc_idx: int) -> list[dict[str, Any]]:
|
||||
style = str(rules.get("refs_style") or "").strip().lower()
|
||||
if not style:
|
||||
# 规则未提供已确认的 refs_style(如 Frontiers 两种制式并存待核实):
|
||||
# 绝不猜测改写用户样式——保持原样 + warning 提示(拍板 2026-10-10)
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
"该刊引用风格未确认,未改动 \\bibliographystyle;"
|
||||
"建议人工核实期刊投稿指南后再调整",
|
||||
"bib_style",
|
||||
)]
|
||||
|
||||
m = next((mm for line in lines if (mm := _BIBSTYLE_RE.search(line))), None)
|
||||
if m is not None:
|
||||
i = next(idx for idx, line in enumerate(lines) if _BIBSTYLE_RE.search(line))
|
||||
cur = m.group(1).strip()
|
||||
if style not in REFS_BST_MAP:
|
||||
return [_warning_change(
|
||||
i + 1, orig[i],
|
||||
f"目标参考文献样式 {style} 无对应标准 .bst,未替换"
|
||||
f"(当前 \\bibliographystyle{{{cur}}}),请按目标期刊模板手动选择样式",
|
||||
"bib_style",
|
||||
)]
|
||||
target = REFS_BST_MAP[style]
|
||||
if cur.casefold() == target.casefold():
|
||||
return []
|
||||
new_line = lines[i][:m.start(1)] + target + lines[i][m.end(1):]
|
||||
lines[i] = new_line
|
||||
hint = "(需 gbt7714 宏包)" if target == "gb7714" else ""
|
||||
return [_change(
|
||||
i + 1, orig[i], new_line,
|
||||
f"参考文献样式要求 {style}:\\bibliographystyle{{{cur}}} → {{{target}}}{hint}",
|
||||
"bib_style", "modified",
|
||||
)]
|
||||
|
||||
has_biblatex = any(_BIBLATEX_RE.search(line) for line in lines)
|
||||
if has_biblatex:
|
||||
suggest = BIBLATEX_STYLE_MAP.get(style, style)
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
f"检测到 biblatex 工作流(未找到 \\bibliographystyle),"
|
||||
f"请在 \\printbibliography 选项中手动配置:style={suggest}(对应 {style})",
|
||||
"bib_style",
|
||||
)]
|
||||
|
||||
if style in REFS_BST_MAP:
|
||||
target = REFS_BST_MAP[style]
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
f"未检测到 \\bibliographystyle,建议手动添加:\\bibliographystyle{{{target}}}"
|
||||
f"(对应 {style})",
|
||||
"bib_style",
|
||||
)]
|
||||
return [_warning_change(
|
||||
doc_idx + 1, orig[doc_idx],
|
||||
f"未检测到 \\bibliographystyle,目标样式 {style} 无标准 .bst 映射,"
|
||||
f"请按期刊模板手动配置",
|
||||
"bib_style",
|
||||
)]
|
||||
|
||||
|
||||
# ================================================================ 入口
|
||||
|
||||
def reformat_tex(source: str, rules: dict[str, Any]) -> dict[str, Any]:
|
||||
"""源码 + 规则 → {modified_tex, changes[], warnings[]}。
|
||||
|
||||
结构守卫:0 处 \\documentclass → ReformatError(PARSER_FAILED);
|
||||
>1 处 → 只处理第一处 + warnings 补 MULTI_DOCUMENTCLASS。
|
||||
"""
|
||||
lines = source.splitlines(keepends=True)
|
||||
doc_idxs = [i for i, line in enumerate(lines) if _DOCCLASS_RE.search(line)]
|
||||
if not doc_idxs:
|
||||
raise ReformatError(
|
||||
"PARSER_FAILED",
|
||||
"未找到 \\documentclass{...}:无法确定主文档"
|
||||
"(P1 不支持由 \\input 拆分的多文件工程,请上传完整单文件源码)",
|
||||
)
|
||||
warnings: list[dict[str, str]] = []
|
||||
if len(doc_idxs) > 1:
|
||||
warnings.append({
|
||||
"code": "MULTI_DOCUMENTCLASS",
|
||||
"message": f"检测到 {len(doc_idxs)} 处 \\documentclass,"
|
||||
f"仅处理第一处(第 {doc_idxs[0] + 1} 行)",
|
||||
})
|
||||
doc_idx = doc_idxs[0]
|
||||
orig = list(lines) # 纯净快照:所有 change.original 取自这里
|
||||
|
||||
changes: list[dict[str, Any]] = []
|
||||
changes += _transform_font(lines, orig, rules, doc_idx)
|
||||
changes += _transform_margin(lines, orig, rules, doc_idx)
|
||||
changes += _transform_spacing(lines, orig, rules, doc_idx)
|
||||
changes += _transform_bib(lines, orig, rules, doc_idx)
|
||||
changes.sort(key=lambda c: c["line"])
|
||||
|
||||
return {"modified_tex": "".join(lines), "changes": changes, "warnings": warnings}
|
||||
@@ -0,0 +1,407 @@
|
||||
"""tests/test_reformat.py —— P1 论文重排版单元测试(离线,零 LLM/零网络/零磁盘污染)。
|
||||
|
||||
覆盖:规则解析链(default/种子占位/目录回链/抓取缓存/未匹配)+ 4 变换器
|
||||
(改/不改/保守 warning)+ 结构守卫 + 幂等 + API 信封(校验/落盘/GET/404)。
|
||||
运行: cd review_service && <venv>/bin/python -m pytest -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.services import reformat as rf
|
||||
from app.services.reformat import RULES_DEFAULT, ReformatError, reformat_tex, resolve_rules
|
||||
|
||||
# 金样例:4 处违规(字号/边距/行距/样式),全部命令齐备 → 0 warning
|
||||
GOLDEN = r"""\documentclass[10pt]{article}
|
||||
\usepackage[margin=1in,top=10mm]{geometry}
|
||||
\linespread{1.0}
|
||||
\bibliographystyle{plain}
|
||||
\begin{document}
|
||||
Hello.
|
||||
\end{document}
|
||||
"""
|
||||
|
||||
MINIMAL = "\\documentclass[11pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
|
||||
|
||||
def _run(source: str, **rule_overrides):
|
||||
rules = {**RULES_DEFAULT, **rule_overrides}
|
||||
return reformat_tex(source, rules)
|
||||
|
||||
|
||||
def _modified(changes):
|
||||
return [c for c in changes if c["action"] == "modified"]
|
||||
|
||||
|
||||
def _warnings(changes):
|
||||
return [c for c in changes if c["action"] == "warning"]
|
||||
|
||||
|
||||
def _by_key(changes, rule_key, action=None):
|
||||
out = [c for c in changes if c["rule_key"] == rule_key
|
||||
and (action is None or c["action"] == action)]
|
||||
return out
|
||||
|
||||
|
||||
# ================================================================ 规则解析链
|
||||
|
||||
def test_resolve_default():
|
||||
r = asyncio.run(resolve_rules("default"))
|
||||
assert r["rules_source"] == "default"
|
||||
assert r["rules"] == RULES_DEFAULT
|
||||
assert r["rules"]["min_font_pt"] == 11
|
||||
assert r["rules"]["min_margin_mm"] == 20
|
||||
assert r["rules"]["min_line_spacing"] == 1.5
|
||||
assert r["rules"]["refs_style"] == "apa"
|
||||
assert r["journal_id"] == "default"
|
||||
|
||||
|
||||
def test_resolve_seed_name_official():
|
||||
r = asyncio.run(resolve_rules("Genome Biology"))
|
||||
assert r["journal_id"] == "genome-biology"
|
||||
assert r["rules_source"] == "official"
|
||||
assert r["rules"]["min_line_spacing"] == 2.0
|
||||
assert r["rules"]["refs_style"] == "vancouver"
|
||||
|
||||
|
||||
def test_resolve_seed_id():
|
||||
r = asyncio.run(resolve_rules("genome-biology"))
|
||||
assert r["journal_id"] == "genome-biology"
|
||||
assert r["rules_source"] == "official"
|
||||
|
||||
|
||||
def test_resolve_unknown_name():
|
||||
r = asyncio.run(resolve_rules("Nonexistent Journal of Nowhere"))
|
||||
assert r["rules_source"] == "default"
|
||||
assert r["journal_id"] is None
|
||||
assert "未匹配" in r["rules_note"]
|
||||
|
||||
|
||||
def test_resolve_directory_id_backlink(monkeypatch):
|
||||
async def fake_dir_name(journal_id: str):
|
||||
return "Genome Biology" if journal_id.startswith("zh:") else None
|
||||
|
||||
monkeypatch.setattr(rf, "_directory_name", fake_dir_name)
|
||||
r = asyncio.run(resolve_rules("zh:some-slug"))
|
||||
assert r["journal_id"] == "genome-biology"
|
||||
|
||||
|
||||
def test_resolve_fetched_cache(monkeypatch):
|
||||
monkeypatch.setattr(rf, "_cache_rules", lambda jid: {
|
||||
"rules_source": "fetched", "min_font_pt": 10, "refs_style": "gb7714"})
|
||||
r = asyncio.run(resolve_rules("genome-biology"))
|
||||
assert r["rules_source"] == "fetched" # 抓取缓存优先于 official
|
||||
assert r["rules"]["min_font_pt"] == 10 # 专属覆盖通用
|
||||
assert r["rules"]["min_margin_mm"] == 20 # 缺失键保留通用
|
||||
assert r["rules"]["refs_style"] == "gb7714"
|
||||
|
||||
|
||||
def test_resolve_official_format_rules(monkeypatch):
|
||||
monkeypatch.setattr(rf, "_find_seed", lambda text: {
|
||||
"id": "x-journal", "name": "X Journal",
|
||||
"format_rules": {"rules_source": "official", "min_line_spacing": 1.3}})
|
||||
r = asyncio.run(resolve_rules("X Journal"))
|
||||
assert r["rules_source"] == "official"
|
||||
assert r["rules"]["min_line_spacing"] == 1.3
|
||||
|
||||
|
||||
# ================================================================ ① 字号 font_pt
|
||||
|
||||
def test_font_modify_below_min():
|
||||
src = "\\documentclass[10pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src)
|
||||
font = _by_key(res["changes"], "font_pt")
|
||||
assert len(font) == 1
|
||||
c = font[0]
|
||||
assert c["action"] == "modified"
|
||||
assert "[11pt]" in res["modified_tex"] and "[10pt]" not in res["modified_tex"]
|
||||
assert "10pt" in c["original"] and "11pt" in c["modified"]
|
||||
|
||||
|
||||
def test_font_compliant_untouched():
|
||||
res = _run(MINIMAL)
|
||||
assert res["modified_tex"] == MINIMAL # warning 不改源码(拍板 2)
|
||||
assert _by_key(res["changes"], "font_pt") == []
|
||||
|
||||
|
||||
def test_font_missing_option_is_warning():
|
||||
src = "\\documentclass[a4paper]{article}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src)
|
||||
assert res["modified_tex"] == src # 拍板 2:绝不自动插入选项
|
||||
(c,) = _by_key(res["changes"], "font_pt")
|
||||
assert c["action"] == "warning"
|
||||
assert "\\documentclass[a4paper, 11pt]{article}" in c["reason"]
|
||||
|
||||
|
||||
def test_font_min_above_standard_uses_min():
|
||||
src = "\\documentclass[12pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src, min_font_pt=14)
|
||||
assert "[14pt]" in res["modified_tex"]
|
||||
|
||||
|
||||
# ================================================================ ② 页边距 margin
|
||||
|
||||
def test_margin_bumps_hmargin_vmargin():
|
||||
src = "\\documentclass[11pt]{article}\n\\usepackage[hmargin=10mm,vmargin=15mm]{geometry}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src)
|
||||
assert "hmargin=20mm" in res["modified_tex"]
|
||||
assert "vmargin=20mm" in res["modified_tex"]
|
||||
assert "]{geometry}" in res["modified_tex"] # 值替换不得吞掉包名尾巴
|
||||
margin = _by_key(res["changes"], "margin")
|
||||
assert len(margin) == 1 # 同行多键合并一条
|
||||
assert "left" in margin[0]["reason"] and "top" in margin[0]["reason"]
|
||||
|
||||
|
||||
def test_margin_per_side_only_bumps_violations():
|
||||
res = _run(GOLDEN)
|
||||
(c,) = [x for x in res["changes"] if x["rule_key"] == "margin"]
|
||||
assert c["action"] == "modified"
|
||||
assert "top=20mm" in c["modified"] # top 10mm 违规
|
||||
assert "margin=1in" in c["modified"] # 1in=25.4mm 合规,不动
|
||||
|
||||
|
||||
def test_margin_compliant_untouched():
|
||||
src = "\\documentclass[11pt]{article}\n\\usepackage[margin=25mm]{geometry}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src)
|
||||
assert res["modified_tex"] == src
|
||||
|
||||
|
||||
def test_margin_no_geometry_is_warning():
|
||||
src = "\\documentclass[11pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src)
|
||||
assert res["modified_tex"] == src
|
||||
(c,) = [x for x in res["changes"] if x["rule_key"] == "margin"]
|
||||
assert c["action"] == "warning"
|
||||
assert "\\usepackage[margin=20mm]{geometry}" in c["reason"]
|
||||
|
||||
|
||||
def test_margin_geometry_without_margin_attrs_is_warning():
|
||||
src = "\\documentclass[11pt]{article}\n\\usepackage[scale=0.7]{geometry}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src)
|
||||
assert res["modified_tex"] == src
|
||||
(c,) = [x for x in res["changes"] if x["rule_key"] == "margin"]
|
||||
assert c["action"] == "warning"
|
||||
assert "margin=20mm" in c["reason"]
|
||||
|
||||
|
||||
def test_margin_partial_sides_bump_and_warn():
|
||||
src = "\\documentclass[11pt]{article}\n\\usepackage[left=10mm]{geometry}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src)
|
||||
assert "left=20mm" in res["modified_tex"]
|
||||
assert "]{geometry}" in res["modified_tex"]
|
||||
mod = _by_key(res["changes"], "margin", "modified")
|
||||
warn = _by_key(res["changes"], "margin", "warning")
|
||||
assert len(mod) == 1 and len(warn) == 1
|
||||
assert "right" in warn[0]["reason"]
|
||||
|
||||
|
||||
# ================================================================ ③ 行距 line_spacing
|
||||
|
||||
def test_spacing_modify():
|
||||
res = _run(GOLDEN)
|
||||
(c,) = [x for x in res["changes"] if x["rule_key"] == "line_spacing"]
|
||||
assert c["action"] == "modified"
|
||||
assert "\\linespread{1.5}" in res["modified_tex"]
|
||||
|
||||
|
||||
def test_spacing_ignores_body_override():
|
||||
src = ("\\documentclass[11pt]{article}\n\\linespread{1.5}\n\\begin{document}\n"
|
||||
"\\setstretch{1.0}\nx\n\\end{document}\n")
|
||||
res = _run(src)
|
||||
assert res["modified_tex"] == src # 正文局部覆盖不碰(导言区已合规)
|
||||
|
||||
|
||||
def test_spacing_missing_is_warning():
|
||||
src = "\\documentclass[11pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src)
|
||||
(c,) = [x for x in res["changes"] if x["rule_key"] == "line_spacing"]
|
||||
assert c["action"] == "warning"
|
||||
assert "\\linespread{1.5}" in c["reason"]
|
||||
|
||||
|
||||
# ================================================================ ④ 参考文献 bib_style
|
||||
|
||||
def test_bib_plain_to_apalike():
|
||||
res = _run(GOLDEN)
|
||||
(c,) = [x for x in res["changes"] if x["rule_key"] == "bib_style"]
|
||||
assert c["action"] == "modified"
|
||||
assert "\\bibliographystyle{apalike}" in res["modified_tex"]
|
||||
|
||||
|
||||
def test_bib_author_year_maps_apalike_not_plain():
|
||||
"""回归(2026-10-10 修正):author_year 是姓名-年份制 → apalike,绝不映射编号制 plain。"""
|
||||
src = "\\documentclass[11pt]{article}\n\\bibliographystyle{unsrt}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src, refs_style="author_year")
|
||||
assert "\\bibliographystyle{apalike}" in res["modified_tex"]
|
||||
assert "unsrt" not in res["modified_tex"].split("bibliographystyle")[1]
|
||||
|
||||
|
||||
def test_bib_null_refs_style_warns_without_touching_source():
|
||||
"""拍板(2026-10-10):refs_style=null(如 Frontiers 两种制式并存)→ 保持原样 + warning。"""
|
||||
src = "\\documentclass[11pt]{article}\n\\bibliographystyle{unsrt}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src, refs_style=None)
|
||||
assert res["modified_tex"] == src # 绝不猜测改写用户样式
|
||||
(c,) = _by_key(res["changes"], "bib_style")
|
||||
assert c["action"] == "warning"
|
||||
assert "未确认" in c["reason"] and "bibliographystyle" in c["reason"]
|
||||
|
||||
|
||||
def test_bib_gb7714_hint():
|
||||
src = "\\documentclass[11pt]{article}\n\\bibliographystyle{plain}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src, refs_style="gb7714")
|
||||
assert "\\bibliographystyle{gb7714}" in res["modified_tex"]
|
||||
(c,) = _by_key(res["changes"], "bib_style")
|
||||
assert "gbt7714" in c["reason"]
|
||||
|
||||
|
||||
def test_bib_unmapped_style_is_warning():
|
||||
src = "\\documentclass[11pt]{article}\n\\bibliographystyle{plain}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src, refs_style="mla")
|
||||
assert res["modified_tex"] == src
|
||||
(c,) = _by_key(res["changes"], "bib_style")
|
||||
assert c["action"] == "warning" and "mla" in c["reason"]
|
||||
|
||||
|
||||
def test_bib_missing_is_warning():
|
||||
src = "\\documentclass[11pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
|
||||
res = _run(src)
|
||||
(c,) = [x for x in res["changes"] if x["rule_key"] == "bib_style"]
|
||||
assert c["action"] == "warning"
|
||||
assert "\\bibliographystyle{apalike}" in c["reason"]
|
||||
|
||||
|
||||
def test_bib_biblatex_workflow_is_warning():
|
||||
src = ("\\documentclass[11pt]{article}\n\\usepackage{biblatex}\n"
|
||||
"\\begin{document}\n\\printbibliography\n\\end{document}\n")
|
||||
res = _run(src)
|
||||
(c,) = [x for x in res["changes"] if x["rule_key"] == "bib_style"]
|
||||
assert c["action"] == "warning"
|
||||
assert "style=apa" in c["reason"]
|
||||
|
||||
|
||||
# ================================================================ 结构守卫 + 幂等
|
||||
|
||||
def test_parser_failed_without_documentclass():
|
||||
with pytest.raises(ReformatError) as ei:
|
||||
_run("% just a fragment\n")
|
||||
assert ei.value.code == "PARSER_FAILED"
|
||||
|
||||
|
||||
def test_multi_documentclass_first_wins():
|
||||
src = ("\\documentclass[10pt]{article}\n\\begin{document}\n"
|
||||
"\\documentclass[9pt]{book}\n\\end{document}\n")
|
||||
res = _run(src)
|
||||
assert [w["code"] for w in res["warnings"]] == ["MULTI_DOCUMENTCLASS"]
|
||||
assert "[11pt]" in res["modified_tex"]
|
||||
assert res["modified_tex"].count("\\documentclass") == 2 # 第二处不碰
|
||||
|
||||
|
||||
def test_idempotent_second_run_clean():
|
||||
first = _run(GOLDEN)
|
||||
assert len(_modified(first["changes"])) == 4
|
||||
assert first["warnings"] == []
|
||||
second = _run(first["modified_tex"])
|
||||
assert second["changes"] == []
|
||||
assert second["modified_tex"] == first["modified_tex"]
|
||||
|
||||
|
||||
def test_changes_sorted_and_fields_complete():
|
||||
res = _run(GOLDEN)
|
||||
lines = [c["line"] for c in res["changes"]]
|
||||
assert lines == sorted(lines)
|
||||
for c in res["changes"]:
|
||||
assert set(c) == {"line", "original", "modified", "reason", "rule_key", "action"}
|
||||
|
||||
|
||||
# ================================================================ API(信封/落盘)
|
||||
|
||||
def _post(client: TestClient, content: bytes, journal: str = "default",
|
||||
filename: str = "paper.tex", **data):
|
||||
if journal is not None:
|
||||
data.setdefault("target_journal", journal)
|
||||
return client.post("/api/review/reformat",
|
||||
files={"file": (filename, content, "text/plain")}, data=data)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
jobs = tmp_path / "jobs"
|
||||
|
||||
def fake_dir():
|
||||
jobs.mkdir(parents=True, exist_ok=True)
|
||||
return jobs
|
||||
|
||||
monkeypatch.setattr("app.routers.reformat._jobs_dir", fake_dir)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_api_non_tex_rejected(client: TestClient):
|
||||
body = _post(client, b"x", filename="a.pdf").json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"]["code"] == "VALIDATION"
|
||||
assert body["error"]["degraded"] is False
|
||||
|
||||
|
||||
def test_api_missing_target_rejected(client: TestClient):
|
||||
body = _post(client, GOLDEN.encode(), journal=None).json()
|
||||
assert body["ok"] is False and body["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_api_empty_file_rejected(client: TestClient):
|
||||
body = _post(client, b"").json()
|
||||
assert body["ok"] is False and body["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_api_non_utf8_rejected(client: TestClient):
|
||||
body = _post(client, b"\xff\xfe\x00bad").json()
|
||||
assert body["ok"] is False and body["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_api_parser_failed_envelope(client: TestClient):
|
||||
body = _post(client, b"% fragment, no documentclass").json()
|
||||
assert body["ok"] is False and body["error"]["code"] == "PARSER_FAILED"
|
||||
assert "documentclass" in body["error"]["message"]
|
||||
|
||||
|
||||
def test_api_success_and_persist(client: TestClient):
|
||||
body = _post(client, GOLDEN.encode()).json()
|
||||
assert body["ok"] is True
|
||||
assert re.fullmatch(r"[0-9a-f]{12}", body["job_id"])
|
||||
mod = _modified(body["changes"])
|
||||
assert len(mod) == 4 and body["warnings"] == []
|
||||
assert "\\documentclass[11pt]" in body["modified_tex"]
|
||||
meta = body["meta"]
|
||||
assert meta["rules_source"] == "default"
|
||||
assert meta["rules"]["min_font_pt"] == 11
|
||||
assert meta["engine"] == "reformat-p1/1.0"
|
||||
assert len(meta["original_sha256"]) == 64
|
||||
# 持久化:重启语义的 GET 回读同一 job
|
||||
got = client.get(f"/api/review/reformat/{body['job_id']}").json()
|
||||
assert got["ok"] and got["job_id"] == body["job_id"]
|
||||
assert len(_modified(got["changes"])) == 4
|
||||
f = client.get(f"/api/review/reformat/{body['job_id']}/file")
|
||||
assert f.status_code == 200
|
||||
assert "x-tex" in f.headers["content-type"]
|
||||
assert "\\documentclass[11pt]" in f.text
|
||||
|
||||
|
||||
def test_api_job_not_found(client: TestClient):
|
||||
for path in ("/api/review/reformat/000000000000",
|
||||
"/api/review/reformat/zzzz"):
|
||||
body = client.get(path).json()
|
||||
assert body["ok"] is False and body["error"]["code"] == "NOT_FOUND"
|
||||
f = client.get("/api/review/reformat/000000000000/file")
|
||||
assert f.status_code == 200 and f.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_api_idempotent_reupload(client: TestClient):
|
||||
first = _post(client, GOLDEN.encode()).json()
|
||||
assert first["ok"] and len(_modified(first["changes"])) == 4
|
||||
second = _post(client, first["modified_tex"].encode()).json()
|
||||
assert second["ok"] and second["changes"] == []
|
||||
assert second["modified_tex"] == first["modified_tex"]
|
||||
+26
-1
@@ -13,11 +13,13 @@ const BASE = '/api/review';
|
||||
function unwrap(promise) {
|
||||
return promise.then((payload) => {
|
||||
if (payload && payload.ok === false && payload.error) {
|
||||
throw new ApiError(payload.error.message || '服务错误', {
|
||||
const err = new ApiError(payload.error.message || '服务错误', {
|
||||
code: payload.error.code || 'INTERNAL',
|
||||
status: 200,
|
||||
degraded: !!payload.error.degraded,
|
||||
});
|
||||
if (payload.error.detail) err.detail = payload.error.detail; // 编译失败的 log 尾等
|
||||
throw err;
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
@@ -79,6 +81,29 @@ export function formatCheck(formData, opts = {}) {
|
||||
return unwrap(post(`${BASE}/format-check`, formData, opts));
|
||||
}
|
||||
|
||||
/** ⑥ POST /api/review/reformat(multipart .tex + target_journal)
|
||||
* → {ok, job_id, modified_tex,
|
||||
* changes:[{line,original,modified,reason,rule_key,action}],
|
||||
* warnings:[{code,message}], // 仅结构级(如多处 \documentclass)
|
||||
* meta:{journal_id,journal_name,rules,rules_source,rules_note,filename,
|
||||
* original_sha256,engine,generated_at}}
|
||||
* rule_key ∈ {font_pt,margin,line_spacing,bib_style};
|
||||
* action ∈ {modified,warning}(warning=缺插入点的保守建议,不计入已修改)。
|
||||
* 纯规则引擎,同步秒回;job 落盘 → GET `${BASE}/reformat/{id}` 可回读。 */
|
||||
export function reformatTex(formData, opts = {}) {
|
||||
return unwrap(post(`${BASE}/reformat`, formData, opts));
|
||||
}
|
||||
|
||||
/** P3a POST /api/review/compile(multipart:job_id 或 file 二选一,前端走 job_id)
|
||||
* → {ok, compile_id, pdf_url, log_url, meta:{seconds,pdf_bytes,engine,compiled_at}}
|
||||
* 错误信封:VALIDATION / TOOL_UNAVAILABLE / COMPILE_FAILED / TIMEOUT / OUTPUT_TOO_LARGE
|
||||
* 为 HTTP 200 信封;QUEUE_FULL 为 HTTP 429 + 同款信封(http.js 抛非 2xx ApiError,
|
||||
* 两者都归一成 ApiError)。COMPILE_FAILED/TIMEOUT 的 err.detail = log 尾 200 行。
|
||||
* 编译为单槽队列,耗时约 10-30 秒(默认 60s 上限)。 */
|
||||
export function compileTex(formData, opts = {}) {
|
||||
return unwrap(post(`${BASE}/compile`, formData, opts));
|
||||
}
|
||||
|
||||
/** ⑤ format-check 专用错误文案:解档/校验类 PARSE_FAILED/VALIDATION 用后端原话
|
||||
* (与 presubmit 的"扫描件"提示是不同语境,不能复用 CODE_HINTS),其余走统一口径。 */
|
||||
export function formatErrorMessage(err) {
|
||||
|
||||
@@ -15,6 +15,7 @@ const ITEMS = [
|
||||
{ key: 'presubmit', label: 'AI 预审稿系统', icon: '🔍' },
|
||||
{ key: 'report', label: '专业审稿意见报告', icon: '📝' },
|
||||
{ key: 'format', label: '论文排版与规范', icon: '📐' },
|
||||
{ key: 'reformat', label: '论文自动重排版', icon: '🧩' },
|
||||
{ key: 'tracker', label: '投稿进度与返修', icon: '📮' },
|
||||
{ key: 'calendar', label: '会议与投稿日历', icon: '📅' },
|
||||
];
|
||||
|
||||
@@ -11,6 +11,7 @@ import RecommendPanel from './panels/RecommendPanel.vue';
|
||||
import PresubmitPanel from './panels/PresubmitPanel.vue';
|
||||
import ReportPanel from './panels/ReportPanel.vue';
|
||||
import FormatPanel from './panels/FormatPanel.vue';
|
||||
import ReformatPanel from './panels/ReformatPanel.vue';
|
||||
import TrackerPanel from './panels/TrackerPanel.vue';
|
||||
import CalendarPanel from './panels/CalendarPanel.vue';
|
||||
|
||||
@@ -24,6 +25,7 @@ const panelMap = {
|
||||
presubmit: PresubmitPanel,
|
||||
report: ReportPanel,
|
||||
format: FormatPanel,
|
||||
reformat: ReformatPanel,
|
||||
tracker: TrackerPanel,
|
||||
calendar: CalendarPanel,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
<!-- ReformatPanel.vue —— ⑤' 论文自动重排版 P1(.tex 源码级规则变换,不编译)。
|
||||
链路:本地 .tex(≤2MB,仅 .tex——docx/pdf 只走 ⑤ 审核不重排)+ 目标期刊 →
|
||||
edu.submitReformat → POST /api/review/reformat(纯规则,同步秒回)
|
||||
→ {ok, job_id, modified_tex, changes[{line,original,modified,reason,
|
||||
rule_key,action}], warnings[结构级], meta{journal_*,rules*,
|
||||
filename,original_sha256,engine,generated_at}}
|
||||
目标下拉分组:通用规则(首项默认 default,APA+通用规范)/ 精选期刊 /
|
||||
期刊目录(选 __dir_* 进搜索模式,getDirectory 300ms 防抖本地态,
|
||||
选定后 target = 目录 id,后端经 journal_directory 回链)。
|
||||
结果三 Tab:修改后源码(Blob 下载)/ Diff 报告(按 rule_key 着色、
|
||||
按 action 计数:modified=已修改、warning=建议·橙色·不计入)/
|
||||
编译 PDF(P3 决策暂缓:仅「开发中」占位,不触发编译;后端 tectonic
|
||||
API(P3a)保留备用,前端不暴露编译入口,引导下载 .tex 去 Overleaf/
|
||||
本地编译)。
|
||||
结果跨菜单保留(store reformatResult);样式 rf- 前缀,零新增依赖。 -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { getDirectory, reviewErrorMessage } from '../../../api/review.js';
|
||||
import { useEduStore } from '../../../stores/edu.js';
|
||||
|
||||
const edu = useEduStore();
|
||||
|
||||
const MAX_SIZE = 2 * 1024 * 1024; // 与后端 REVIEW_MAX_TEX_BYTES 一致
|
||||
const RULE_LABEL = { font_pt: '字号', margin: '页边距', line_spacing: '行距', bib_style: '参考文献' };
|
||||
const RULE_MOD = { font_pt: 'k1', margin: 'k2', line_spacing: 'k3', bib_style: 'k4' };
|
||||
const SOURCE_LABEL = {
|
||||
default: '通用规则', official: '期刊专属规则', fetched: '官网抓取规则', cache: '缓存规则',
|
||||
};
|
||||
|
||||
const fileInput = ref(null);
|
||||
const file = ref(null);
|
||||
const fileError = ref('');
|
||||
const dragging = ref(false);
|
||||
const tab = ref('source'); // 'source' | 'diff' | 'pdf'
|
||||
|
||||
/* —— 目标期刊(drafts.reformatTarget 持久:'default' | 精选 id | '__dir_en__'
|
||||
过渡态 | 'dir:<id>' 目录已选)—— */
|
||||
const targetChoice = ref(edu.drafts.reformatTarget || 'default');
|
||||
|
||||
const dirMode = computed(() => {
|
||||
const t = targetChoice.value;
|
||||
if (t === '__dir_en__') return 'en';
|
||||
if (t === '__dir_zh__') return 'zh';
|
||||
return '';
|
||||
});
|
||||
const actualTarget = computed(() => {
|
||||
const t = String(targetChoice.value || '');
|
||||
if (t.startsWith('dir:')) return t.slice(4);
|
||||
if (t.startsWith('__dir')) return ''; // 搜索中未选定 → 不可提交
|
||||
return t;
|
||||
});
|
||||
const targetReady = computed(() => !!actualTarget.value);
|
||||
const dirPick = computed(() => {
|
||||
const t = targetChoice.value;
|
||||
const pick = edu.drafts.reformatDirPick;
|
||||
if (t.startsWith('dir:') && pick && `dir:${pick.id}` === t) return pick;
|
||||
return null;
|
||||
});
|
||||
|
||||
/* —— 目录搜索(本地态,不占用 store.directory ——那是 ① 目录页的数据)—— */
|
||||
const dirQuery = ref('');
|
||||
const dirResults = ref([]);
|
||||
const dirLoading = ref(false);
|
||||
const dirError = ref('');
|
||||
let dirTimer = null;
|
||||
let dirSeq = 0;
|
||||
|
||||
watch(targetChoice, (v) => {
|
||||
edu.drafts.reformatTarget = v;
|
||||
if (!v.startsWith('__dir')) {
|
||||
dirQuery.value = '';
|
||||
dirResults.value = [];
|
||||
dirError.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
async function runDirSearch() {
|
||||
const q = dirQuery.value.trim();
|
||||
if (!dirMode.value || !q) {
|
||||
dirResults.value = [];
|
||||
return;
|
||||
}
|
||||
const seq = ++dirSeq;
|
||||
dirLoading.value = true;
|
||||
dirError.value = '';
|
||||
try {
|
||||
const payload = await getDirectory({ q, lang: dirMode.value, size: 20 });
|
||||
if (seq !== dirSeq) return;
|
||||
dirResults.value = (payload && payload.items) || [];
|
||||
} catch (err) {
|
||||
if (seq !== dirSeq) return;
|
||||
dirError.value = reviewErrorMessage(err);
|
||||
} finally {
|
||||
if (seq === dirSeq) dirLoading.value = false;
|
||||
}
|
||||
}
|
||||
watch(dirQuery, () => {
|
||||
clearTimeout(dirTimer);
|
||||
dirTimer = setTimeout(runDirSearch, 300);
|
||||
});
|
||||
|
||||
function pickDir(item) {
|
||||
targetChoice.value = `dir:${item.id}`;
|
||||
edu.drafts.reformatDirPick = { id: item.id, name: item.name };
|
||||
dirQuery.value = '';
|
||||
dirResults.value = [];
|
||||
}
|
||||
|
||||
/* —— 结果视图 —— */
|
||||
const hasResult = computed(() => !!(edu.reformatResult && edu.reformatResult.ok));
|
||||
const loading = computed(() => edu.reformatStatus === 'loading');
|
||||
const errored = computed(() => edu.reformatStatus === 'error');
|
||||
const result = computed(() => edu.reformatResult || null);
|
||||
const meta = computed(() => result.value?.meta || null);
|
||||
const changes = computed(() => result.value?.changes || []);
|
||||
const structWarnings = computed(() => result.value?.warnings || []);
|
||||
const modifiedCount = computed(() => changes.value.filter((c) => c.action === 'modified').length);
|
||||
const suggestCount = computed(() => changes.value.filter((c) => c.action === 'warning').length);
|
||||
const sourceLabel = computed(() => SOURCE_LABEL[meta.value?.rules_source] || meta.value?.rules_source || '—');
|
||||
|
||||
onMounted(() => {
|
||||
if (edu.journalsStatus === 'idle') edu.fetchJournals(); // 精选期刊下拉数据源
|
||||
// 恢复的 target 是目录刊但无选中记录(历史草稿)→ 回落通用规则
|
||||
if (targetChoice.value.startsWith('dir:') && !dirPick.value) {
|
||||
targetChoice.value = 'default';
|
||||
}
|
||||
});
|
||||
|
||||
/* —— 文件(仅 .tex,拍板 5)—— */
|
||||
function acceptFile(f) {
|
||||
fileError.value = '';
|
||||
if (!f) return;
|
||||
if (!String(f.name || '').toLowerCase().endsWith('.tex')) {
|
||||
fileError.value = '仅支持 LaTeX 源码(.tex);docx/pdf 请使用「论文排版与规范」审核';
|
||||
return;
|
||||
}
|
||||
if (!f.size) {
|
||||
fileError.value = '上传文件为空';
|
||||
return;
|
||||
}
|
||||
if (f.size > MAX_SIZE) {
|
||||
fileError.value = '源码超过 2MB 上限';
|
||||
return;
|
||||
}
|
||||
file.value = f;
|
||||
}
|
||||
function onPick(e) {
|
||||
acceptFile(e.target.files?.[0]);
|
||||
e.target.value = '';
|
||||
}
|
||||
function onDrop(e) {
|
||||
dragging.value = false;
|
||||
acceptFile(e.dataTransfer?.files?.[0]);
|
||||
}
|
||||
function removeFile() {
|
||||
file.value = null;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!file.value || loading.value || !targetReady.value) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file.value);
|
||||
fd.append('target_journal', actualTarget.value);
|
||||
tab.value = 'source';
|
||||
await edu.submitReformat(fd);
|
||||
}
|
||||
function reset() {
|
||||
edu.clearReformat();
|
||||
file.value = null;
|
||||
fileError.value = '';
|
||||
tab.value = 'source';
|
||||
}
|
||||
function download() {
|
||||
const tex = result.value?.modified_tex;
|
||||
if (tex == null) return;
|
||||
const base = String(meta.value?.filename || 'paper').replace(/\.tex$/i, '');
|
||||
const url = URL.createObjectURL(new Blob([tex], { type: 'application/x-tex;charset=utf-8' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${base}.reformat.tex`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rf">
|
||||
<header class="rf-head">
|
||||
<h1 class="rf-title">论文自动重排版</h1>
|
||||
<span class="rf-sub">LaTeX (.tex) · 字号 · 页边距 · 行距 · 参考文献 · 纯规则不编译</span>
|
||||
</header>
|
||||
|
||||
<!-- ========== ① 有结果 → 结果区 ========== -->
|
||||
<template v-if="hasResult">
|
||||
<div class="rf-meta">
|
||||
📄 {{ meta?.filename || '—' }} · 目标:{{ meta?.journal_name || '—' }}({{ sourceLabel }})·
|
||||
{{ meta?.engine }} · {{ meta?.generated_at || '' }}
|
||||
<span class="rf-meta-sub"> · job {{ result.job_id }}</span>
|
||||
</div>
|
||||
<p v-if="meta?.rules_note" class="rf-note" role="note">{{ meta.rules_note }}</p>
|
||||
|
||||
<!-- 汇总:已修改 / 建议(橙,不计入修改)/ 结构提示 -->
|
||||
<div class="rf-summary">
|
||||
<span class="rf-count mod">{{ modifiedCount }} 处已修改</span>
|
||||
<span class="rf-count sug">{{ suggestCount }} 条建议</span>
|
||||
<span class="rf-count warn" v-if="structWarnings.length">{{ structWarnings.length }} 条结构提示</span>
|
||||
<button class="rf-again" type="button" @click="reset">重新排版</button>
|
||||
<button class="rf-dl" type="button" @click="download">下载修改后源码</button>
|
||||
</div>
|
||||
|
||||
<article v-for="(w, i) in structWarnings" :key="'w' + i" class="rf-struct">
|
||||
⚠ {{ w.message }}
|
||||
</article>
|
||||
|
||||
<!-- Tab:修改后源码 | Diff 报告 | 编译 PDF(P3a) -->
|
||||
<div class="rf-tabs" role="tablist">
|
||||
<button class="rf-tab" :class="{ active: tab === 'source' }" type="button"
|
||||
role="tab" @click="tab = 'source'">修改后源码</button>
|
||||
<button class="rf-tab" :class="{ active: tab === 'diff' }" type="button"
|
||||
role="tab" @click="tab = 'diff'">Diff 报告</button>
|
||||
<button class="rf-tab" :class="{ active: tab === 'pdf' }" type="button"
|
||||
role="tab" @click="tab = 'pdf'">编译 PDF</button>
|
||||
</div>
|
||||
|
||||
<pre v-if="tab === 'source'" class="rf-src">{{ result.modified_tex }}</pre>
|
||||
|
||||
<div v-else-if="tab === 'diff'" class="rf-diff">
|
||||
<p v-if="!changes.length" class="rf-empty">
|
||||
✅ 未发现违反目标规则的可修改项——源码未被触碰(规则:{{ sourceLabel }})。
|
||||
</p>
|
||||
<article
|
||||
v-for="(c, i) in changes"
|
||||
:key="i"
|
||||
class="rf-row"
|
||||
:class="c.action"
|
||||
>
|
||||
<header class="rf-row-head">
|
||||
<span class="rf-line">L{{ c.line }}</span>
|
||||
<span class="rf-rule" :class="RULE_MOD[c.rule_key]">{{ RULE_LABEL[c.rule_key] || c.rule_key }}</span>
|
||||
<span class="rf-act" :class="c.action">
|
||||
{{ c.action === 'modified' ? '已修改' : '建议' }}
|
||||
</span>
|
||||
</header>
|
||||
<div class="rf-pair">
|
||||
<pre class="rf-old">{{ c.original }}</pre>
|
||||
<pre v-if="c.action === 'modified'" class="rf-new">{{ c.modified }}</pre>
|
||||
<pre v-else class="rf-skip">(未修改 — 缺少可安全修改的插入点)</pre>
|
||||
</div>
|
||||
<p class="rf-reason" :class="{ sug: c.action === 'warning' }">{{ c.reason }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 编译 PDF(P3 暂缓):「开发中」占位,不触发编译;后端 P3a API 保留备用 -->
|
||||
<div v-else class="rf-compile">
|
||||
<div class="rf-wip" role="note">
|
||||
<p class="rf-wip-main">
|
||||
PDF 编译功能开发中,暂时请下载修改后的 .tex 文件,在 Overleaf 或本地编译。
|
||||
后续将支持多文件工程和图片上传。
|
||||
</p>
|
||||
<p class="rf-wip-sub">已提供逐行修改对照(Diff 报告)与修改后源码下载。</p>
|
||||
<a
|
||||
class="rf-wip-feedback"
|
||||
href="http://192.168.0.175:3000/admin/PlantAI-rag-new/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>反馈需求 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ========== ② loading ========== -->
|
||||
<div v-else-if="loading" class="rf-state" aria-busy="true">
|
||||
<div class="rf-skel"></div>
|
||||
<p class="rf-state-msg">重排版中…(纯规则引擎,通常 <1 秒)</p>
|
||||
</div>
|
||||
|
||||
<!-- ========== ③ error ========== -->
|
||||
<div v-else-if="errored" class="rf-state" role="alert">
|
||||
<p class="rf-state-msg rf-state-error">{{ edu.reformatError }}</p>
|
||||
<div class="rf-err-btns">
|
||||
<button v-if="file" class="rf-retry" type="button" @click="submit">重试</button>
|
||||
<button class="rf-retry" type="button" @click="reset">重新开始</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== ④ 上传区 ========== -->
|
||||
<div v-else class="rf-upload">
|
||||
<div
|
||||
class="rf-drop"
|
||||
:class="{ dragging, filled: !!file }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="fileInput.click()"
|
||||
@keydown.enter="fileInput.click()"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="rf-file-input"
|
||||
type="file"
|
||||
accept=".tex"
|
||||
@click.stop
|
||||
@change="onPick"
|
||||
/>
|
||||
<template v-if="file">
|
||||
<span class="rf-file-name">📄 {{ file.name }} · {{ (file.size / 1024).toFixed(1) }} KB</span>
|
||||
<button class="rf-remove" type="button" title="移除文件" @click.stop="removeFile">×</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="rf-drop-main">拖拽 LaTeX 源码(.tex)到此处,或点击选择</span>
|
||||
<span class="rf-hint">仅 .tex 单文件(多文件 \input 工程请先合并)· ≤ 2MB · UTF-8</span>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="fileError" class="rf-file-error">{{ fileError }}</p>
|
||||
|
||||
<label class="rf-label" for="rf-target">目标期刊(必选,决定规则集)</label>
|
||||
<select id="rf-target" v-model="targetChoice" class="rf-input rf-select" :disabled="loading">
|
||||
<optgroup label="通用规则">
|
||||
<option value="default">通用规则(APA + 通用论文规范)</option>
|
||||
</optgroup>
|
||||
<optgroup v-if="edu.journals.length" label="精选期刊">
|
||||
<option v-for="j in edu.journals" :key="j.id" :value="j.id">{{ j.name }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="期刊目录(1056 本)">
|
||||
<option value="__dir_en__">国际目录 · 搜索选择…</option>
|
||||
<option value="__dir_zh__">中文目录 · 搜索选择…</option>
|
||||
<option v-if="dirPick" :value="`dir:${dirPick.id}`">{{ dirPick.name }}(已选定)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
<!-- 目录搜索模式:输入 → 300ms 防抖 → 列表点选 -->
|
||||
<div v-if="dirMode" class="rf-dirsearch">
|
||||
<input
|
||||
v-model="dirQuery"
|
||||
class="rf-input"
|
||||
type="search"
|
||||
:placeholder="dirMode === 'en' ? '搜索国际目录期刊名 / ISSN…' : '搜索中文目录期刊名 / ISSN…'"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<p v-if="dirError" class="rf-file-error">{{ dirError }}</p>
|
||||
<p v-if="dirLoading" class="rf-dirhint">搜索中…</p>
|
||||
<p v-else-if="dirQuery.trim() && !dirResults.length && !dirError" class="rf-dirhint">无匹配期刊</p>
|
||||
<ul v-if="dirResults.length" class="rf-dirlist">
|
||||
<li v-for="it in dirResults" :key="it.id">
|
||||
<button class="rf-diritem" type="button" @click="pickDir(it)">
|
||||
<span class="rf-dirname">{{ it.name }}</span>
|
||||
<span class="rf-dirmeta">
|
||||
{{ it.issn ? 'ISSN ' + it.issn : '' }}
|
||||
<template v-if="it.cas_partition"> · {{ it.cas_partition }}</template>
|
||||
<template v-if="it.jif"> · JIF {{ it.jif }}</template>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-else-if="targetChoice.startsWith('dir:') && !dirPick" class="rf-dirhint">
|
||||
⚠ 目录选中记录缺失,请重新搜索选择期刊
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="rf-submit"
|
||||
type="button"
|
||||
:disabled="!file || !targetReady || loading"
|
||||
@click="submit"
|
||||
>
|
||||
开始自动重排版
|
||||
</button>
|
||||
<p v-if="dirMode && !actualTarget" class="rf-dirhint">请从搜索结果中选择期刊后提交</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.rf-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.rf-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rf-sub {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rf-meta {
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rf-meta-sub {
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rf-note {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--pa-warning);
|
||||
border-radius: var(--pa-r-md);
|
||||
background: transparent;
|
||||
color: var(--pa-warning);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.rf-struct {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--pa-warning);
|
||||
border-radius: var(--pa-r-md);
|
||||
color: var(--pa-warning);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* 汇总条 */
|
||||
.rf-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rf-count {
|
||||
padding: 3px 12px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rf-count.mod { color: var(--pa-success); }
|
||||
.rf-count.sug { color: var(--pa-warning); }
|
||||
.rf-count.warn { color: var(--pa-warning); }
|
||||
.rf-again,
|
||||
.rf-dl {
|
||||
padding: 5px 14px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
background: transparent;
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rf-dl { margin-left: auto; }
|
||||
.rf-again:hover,
|
||||
.rf-dl:hover {
|
||||
color: var(--pa-ink);
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
|
||||
/* Tab */
|
||||
.rf-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.rf-tab {
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rf-tab.active {
|
||||
color: var(--pa-ink);
|
||||
font-weight: 600;
|
||||
border-bottom-color: var(--pa-g-from);
|
||||
}
|
||||
.rf-tab:disabled {
|
||||
opacity: .45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.rf-src {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
background: var(--pa-code-bg);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
color: var(--pa-ink);
|
||||
white-space: pre;
|
||||
}
|
||||
.rf-empty {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
|
||||
/* Diff 行 */
|
||||
.rf-diff {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.rf-row {
|
||||
padding: 10px 14px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--pa-r-md);
|
||||
}
|
||||
.rf-row.modified { border-left-color: var(--pa-success); }
|
||||
.rf-row.warning { border-left-color: var(--pa-warning); }
|
||||
.rf-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.rf-line {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rf-rule {
|
||||
padding: 1px 8px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rf-rule.k1 { color: var(--pa-g-from); } /* 字号 */
|
||||
.rf-rule.k2 { color: var(--pa-success); } /* 页边距 */
|
||||
.rf-rule.k3 { color: var(--pa-info, #5b8def); } /* 行距 */
|
||||
.rf-rule.k4 { color: var(--pa-error); } /* 参考文献 */
|
||||
.rf-act {
|
||||
margin-left: auto;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.rf-act.modified {
|
||||
color: var(--pa-success);
|
||||
border: 1px solid var(--pa-success);
|
||||
}
|
||||
.rf-act.warning {
|
||||
color: var(--pa-warning);
|
||||
border: 1px solid var(--pa-warning);
|
||||
}
|
||||
.rf-pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.rf-pair pre {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.rf-old {
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rf-old::before {
|
||||
content: '− ';
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.rf-new {
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rf-new::before {
|
||||
content: '+ ';
|
||||
color: var(--pa-success);
|
||||
}
|
||||
.rf-skip {
|
||||
background: transparent;
|
||||
color: var(--pa-warning);
|
||||
font-style: italic;
|
||||
}
|
||||
.rf-reason {
|
||||
margin: 6px 0 0;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--pa-ink-muted);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.rf-reason.sug {
|
||||
color: var(--pa-warning);
|
||||
}
|
||||
|
||||
/* 编译 PDF(P3 暂缓:开发中占位) */
|
||||
.rf-compile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.rf-wip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 16px 18px;
|
||||
border: 1px dashed var(--pa-hairline-strong);
|
||||
border-radius: var(--pa-r-lg);
|
||||
background: var(--pa-surface-1);
|
||||
}
|
||||
.rf-wip-main {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rf-wip-sub {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rf-wip-feedback {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-g-from);
|
||||
text-decoration: none;
|
||||
}
|
||||
.rf-wip-feedback:hover { text-decoration: underline; }
|
||||
|
||||
/* 状态区 */
|
||||
.rf-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.rf-skel {
|
||||
height: 96px;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-2);
|
||||
animation: rf-pulse 1.2s var(--pa-ease) infinite alternate;
|
||||
}
|
||||
@keyframes rf-pulse {
|
||||
from { opacity: .55; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.rf-state-msg {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rf-state-error { color: var(--pa-error); }
|
||||
.rf-err-btns { display: flex; gap: 10px; }
|
||||
.rf-retry {
|
||||
padding: 5px 14px;
|
||||
border: none;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rf-retry:hover { background: var(--pa-surface-3); }
|
||||
|
||||
/* 上传区 */
|
||||
.rf-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.rf-drop {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 130px;
|
||||
padding: 18px;
|
||||
border: 1.5px dashed var(--pa-hairline-strong);
|
||||
border-radius: var(--pa-r-lg);
|
||||
background: var(--pa-surface-1);
|
||||
cursor: pointer;
|
||||
transition: border-color .15s var(--pa-ease), background .15s var(--pa-ease);
|
||||
}
|
||||
.rf-drop:hover,
|
||||
.rf-drop.dragging {
|
||||
border-color: var(--pa-g-from);
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.rf-drop.filled { border-style: solid; }
|
||||
.rf-file-input { display: none; }
|
||||
.rf-drop-main {
|
||||
font-size: 13.5px;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rf-hint {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rf-file-name {
|
||||
font-size: 13.5px;
|
||||
color: var(--pa-ink);
|
||||
word-break: break-all;
|
||||
}
|
||||
.rf-remove {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--pa-surface-3);
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rf-remove:hover { color: var(--pa-ink); }
|
||||
.rf-file-error {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.rf-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rf-input {
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 7px 11px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.rf-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--pa-g-from);
|
||||
}
|
||||
.rf-select:disabled { opacity: .6; }
|
||||
.rf-submit {
|
||||
align-self: flex-start;
|
||||
padding: 8px 22px;
|
||||
border: none;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-gradient);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rf-submit:disabled {
|
||||
opacity: .45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 目录搜索 */
|
||||
.rf-dirsearch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.rf-dirhint {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rf-dirlist {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-1);
|
||||
}
|
||||
.rf-diritem {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rf-diritem:hover { background: var(--pa-surface-2); }
|
||||
.rf-dirname {
|
||||
font-size: 13px;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rf-dirmeta {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
</style>
|
||||
+65
-2
@@ -4,10 +4,10 @@
|
||||
// - 进行中的请求由 store action 持有 → 面板卸载不中断;
|
||||
// - 表单草稿存 drafts → 切菜单不丢。
|
||||
import { defineStore } from 'pinia';
|
||||
import { formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, presubmit, recommend, reviewErrorMessage } from '../api/review.js';
|
||||
import { compileTex, formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, presubmit, recommend, reformatTex, reviewErrorMessage } from '../api/review.js';
|
||||
|
||||
const STORAGE_KEY = 'plantai.edu.submissions'; // D3 台账持久化
|
||||
const SECTIONS = ['journals', 'recommend', 'presubmit', 'report', 'format', 'tracker', 'calendar'];
|
||||
const SECTIONS = ['journals', 'recommend', 'presubmit', 'report', 'format', 'reformat', 'tracker', 'calendar'];
|
||||
|
||||
// ⑥ 台账状态枚举(labels 由 step-7 UI 层给;store 只存字符串)
|
||||
export const SUBMISSION_STATUS = ['draft', 'submitted', 'under_review', 'revision', 'accepted', 'rejected'];
|
||||
@@ -74,6 +74,16 @@ export const useEduStore = defineStore('edu', {
|
||||
formatReport: null, // ⑤ 版式检查响应 {ok, checks, summary, meta}(跨菜单保留)
|
||||
formatStatus: 'idle',
|
||||
formatError: null,
|
||||
/* —— ⑤' 论文自动重排版 P1(.tex 规则变换)响应
|
||||
{ok, job_id, modified_tex, changes[], warnings[], meta}(跨菜单保留)—— */
|
||||
reformatResult: null,
|
||||
reformatStatus: 'idle',
|
||||
reformatError: null,
|
||||
/* —— P3a 编译 PDF(tectonic;与 reformatResult 同生命周期,重新排版即失效)—— */
|
||||
compileResult: null, // {ok, compile_id, pdf_url, log_url, meta}
|
||||
compileStatus: 'idle', // 'idle' | 'loading' | 'ready' | 'error'
|
||||
compileError: null,
|
||||
compileLogTail: '', // COMPILE_FAILED/TIMEOUT 的 log 尾 200 行(err.detail)
|
||||
|
||||
/* —— 表单草稿(切菜单不丢;四字段并存:recommend 用 abstract+prefs、
|
||||
presubmit 用 targetJournal、format 用 formatJournalId(期刊 id 下拉))—— */
|
||||
@@ -81,6 +91,11 @@ export const useEduStore = defineStore('edu', {
|
||||
abstract: '',
|
||||
targetJournal: '',
|
||||
formatJournalId: '',
|
||||
// ⑤' 重排版目标:'default'(通用规则,首项默认)| 精选期刊 id | 'dir:<id>'(目录刊)
|
||||
// '__dir_en__'/'__dir_zh__' = 目录搜索模式(过渡态,非合法 target,面板内校验)。
|
||||
// reformatDirPick = {id,name}(目录选中回显,跨菜单保留防 select 失焦)。
|
||||
reformatTarget: 'default',
|
||||
reformatDirPick: null,
|
||||
// 与 POST /api/review/recommend 的 preferences 同构:
|
||||
// {max_review_weeks, min_impact_factor, tier_focus} 子字段逐一同名同型,
|
||||
// 提交时整体映射为 payload.preferences(null 数值剔除)。
|
||||
@@ -255,5 +270,53 @@ export const useEduStore = defineStore('edu', {
|
||||
this.formatStatus = 'idle';
|
||||
this.formatError = null;
|
||||
},
|
||||
/* —— ⑤' 重排版(P1:.tex 源码变换,纯规则同步秒回)—— */
|
||||
async submitReformat(formData) {
|
||||
this.clearCompile(); // 新排版结果使旧 PDF 失效
|
||||
this.reformatStatus = 'loading';
|
||||
this.reformatError = null;
|
||||
try {
|
||||
const payload = await reformatTex(formData);
|
||||
this.reformatResult = payload;
|
||||
this.reformatStatus = 'ready';
|
||||
return payload;
|
||||
} catch (err) {
|
||||
this.reformatStatus = 'error';
|
||||
this.reformatError = formatErrorMessage(err); // VALIDATION/PARSER_FAILED 用后端原话
|
||||
return null;
|
||||
}
|
||||
},
|
||||
clearReformat() {
|
||||
this.reformatResult = null;
|
||||
this.reformatStatus = 'idle';
|
||||
this.reformatError = null;
|
||||
this.clearCompile();
|
||||
},
|
||||
/* —— P3a 编译 PDF(tectonic 单槽队列;约 10-30 秒)—— */
|
||||
async submitCompile(jobId) {
|
||||
if (this.compileStatus === 'loading') return null;
|
||||
this.compileStatus = 'loading';
|
||||
this.compileError = null;
|
||||
this.compileLogTail = '';
|
||||
const fd = new FormData();
|
||||
fd.append('job_id', jobId);
|
||||
try {
|
||||
const payload = await compileTex(fd);
|
||||
this.compileResult = payload;
|
||||
this.compileStatus = 'ready';
|
||||
return payload;
|
||||
} catch (err) {
|
||||
this.compileStatus = 'error';
|
||||
this.compileError = reviewErrorMessage(err);
|
||||
this.compileLogTail = err.detail || ''; // COMPILE_FAILED/TIMEOUT 附 log 尾
|
||||
return null;
|
||||
}
|
||||
},
|
||||
clearCompile() {
|
||||
this.compileResult = null;
|
||||
this.compileStatus = 'idle';
|
||||
this.compileError = null;
|
||||
this.compileLogTail = '';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user