forked from admin/PlantAI-rag-new
feat(edu): complete 7-panel academic assistant + journals v4 dataset
Backend: - review_service: presubmit / recommend / journals / journals/directory / format-check / conferences endpoints - journals.json 5 精选种子 + journal_directory 1056 条全量(v4 数据) - v4 数据集:2025 JIF(2026 JCR 发布)+ 2025 中科院分区 + 1053 条官网 URL - format_check 行距 bug 修复(基线 y + 物理过滤 + docx EMU) BFF: - review_proxy 反代(修复 httpx build_request await bug) - REVIEW_ENABLED 开关(默认关闭,零痕部署) Frontend: - 教育科研工作台(7 面板 + 菜单 + 状态管理) - TopBar 教育科研入口(v-show 覆盖层语义,RAG 会话不丢) - 期刊情报:Tab 切换 / 搜索 / 筛选 / 分页 / 官网列 / 展开凭证 - 智能推荐 / AI预审稿 / 专业报告 / 投稿台账 / 排版规范 / 会议日历
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# AGENTS.md —— 协作约束(科研审稿模块)
|
||||
|
||||
## 项目背景
|
||||
|
||||
本项目(PlantAI-rag-new)是植物基因组学文献知识工作台,拥有自研 RAG 与知识库
|
||||
(Qdrant + Neo4j + vstore)。2026-10 起新增「科研投稿与智能审稿助手」功能
|
||||
(审稿周期/期刊分析/期刊推荐/投稿前自审/审稿意见辅助/格式检查)。
|
||||
|
||||
## 审稿模块(review_service)约束 —— 必须遵守
|
||||
|
||||
1. **所有审稿相关新代码只允许写在 `review_service/` 目录下新增**。
|
||||
- 服务自身:FastAPI 独立进程,端口 8100(模式同 `paper_writer_service` 8097 / `bioflow_service` 8098)。
|
||||
- 环境变量前缀统一 `REVIEW_`;LLM 凭据复用 `DOCGEN_LLM_*` 裸键(回退链见 `review_service/app/services/llm.py`)。
|
||||
2. **BFF(`sdk/`)层只允许条件注册代理路由**:
|
||||
- 允许的全部改动 = `sdk/app/config.py` 增加 `review_enabled` / `review_service_url` 等开关键
|
||||
+ `sdk/app/main.py` 按开关 `include_router(review_proxy.router)`
|
||||
+ 新文件 `sdk/app/routers/review_proxy.py`(反代实现)。
|
||||
- **严禁**修改 sdk 下其他 router/service/db 的任何既有逻辑。
|
||||
3. **严禁修改现有 RAG 核心与既有服务代码**,包括但不限于:
|
||||
- `agent/`(qa 8091 / methodology 8092 / legacy-rag 8093)
|
||||
- `vstore/`(8094,审稿服务只经 HTTP 只读消费其 `/api/vstore/*` 接口)
|
||||
- `paper_writer_service/`、`bioflow_service/`、`bioflow_kernel/`
|
||||
- `ui/`(前端入口另行按 UI_SPEC 流程评审)
|
||||
- `knowledge/`、`kb-libraries/`、`tooling/`(依赖锁另议)
|
||||
4. **依赖纪律**:审稿服务只使用统一 venv(`tooling/requirements.lock`)中已有的库
|
||||
(fastapi / httpx / pydantic-settings 等)。如确需新增全局依赖,
|
||||
须先更新 `tooling/requirements.lock` 并经人工确认。
|
||||
5. **上游对接只读**:PDF 解析走 vstore 8094(`POST /api/vstore/ingest` +
|
||||
`GET/DELETE /api/vstore/documents/{id}`);LLM 走 DeepSeek OpenAI 兼容接口;
|
||||
后续期刊数据/期刊推荐等子功能同样以新 service + 新路由在 `review_service/` 内扩展。
|
||||
6. **format_check 例外**:format_check 允许用 pymupdf 本地解档获取版式元数据
|
||||
(字体/边距/行距/页数),因为 vstore 只回文本不返回版式信息。正文提取仍走 vstore。
|
||||
|
||||
## 目录速览(审稿模块)
|
||||
|
||||
```
|
||||
review_service/
|
||||
├── app/main.py # FastAPI 入口(/health + 挂载业务路由)
|
||||
├── app/config.py # pydantic-settings(REVIEW_ 前缀)
|
||||
├── app/routers/presubmit.py # POST /api/review/presubmit(自审稿报告)
|
||||
├── app/routers/format_check.py # POST /api/review/format-check(版式与规范检查)
|
||||
├── app/services/llm.py # DeepSeek httpx 封装(complete_text/complete_json)
|
||||
├── app/services/paper_parse.py # PDF → 纯文本(经 vstore 解析接口)
|
||||
├── app/services/format_check.py# 版式规则引擎(pymupdf/docx 本地解档 + 规则检查)
|
||||
├── restart.sh # 8100 重启脚本
|
||||
└── pytest.ini
|
||||
```
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
cd review_service && bash restart.sh # 默认 8100,venv 从 ../plantai-rag-new-venv 推导
|
||||
# BFF 开关:sdk/.env 加 REVIEW_ENABLED=1 后 bff_restart(反代 /api/review/*)
|
||||
```
|
||||
@@ -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 journals, presubmit
|
||||
from .routers import conferences, format_check, journal_directory, journals, presubmit
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -65,6 +65,9 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.include_router(presubmit.router)
|
||||
app.include_router(journals.router)
|
||||
app.include_router(format_check.router)
|
||||
app.include_router(conferences.router)
|
||||
app.include_router(journal_directory.router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""routers/conferences.py —— 会议投稿时间线(只读静态种子)。
|
||||
|
||||
- GET /api/review/conferences?domain=&ccf=&upcoming_days=180
|
||||
data/journals/conferences.json → 过滤/排序 → 列表 + meta。
|
||||
- 数据诚实性铁律:每条会议必须带 deadline_source ∈ {placeholder, official}、
|
||||
last_verified 与 deadline_confidence ∈ {high, medium, low},缺失/非法条目加载时
|
||||
跳过并记日志(宁缺毋造);本端点无任何生成日期的逻辑,日期只来自种子常量。
|
||||
v2 共 27/27 全 placeholder,meta.data_source_note 显式声明占位与置信度,
|
||||
由前端渲染「⚠ 日期待核验」及置信度提示。
|
||||
- 过滤语义:domain 对 domain_tags 精确匹配(大小写不敏感);ccf ∈ {A,B,C};
|
||||
upcoming_days=0 不过滤,>0 仅保留 deadline_full_paper ∈ [UTC now, now+N 天],
|
||||
缺失/不可解析截稿日期在过滤开启时排除。排序:deadline_full_paper 升序,
|
||||
缺失/不可解析恒排最后。
|
||||
- 只读无写接口;文件不可读 → ok=false + DATA_UNAVAILABLE 信封;参数非法 →
|
||||
VALIDATION 信封(均 HTTP 200,对齐本服务"错误信封永远 200"约定)。
|
||||
- 静态文件首次请求加载进内存(lru_cache),进程内 O(1);不触碰 SQLite/BFF。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review", tags=["review-conferences"])
|
||||
|
||||
# review_service/app/routers/ → parents[2] = review_service(对齐 format_check 写法)
|
||||
_CONFERENCES_PATH = (
|
||||
Path(__file__).resolve().parents[2] / "data" / "journals" / "conferences.json"
|
||||
)
|
||||
|
||||
_CCF_OK = frozenset({"A", "B", "C"})
|
||||
_REQUIRED = ("id", "name", "deadline_source", "last_verified", "deadline_confidence")
|
||||
_CONFIDENCE_OK = frozenset({"high", "medium", "low"})
|
||||
_SOURCE_NOTE = (
|
||||
"会议截稿/通知日期首版均为占位数据(deadline_source=placeholder),"
|
||||
"接入官方渠道前须人工核验;deadline_confidence 标注日期置信度"
|
||||
"(high=官方已公布/历年极稳定,medium=按历年规律推算,low=待核验);"
|
||||
"所有时间按 AoE (UTC-12) 时区。"
|
||||
)
|
||||
|
||||
|
||||
# ---- 请求/响应模型 ----
|
||||
|
||||
class ConferenceItem(BaseModel):
|
||||
"""会议公开字段(种子全字段白名单;未知键丢弃)。"""
|
||||
id: str
|
||||
name: str
|
||||
full_name: str = ""
|
||||
domain_tags: list[str] = []
|
||||
application_tags: list[str] = []
|
||||
ccf_rank: str | None = None
|
||||
deadline_abstract: str | None = None
|
||||
deadline_full_paper: str | None = None
|
||||
notification_date: str | None = None
|
||||
conference_date: str | None = None
|
||||
location: str | None = None
|
||||
official_url: str | None = None
|
||||
timezone_note: str | None = None
|
||||
deadline_source: str
|
||||
last_verified: str
|
||||
deadline_confidence: str = ""
|
||||
|
||||
|
||||
class ConferencesResponse(BaseModel):
|
||||
ok: bool
|
||||
items: list[ConferenceItem] = []
|
||||
meta: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _error(code: str, message: str) -> ConferencesResponse:
|
||||
return ConferencesResponse(
|
||||
ok=False, items=[], meta=None,
|
||||
error={"code": code, "message": message, "degraded": False},
|
||||
)
|
||||
|
||||
|
||||
# ---- 种子加载(首次调用读盘,进程内缓存)----
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_conferences() -> list[dict[str, Any]] | None:
|
||||
"""读 conferences.json → 合法会议列表;文件不可读 → None(DATA_UNAVAILABLE)。"""
|
||||
try:
|
||||
payload = json.loads(_CONFERENCES_PATH.read_text("utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("conferences: 种子文件不可读 %s: %s", _CONFERENCES_PATH, exc)
|
||||
return None
|
||||
raw = payload.get("conferences") if isinstance(payload, dict) else None
|
||||
if not isinstance(raw, list):
|
||||
logger.warning("conferences: 种子缺少 conferences 数组:%s", _CONFERENCES_PATH)
|
||||
return None
|
||||
items: list[dict[str, Any]] = []
|
||||
for conf in raw:
|
||||
if not isinstance(conf, dict):
|
||||
continue
|
||||
missing = [k for k in _REQUIRED if not conf.get(k)]
|
||||
if missing:
|
||||
logger.warning("conferences: 条目缺字段 %s(id=%s),跳过", missing, conf.get("id"))
|
||||
continue
|
||||
if conf.get("deadline_source") not in ("placeholder", "official"):
|
||||
logger.warning(
|
||||
"conferences: deadline_source 非法(id=%s),跳过", conf.get("id")
|
||||
)
|
||||
continue
|
||||
if str(conf.get("deadline_confidence") or "").strip().lower() not in _CONFIDENCE_OK:
|
||||
logger.warning(
|
||||
"conferences: deadline_confidence 非法(id=%s),跳过", conf.get("id")
|
||||
)
|
||||
continue
|
||||
items.append(conf)
|
||||
return items
|
||||
|
||||
|
||||
def _parse_deadline(value: Any) -> datetime | None:
|
||||
"""截稿时间 → aware datetime(naive 视为 UTC);非法 → None。"""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
|
||||
|
||||
|
||||
# ---- GET /api/review/conferences ----
|
||||
|
||||
@router.get("/conferences", response_model=ConferencesResponse)
|
||||
async def conferences_list(
|
||||
domain: str = "",
|
||||
ccf: str = "",
|
||||
upcoming_days: int = 180,
|
||||
) -> ConferencesResponse:
|
||||
"""只读:会议投稿时间线(无写/删/改接口)。
|
||||
|
||||
- ?domain= 对 domain_tags 精确匹配(大小写不敏感),空=不过滤;
|
||||
- ?ccf= ccf_rank ∈ A/B/C,空=不过滤,非法值 → VALIDATION 信封;
|
||||
- ?upcoming_days= int≥0,默认 180;0=不过滤,>0 仅保留截稿在
|
||||
[UTC now, now+N 天] 内的会议,负数 → VALIDATION 信封;
|
||||
- 排序按 deadline_full_paper 升序,缺失/不可解析恒排最后;
|
||||
- 空结果 → items=[] + meta.count=0(HTTP 200,不报错)。
|
||||
"""
|
||||
ccf_norm = (ccf or "").strip().upper()
|
||||
if ccf_norm and ccf_norm not in _CCF_OK:
|
||||
return _error("VALIDATION", f"不支持的 ccf={ccf!r},可选:A / B / C")
|
||||
if upcoming_days < 0:
|
||||
return _error(
|
||||
"VALIDATION", f"upcoming_days 必须 ≥ 0,收到 {upcoming_days}"
|
||||
)
|
||||
|
||||
all_items = _load_conferences()
|
||||
if all_items is None:
|
||||
return _error("DATA_UNAVAILABLE", "会议种子数据不可读,请联系管理员")
|
||||
|
||||
items = list(all_items)
|
||||
|
||||
dom = (domain or "").strip().casefold()
|
||||
if dom:
|
||||
items = [
|
||||
c for c in items
|
||||
if any(
|
||||
str(t).strip().casefold() == dom
|
||||
for t in (c.get("domain_tags") or [])
|
||||
)
|
||||
]
|
||||
if ccf_norm:
|
||||
items = [
|
||||
c for c in items
|
||||
if str(c.get("ccf_rank") or "").strip().upper() == ccf_norm
|
||||
]
|
||||
if upcoming_days > 0:
|
||||
now = datetime.now(timezone.utc)
|
||||
limit = now + timedelta(days=upcoming_days)
|
||||
items = [
|
||||
c for c in items
|
||||
if (dl := _parse_deadline(c.get("deadline_full_paper"))) is not None
|
||||
and now <= dl <= limit
|
||||
]
|
||||
|
||||
def _sort_key(c: dict[str, Any]) -> tuple[bool, datetime]:
|
||||
dl = _parse_deadline(c.get("deadline_full_paper"))
|
||||
if dl is None:
|
||||
return (True, datetime.max.replace(tzinfo=timezone.utc))
|
||||
return (False, dl)
|
||||
|
||||
items.sort(key=_sort_key)
|
||||
|
||||
out = [ConferenceItem(**c) for c in items]
|
||||
verified = max(
|
||||
(str(c.get("last_verified") or "") for c in all_items), default=""
|
||||
)
|
||||
meta: dict[str, Any] = {
|
||||
"count": len(out),
|
||||
"last_verified": verified or None,
|
||||
"data_source_note": _SOURCE_NOTE,
|
||||
"query": {
|
||||
"domain": (domain or "").strip(),
|
||||
"ccf": ccf_norm,
|
||||
"upcoming_days": upcoming_days,
|
||||
},
|
||||
}
|
||||
return ConferencesResponse(ok=True, items=out, meta=meta, error=None)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""routers/format_check.py —— 版式与规范检查(POST /api/review/format-check)。
|
||||
|
||||
纯规则引擎(services/format_check.py):PDF/Word 本地解档取版式元数据 +
|
||||
journals.json 规则集(format_rules_default + 每条 format_rules 带 rules_source)。
|
||||
无 LLM / 无 vstore,同步执行通常 <1s;恒 200 + {ok, checks, summary, meta|error} 信封。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import get_settings
|
||||
from ..services import format_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review", tags=["review-format"])
|
||||
|
||||
|
||||
class CheckItem(BaseModel):
|
||||
key: str
|
||||
name: str
|
||||
status: str # pass | warn | fail
|
||||
detail: str
|
||||
suggestion: str = ""
|
||||
|
||||
|
||||
class FormatCheckResponse(BaseModel):
|
||||
ok: bool
|
||||
checks: list[CheckItem] | None = None
|
||||
summary: dict[str, int] | None = None
|
||||
meta: dict[str, object] | None = None
|
||||
error: dict[str, object] | None = None
|
||||
|
||||
|
||||
def _error(code: str, message: str, *, degraded: bool = False) -> FormatCheckResponse:
|
||||
return FormatCheckResponse(
|
||||
ok=False, error={"code": code, "message": message, "degraded": degraded},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/format-check", response_model=FormatCheckResponse)
|
||||
async def format_check_endpoint(
|
||||
file: UploadFile = File(..., description="待检查的 PDF / Word(.docx) / LaTeX(.tex) 文档"),
|
||||
target_journal: str = Form("", description="目标期刊(可选,传 journals 的 id)"),
|
||||
) -> FormatCheckResponse:
|
||||
settings = get_settings()
|
||||
|
||||
filename = file.filename or ""
|
||||
low = filename.lower()
|
||||
if low.endswith(".pdf"):
|
||||
kind = "pdf"
|
||||
elif low.endswith(".docx"):
|
||||
kind = "docx"
|
||||
elif low.endswith(".tex"):
|
||||
kind = "tex"
|
||||
else:
|
||||
return _error("VALIDATION", "仅支持 PDF / Word(.docx)/ LaTeX(.tex)文件")
|
||||
|
||||
content = await file.read()
|
||||
if not content:
|
||||
return _error("VALIDATION", "上传文件为空")
|
||||
if len(content) > settings.max_pdf_bytes:
|
||||
return _error("VALIDATION", "上传文件超过 50MB 上限")
|
||||
|
||||
try:
|
||||
result = format_check.check_document(content, kind, (target_journal or "").strip())
|
||||
except format_check.FormatCheckError as exc:
|
||||
logger.warning("format-check: 解档失败:%s", exc)
|
||||
return _error("PARSE_FAILED", str(exc))
|
||||
|
||||
meta = dict(result.get("meta") or {})
|
||||
meta["filename"] = filename
|
||||
return FormatCheckResponse(
|
||||
ok=True,
|
||||
checks=[CheckItem(**c) for c in result["checks"]],
|
||||
summary=result["summary"],
|
||||
meta=meta,
|
||||
)
|
||||
@@ -0,0 +1,276 @@
|
||||
"""routers/journal_directory.py —— 期刊目录浏览(1056 本,只读分页)。
|
||||
|
||||
- GET /api/review/journals/directory:journal_directory 表(scripts/import_journals.py
|
||||
导入的 v4 目录:2025 中科院分区 + 2025 JIF(2026 年 JCR 发布)+ 年报 2025 版
|
||||
(中文统计年 2024)+ 官网网址/核对状态)→ 过滤/排序/分页 → 列表 + meta。
|
||||
- 与 GET /journals(journals.json 精选 5 本,供 FormatPanel 下拉/recommend 语义)物理
|
||||
分离(方案 A);本端点绝不读写 journals 表,recommend/journal_kb/journal_rank 零触碰。
|
||||
- 读路径只 SELECT;表由导入脚本拥有(db.py 零改动);表不存在 → data_imported=false
|
||||
优雅降级(HTTP 200 空列表)。
|
||||
|
||||
读时归一化(拍板细节 1-3,均在 API 层做,不动脚本/DB 原值):
|
||||
细节1 lang=zh → cas_partition 恒 null + cas_partition_note="不按中科院分区口径列示";
|
||||
细节2 jif_note 源文本映射(v4 值域已是用户可读文本,旧值映射保留兜底);
|
||||
细节3 缺值即矛盾清零:en 且 jif=null → jif_source_url=null;
|
||||
cas_partition=null → partition_source_url=null。
|
||||
(zh 的 IF 载体是 composite/comprehensive,其 jif_source_url 本恒 null。)
|
||||
决策2:响应不再下发"来源"文本字段(partition_source/jif_source/data_source_label
|
||||
已从 DirectoryItem 移除;jif_source_url/partition_source_url/data_source_url
|
||||
保留,供前端展开行链接)。新增 official_check_status / jcr_release_year。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..db import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review", tags=["review-directory"])
|
||||
|
||||
_LANGS = frozenset({"en", "zh"})
|
||||
# 排序列白名单(列名不可参数化 → 白名单防注入)
|
||||
_FIELDS = frozenset({
|
||||
"name",
|
||||
"jif",
|
||||
"composite_impact_factor",
|
||||
"comprehensive_impact_factor",
|
||||
"research_direction",
|
||||
"cas_partition",
|
||||
})
|
||||
_ORDERS = frozenset({"asc", "desc"})
|
||||
_PARTS = {"1": "1区", "2": "2区", "3": "3区", "4": "4区"}
|
||||
_MAX_SIZE = 200
|
||||
|
||||
# 细节2:源核对状态 → 用户可读 note
|
||||
_JIF_NOTE_MAP = {
|
||||
"源表标N/A": "未被2024 JCR收录",
|
||||
"所用年度来源未检得": "本次汇编未查到,建议查阅最新 JCR",
|
||||
}
|
||||
_ZH_PART_NOTE = "不按中科院分区口径列示"
|
||||
|
||||
_NOTE_EN = (
|
||||
"分区:2025年中科院公开表(多表比对,2026年起停止更新);"
|
||||
"JIF:2025年度指标(2026年JCR发布,两份公开汇编一致或已核对出版社官方指标);"
|
||||
"整理日期2026-10-10。空白=来源未给值≠0;研究方向/期刊标记/文章类型为导航标签,非官方评级。"
|
||||
)
|
||||
_NOTE_ZH = (
|
||||
"指标:国内自然科学期刊年报2025版(统计年2024,official,含复合/综合IF两口径);"
|
||||
"中文期刊不按中科院分区口径列示;国内IF与JIF统计源不同,不可混排。"
|
||||
)
|
||||
_UNIMPORTED_NOTE = "目录数据未导入(请先运行 scripts/import_journals.py)"
|
||||
|
||||
|
||||
# ---- 响应模型 ----
|
||||
|
||||
class DirectoryItem(BaseModel):
|
||||
"""目录公开字段(DB 全列白名单;imported_at 等内部列丢弃)。"""
|
||||
id: str
|
||||
lang: str
|
||||
name: str
|
||||
research_direction: str | None = None
|
||||
cas_partition: str | None = None
|
||||
cas_partition_note: str | None = None
|
||||
cas_partition_year: int | None = None
|
||||
jif: float | None = None
|
||||
jif_year: int | None = None
|
||||
jif_note: str | None = None
|
||||
jif_check_status: str | None = None
|
||||
composite_impact_factor: float | None = None
|
||||
comprehensive_impact_factor: float | None = None
|
||||
metric_year: int | None = None
|
||||
metric_report_year: int | None = None
|
||||
metric_caliber: str | None = None
|
||||
annual_report_page: int | None = None
|
||||
top_flag: str | None = None
|
||||
publisher_series: str | None = None
|
||||
journal_mark: str | None = None
|
||||
article_type: str | None = None
|
||||
issn: str | None = None
|
||||
eissn: str | None = None
|
||||
jcr_disciplines: list[str] = []
|
||||
supplement_note: str | None = None
|
||||
evidence_note: str | None = None
|
||||
official_url: str | None = None
|
||||
official_check_status: str | None = None
|
||||
jcr_release_year: int | None = None
|
||||
jif_source_url: str | None = None
|
||||
partition_source_url: str | None = None
|
||||
data_source_url: str | None = None
|
||||
|
||||
|
||||
class DirectoryResponse(BaseModel):
|
||||
ok: bool
|
||||
items: list[DirectoryItem] = []
|
||||
meta: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _error(code: str, message: str) -> DirectoryResponse:
|
||||
return DirectoryResponse(
|
||||
ok=False, items=[], meta=None,
|
||||
error={"code": code, "message": message, "degraded": False},
|
||||
)
|
||||
|
||||
|
||||
# ---- 读时归一化(拍板细节 1-3)----
|
||||
|
||||
def _present(raw: dict[str, Any], lang: str) -> dict[str, Any]:
|
||||
d = dict(raw)
|
||||
# jcr_disciplines:JSON 数组字符串 → list
|
||||
jcr = d.get("jcr_disciplines")
|
||||
if jcr:
|
||||
try:
|
||||
parsed = json.loads(jcr)
|
||||
d["jcr_disciplines"] = parsed if isinstance(parsed, list) else []
|
||||
except (TypeError, ValueError):
|
||||
d["jcr_disciplines"] = []
|
||||
else:
|
||||
d["jcr_disciplines"] = []
|
||||
|
||||
# 细节2:jif_note 源文本 → 用户可读
|
||||
note = d.get("jif_note")
|
||||
if note in _JIF_NOTE_MAP:
|
||||
d["jif_note"] = _JIF_NOTE_MAP[note]
|
||||
|
||||
# 细节1:zh 分区归一(DB 原值"不按此口径列示"只留在库里)
|
||||
if lang == "zh":
|
||||
d["cas_partition"] = None
|
||||
d["cas_partition_note"] = _ZH_PART_NOTE
|
||||
|
||||
# 细节3:缺值 → 来源链接清零(消除 null 值却挂来源链接的矛盾;
|
||||
# "来源"文本字段已按决策2 移出响应,此处只清 *_url)
|
||||
if lang == "en":
|
||||
if d.get("jif") is None:
|
||||
d["jif_source_url"] = None
|
||||
if d.get("cas_partition") is None:
|
||||
d["partition_source_url"] = None
|
||||
return d
|
||||
|
||||
|
||||
def _escape_like(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
# ---- GET /api/review/journals/directory ----
|
||||
|
||||
@router.get("/journals/directory", response_model=DirectoryResponse)
|
||||
async def journals_directory(
|
||||
lang: str = "en",
|
||||
domain: str = "",
|
||||
cas_partition: str = "",
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
field: str = "",
|
||||
order: str = "",
|
||||
) -> DirectoryResponse:
|
||||
"""只读:期刊目录分页浏览(无写接口)。
|
||||
|
||||
- ?lang=en|zh(默认 en;**强制单语,保证国际/中文永不混排**);
|
||||
- ?domain= 研究方向精确匹配;?cas_partition=1|2|3|4(仅 lang=en,zh 忽略);
|
||||
- ?q= 期刊名模糊(ASCII 大小写不敏感);?page≥1 ?size 1-200(默认 50);
|
||||
- ?field= 排序白名单(name/jif/composite_impact_factor/
|
||||
comprehensive_impact_factor/research_direction/cas_partition),?order=asc|desc;
|
||||
未传 field → 默认排序 en=jif 降序 / zh=复合IF 降序(NULL 恒排最后);
|
||||
- 参数非法 → VALIDATION 信封;表不存在 → data_imported=false 空列表(HTTP 200)。
|
||||
"""
|
||||
lang_n = (lang or "").strip().lower()
|
||||
if lang_n not in _LANGS:
|
||||
return _error("VALIDATION", f"不支持的 lang={lang!r},可选:en / zh")
|
||||
if page < 1:
|
||||
return _error("VALIDATION", f"page 必须 ≥ 1,收到 {page}")
|
||||
if size < 1 or size > _MAX_SIZE:
|
||||
return _error("VALIDATION", f"size 必须在 1-{_MAX_SIZE},收到 {size}")
|
||||
field_n = (field or "").strip()
|
||||
if field_n and field_n not in _FIELDS:
|
||||
return _error(
|
||||
"VALIDATION",
|
||||
f"不支持的 field={field!r},可选:{', '.join(sorted(_FIELDS))}",
|
||||
)
|
||||
order_n = (order or "").strip().lower()
|
||||
if order_n and order_n not in _ORDERS:
|
||||
return _error("VALIDATION", f"不支持的 order={order!r},可选:asc / desc")
|
||||
part_q = (cas_partition or "").strip()
|
||||
if part_q:
|
||||
part_q = _PARTS.get(part_q, _PARTS.get(part_q.rstrip("区").strip(), ""))
|
||||
if not part_q:
|
||||
return _error(
|
||||
"VALIDATION",
|
||||
f"不支持的 cas_partition={cas_partition!r},可选:1 / 2 / 3 / 4",
|
||||
)
|
||||
|
||||
# 过滤条件(白名单拼接;参数全部占位符)
|
||||
where = ["lang = ?"]
|
||||
params: list[Any] = [lang_n]
|
||||
domain_n = (domain or "").strip()
|
||||
if domain_n:
|
||||
where.append("research_direction = ?")
|
||||
params.append(domain_n)
|
||||
if part_q and lang_n == "en": # zh 不分区:忽略(meta 回显)
|
||||
where.append("cas_partition = ?")
|
||||
params.append(part_q)
|
||||
q_n = (q or "").strip()
|
||||
if q_n:
|
||||
where.append("name LIKE ? ESCAPE '\\'")
|
||||
params.append(f"%{_escape_like(q_n)}%")
|
||||
where_sql = " AND ".join(where)
|
||||
|
||||
# 排序(列名出自白名单;NULL 恒排最后)
|
||||
if field_n:
|
||||
dir_sql = "DESC" if order_n == "desc" else "ASC"
|
||||
order_sql = f"{field_n} IS NULL, {field_n} {dir_sql}"
|
||||
else:
|
||||
col = "jif" if lang_n == "en" else "composite_impact_factor"
|
||||
order_sql = f"{col} IS NULL, {col} DESC"
|
||||
|
||||
try:
|
||||
total_row = await db.fetchone(
|
||||
f"SELECT COUNT(*) AS c FROM journal_directory WHERE {where_sql}", params # noqa: S608
|
||||
)
|
||||
total = int(total_row["c"]) if total_row else 0
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM journal_directory WHERE {where_sql} " # noqa: S608
|
||||
f"ORDER BY {order_sql} LIMIT ? OFFSET ?",
|
||||
[*params, size, (page - 1) * size],
|
||||
)
|
||||
dom_rows = await db.fetchall(
|
||||
"SELECT research_direction AS d, COUNT(*) AS c FROM journal_directory "
|
||||
"WHERE lang = ? AND research_direction IS NOT NULL "
|
||||
"GROUP BY 1 ORDER BY c DESC, d ASC",
|
||||
[lang_n],
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
# 表不存在(脚本未跑)→ 优雅降级,HTTP 200
|
||||
logger.warning("journal_directory: 表不可用(目录未导入?): %s", exc)
|
||||
return DirectoryResponse(
|
||||
ok=True, items=[],
|
||||
meta={
|
||||
"total": 0, "page": page, "size": size, "lang": lang_n,
|
||||
"domain": domain_n, "cas_partition": part_q, "q": q_n,
|
||||
"domains": [], "data_source_note": _UNIMPORTED_NOTE,
|
||||
"data_imported": False,
|
||||
},
|
||||
error=None,
|
||||
)
|
||||
|
||||
items = [DirectoryItem(**_present(dict(r), lang_n)) for r in rows]
|
||||
meta: dict[str, Any] = {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"lang": lang_n,
|
||||
"domain": domain_n,
|
||||
"cas_partition": part_q if lang_n == "en" else "",
|
||||
"q": q_n,
|
||||
"domains": [r["d"] for r in dom_rows if r["d"]],
|
||||
"data_source_note": _NOTE_EN if lang_n == "en" else _NOTE_ZH,
|
||||
"data_imported": True,
|
||||
}
|
||||
return DirectoryResponse(ok=True, items=items, meta=meta, error=None)
|
||||
@@ -13,7 +13,10 @@ VALIDATION 错误信封(HTTP 200,与本服务其余端点一致)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
@@ -69,7 +72,11 @@ class RecommendResponse(BaseModel):
|
||||
# ---- GET /api/review/journals(只读列表)----
|
||||
|
||||
class JournalBrief(BaseModel):
|
||||
"""期刊公开字段(journals 表全列;不含 vstore_doc_id 内部映射)。"""
|
||||
"""期刊公开字段(journals 表全列;不含 vstore_doc_id 内部映射)。
|
||||
|
||||
标签/分区 6 字段不进 SQLite:GET 时按 id 从 journals.json 内存缓存合并;
|
||||
JSON 缺该 id → 空数组/null 默认值。
|
||||
"""
|
||||
id: str
|
||||
name: str
|
||||
scope_desc: str = ""
|
||||
@@ -79,6 +86,12 @@ class JournalBrief(BaseModel):
|
||||
submission_deadline: str | None = None
|
||||
official_url: str | None = None
|
||||
last_updated: str = ""
|
||||
scope_tags: list[str] = []
|
||||
application_tags: list[str] = []
|
||||
cas_partition: str | None = None
|
||||
jcr_quartile: str | None = None
|
||||
partition_source: str | None = None
|
||||
cross_domain: list[str] = []
|
||||
|
||||
|
||||
class JournalsListResponse(BaseModel):
|
||||
@@ -99,8 +112,55 @@ _SORT_FIELDS = frozenset({
|
||||
})
|
||||
_DATA_SOURCE_NOTE = (
|
||||
"IF/审稿周期为公开近似值,acceptance_rate 为 null 表示暂无公开数据,需人工核验。"
|
||||
"CAS/JCR 分区为首版占位数据,需核验。"
|
||||
)
|
||||
|
||||
# journals.json 新增字段(标签/分区;刻意不进 SQLite,见 C-2 方案)
|
||||
_TAG_FIELDS = (
|
||||
"scope_tags",
|
||||
"application_tags",
|
||||
"cas_partition",
|
||||
"jcr_quartile",
|
||||
"partition_source",
|
||||
"cross_domain",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_tag_extras() -> dict[str, dict[str, Any]]:
|
||||
"""journals.json 标签/分区字段 → {id: {6 新字段}}。
|
||||
|
||||
首次请求读盘后进程内缓存,后续 GET O(1) 按 id 合并(不做每请求磁盘 IO)。
|
||||
JSON 缺该 id → 合并时用空数组/null 默认;文件不可读 → 空 dict 降级(不 500)。
|
||||
"""
|
||||
seed = get_settings().journals_seed_abs
|
||||
try:
|
||||
payload = json.loads(Path(seed).read_text("utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("journals: 标签扩展文件不可读,合并降级为默认值:%s", exc)
|
||||
return {}
|
||||
raw = payload.get("journals") if isinstance(payload, dict) else None
|
||||
if not isinstance(raw, list):
|
||||
return {}
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for j in raw:
|
||||
if isinstance(j, dict) and j.get("id"):
|
||||
out[str(j["id"])] = {k: j.get(k) for k in _TAG_FIELDS}
|
||||
return out
|
||||
|
||||
|
||||
def _tag_fields(extra: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""缓存条目 → JournalBrief 关键字参数(JSON 缺该 id → 空数组/null 默认)。"""
|
||||
ex = extra or {}
|
||||
return {
|
||||
"scope_tags": list(ex.get("scope_tags") or []),
|
||||
"application_tags": list(ex.get("application_tags") or []),
|
||||
"cas_partition": ex.get("cas_partition"),
|
||||
"jcr_quartile": ex.get("jcr_quartile"),
|
||||
"partition_source": ex.get("partition_source"),
|
||||
"cross_domain": list(ex.get("cross_domain") or []),
|
||||
}
|
||||
|
||||
|
||||
def _sort_rows(rows: list[dict[str, Any]], field: str, order: str) -> list[dict[str, Any]]:
|
||||
"""按白名单字段排序;NULL 恒排最后;次序不稳定时按 db 默认序(稳定排序保留)。"""
|
||||
@@ -142,6 +202,7 @@ async def journals_list(
|
||||
rows = await list_journals()
|
||||
rows = _sort_rows(rows, col, ord_norm)
|
||||
|
||||
tag_extras = _load_tag_extras()
|
||||
items = [
|
||||
JournalBrief(
|
||||
id=str(r.get("id") or ""),
|
||||
@@ -153,6 +214,7 @@ async def journals_list(
|
||||
submission_deadline=r.get("submission_deadline"),
|
||||
official_url=r.get("official_url"),
|
||||
last_updated=str(r.get("last_updated") or ""),
|
||||
**_tag_fields(tag_extras.get(str(r.get("id") or ""))),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@@ -0,0 +1,788 @@
|
||||
"""services/format_check.py —— 版式与规范规则引擎(⑤ 论文排版与规范)。
|
||||
|
||||
纯规则、无 LLM、无 vstore、同步执行(通常 <1s):
|
||||
- PDF → pymupdf 本地解档(AGENTS.md 约束 6 例外:vstore 只回文本,字体/边距/
|
||||
行距/页数等版式元数据拿不到;正文提取仍走 vstore,本服务不碰正文语义);
|
||||
- Word → python-docx(页边距/行距精确,页数取 docProps/app.xml 的 <Pages>);
|
||||
- 规则集 → data/journals/journals.json:
|
||||
顶层 format_rules_default(通用默认值,rules_source="default")
|
||||
每条目可选 format_rules(按期刊覆盖,必须带 rules_source: placeholder|official|default)
|
||||
⚠ 诚实原则:不知道期刊真实投稿指南 → 该条只写 {"rules_source": "placeholder"},
|
||||
所有具体数字回退 default 通用值,绝不编造期刊专属数字。
|
||||
|
||||
输出:{checks[{name,key,status,detail,suggestion}], summary{pass,warn,fail}, meta{...}}
|
||||
status ∈ pass|warn|fail。参考文献样式宽容化:仅"明确不符"(如 [J] 标引但规则要求 APA)
|
||||
才 fail,"疑似但不确定/检测不到"一律 warn,避免误伤。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import statistics
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import fitz # pymupdf(venv 已装 1.28.x)
|
||||
from docx import Document
|
||||
from docx.shared import Length
|
||||
|
||||
# review_service/app/services/ → parents[2] = review_service
|
||||
_JOURNALS_PATH = Path(__file__).resolve().parents[2] / "data" / "journals" / "journals.json"
|
||||
|
||||
_MAX_SCAN_PAGES = 80 # 版式扫描页数上限(正文/字体/边距;稿件通常远小于此)
|
||||
_MAX_REFS_SCAN = 40 # 从文末向前找参考文献标题的页数窗口
|
||||
_MAX_PARAS = 8000 # docx 段落扫描上限
|
||||
_PT_TO_MM = 25.4 / 72.0
|
||||
_EMU_TO_MM = 1.0 / 36000.0
|
||||
|
||||
_HEADING_RE = re.compile(
|
||||
r"^\s*(references|bibliography|literature cited|works cited|参考文献|文献)\s*[::]?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FIG_RE = re.compile(r"(?im)^\s*(figure|fig\.?|图)\s*\d+")
|
||||
_TAB_RE = re.compile(r"(?im)^\s*(table|表)\s*\d+")
|
||||
_GB_TAG_RE = re.compile(r"\[[A-Z]{1,3}\d*\]") # [J] [M] [D] [C] …(GB/T 7714 标引)
|
||||
_PAREN_YEAR_RE = re.compile(r"\((?:19|20)\d{2}\)") # (2024) 作者-年份制
|
||||
_BRACKET_NUM_RE = re.compile(r"^\[\d+\]") # [1] 编号制
|
||||
_DOI_RE = re.compile(r"doi:?\.?org|doi:\s*1\.", re.IGNORECASE)
|
||||
|
||||
_STYLE_LABEL = {
|
||||
"gb7714": "GB/T 7714",
|
||||
"author_year": "作者-年份制(类 APA)",
|
||||
"numbered": "编号制(顺序编码)",
|
||||
"unknown": "无法确定",
|
||||
}
|
||||
|
||||
|
||||
class FormatCheckError(Exception):
|
||||
"""解档/规则加载失败(路由器转 PARSE_FAILED)。"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 规则集加载
|
||||
|
||||
def load_ruleset(target_journal: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""返回 (rules, ruleset_meta)。target_journal 传 id 或 name 均可;未知 → default。"""
|
||||
try:
|
||||
data = json.loads(_JOURNALS_PATH.read_text("utf-8"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise FormatCheckError(f"规则集加载失败:{exc}") from exc
|
||||
|
||||
default = data.get("format_rules_default") or {}
|
||||
jid = (target_journal or "").strip()
|
||||
entry = None
|
||||
if jid:
|
||||
for j in data.get("journals") or []:
|
||||
if j.get("id") == jid or j.get("name") == jid:
|
||||
entry = j
|
||||
break
|
||||
|
||||
if entry is None:
|
||||
rules = dict(default)
|
||||
meta = {
|
||||
"journal_id": None,
|
||||
"journal_name": None,
|
||||
"ruleset_source": "default",
|
||||
"rules_source": str(default.get("rules_source", "default")),
|
||||
}
|
||||
else:
|
||||
journal_rules = entry.get("format_rules") or {}
|
||||
rules = {**default, **journal_rules}
|
||||
meta = {
|
||||
"journal_id": entry.get("id"),
|
||||
"journal_name": entry.get("name"),
|
||||
"ruleset_source": "journal",
|
||||
# 占位规则(无官方核验)→ 前端黄字提示
|
||||
"rules_source": str(journal_rules.get("rules_source", "placeholder")),
|
||||
}
|
||||
rules.setdefault("rules_source", "default")
|
||||
return rules, meta
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- PDF 解档(pymupdf)
|
||||
|
||||
def _norm_font(name: str) -> str:
|
||||
n = (name or "").split("+")[-1].strip()
|
||||
return n or "unknown"
|
||||
|
||||
|
||||
def extract_profile_pdf(data: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
doc = fitz.open(stream=data, filetype="pdf")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise FormatCheckError(f"PDF 解档失败:{exc}") from exc
|
||||
if getattr(doc, "needs_pass", False):
|
||||
doc.close()
|
||||
raise FormatCheckError("PDF 已加密,无法解析")
|
||||
|
||||
pages_total = doc.page_count
|
||||
scan = min(pages_total, _MAX_SCAN_PAGES)
|
||||
margin_rows: list[tuple[float, float, float, float]] = [] # t r b l (mm)
|
||||
font_w: dict[str, int] = {}
|
||||
size_w: dict[float, int] = {}
|
||||
ratios: list[float] = []
|
||||
img_ids: set[int] = set()
|
||||
fig_caps = tab_caps = 0
|
||||
|
||||
for pno in range(scan):
|
||||
page = doc[pno]
|
||||
rect = page.rect
|
||||
blocks = page.get_text("blocks")
|
||||
content = [b for b in blocks if b[4].strip()]
|
||||
if content:
|
||||
left = min(b[0] for b in content)
|
||||
top = min(b[1] for b in content)
|
||||
right = rect.width - max(b[2] for b in content)
|
||||
bottom = rect.height - max(b[3] for b in content)
|
||||
margin_rows.append(
|
||||
(
|
||||
max(0.0, top) * _PT_TO_MM,
|
||||
max(0.0, right) * _PT_TO_MM,
|
||||
max(0.0, bottom) * _PT_TO_MM,
|
||||
max(0.0, left) * _PT_TO_MM,
|
||||
)
|
||||
)
|
||||
|
||||
for img in page.get_images(full=True):
|
||||
img_ids.add(img[0])
|
||||
|
||||
text = page.get_text()
|
||||
fig_caps += len(_FIG_RE.findall(text))
|
||||
tab_caps += len(_TAB_RE.findall(text))
|
||||
|
||||
d = page.get_text("dict")
|
||||
for block in d.get("blocks", []):
|
||||
if block.get("type") != 0:
|
||||
continue
|
||||
line_meta: list[tuple[float, float]] = [] # (基线 y, 字号)
|
||||
for line in block.get("lines", []):
|
||||
spans = [s for s in line.get("spans", []) if s.get("text", "").strip()]
|
||||
if not spans:
|
||||
continue
|
||||
row_size = max(s.get("size", 0.0) for s in spans)
|
||||
if row_size <= 0:
|
||||
continue
|
||||
for s in spans:
|
||||
size = round(float(s.get("size", 0.0)), 1)
|
||||
chars = len(s.get("text", ""))
|
||||
if size > 0:
|
||||
norm = _norm_font(s.get("font", ""))
|
||||
font_w[norm] = font_w.get(norm, 0) + chars
|
||||
size_w[size] = size_w.get(size, 0) + chars
|
||||
# 行距基准用 span.origin 基线 y,不用 bbox 顶缘:bbox 顶随字形高低
|
||||
# 漂移,表格/图注等同基线并列文本会产出假性小 delta,混入中位数会把
|
||||
# 行距拉成 0.1x 的不可能值。
|
||||
origin = spans[0].get("origin")
|
||||
line_meta.append((origin[1] if origin else line["bbox"][1], row_size))
|
||||
for i in range(1, len(line_meta)):
|
||||
delta = line_meta[i][0] - line_meta[i - 1][0]
|
||||
size = line_meta[i][1]
|
||||
# 行距倍数 = 相邻行基线 y 差 / 当前行字号;
|
||||
# 过滤物理不可能值:<0.5(行重叠/同基线并列表格)、>4.0(异常结构)
|
||||
ratio = delta / size
|
||||
if 0.5 <= ratio <= 4.0:
|
||||
ratios.append(ratio)
|
||||
|
||||
refs_text, refs_found = _find_refs_pdf(doc, pages_total)
|
||||
doc.close()
|
||||
|
||||
if margin_rows:
|
||||
med = [statistics.median(col) for col in zip(*margin_rows)]
|
||||
margins = {"top": med[0], "right": med[1], "bottom": med[2], "left": med[3]}
|
||||
else:
|
||||
margins = None
|
||||
|
||||
return {
|
||||
"source_kind": "pdf",
|
||||
"pages": pages_total,
|
||||
"margins_mm": margins,
|
||||
"font_weights": font_w,
|
||||
"size_weights": size_w,
|
||||
"line_spacing_median": statistics.median(ratios) if ratios else None,
|
||||
"figures": max(len(img_ids), fig_caps),
|
||||
"tables": tab_caps,
|
||||
"refs_text": refs_text,
|
||||
"refs_found": refs_found,
|
||||
}
|
||||
|
||||
|
||||
def _find_refs_pdf(doc: "fitz.Document", pages_total: int) -> tuple[str | None, bool]:
|
||||
"""从文末向前找参考文献标题行;找到 → 标题行至文末文本;找不到 → (None, False)。"""
|
||||
buf: list[str] = []
|
||||
start = max(0, pages_total - _MAX_REFS_SCAN)
|
||||
for pno in range(pages_total - 1, start - 1, -1):
|
||||
page_text = doc[pno].get_text()
|
||||
lines = page_text.splitlines()
|
||||
for idx in range(len(lines) - 1, -1, -1):
|
||||
if _HEADING_RE.match(lines[idx].strip()):
|
||||
tail = "\n".join(lines[idx + 1:])
|
||||
joined = "\n".join([tail] + buf).strip()
|
||||
return (joined if len(joined) >= 50 else None), bool(joined)
|
||||
buf.insert(0, page_text)
|
||||
return None, False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Word 解档(python-docx)
|
||||
|
||||
def _docx_app_pages(data: bytes) -> int | None:
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
xml = zf.read("docProps/app.xml").decode("utf-8", "ignore")
|
||||
m = re.search(r"<Pages>(\d+)</Pages>", xml)
|
||||
return int(m.group(1)) if m else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def extract_profile_docx(data: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
doc = Document(io.BytesIO(data))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise FormatCheckError(f"Word 文件解析失败:{exc}") from exc
|
||||
|
||||
side_lists: dict[str, list[float]] = {"top": [], "right": [], "bottom": [], "left": []}
|
||||
for sec in doc.sections:
|
||||
for k, v in (
|
||||
("top", sec.top_margin),
|
||||
("right", sec.right_margin),
|
||||
("bottom", sec.bottom_margin),
|
||||
("left", sec.left_margin),
|
||||
):
|
||||
if v is not None:
|
||||
side_lists[k].append(float(v) * _EMU_TO_MM)
|
||||
margins = None
|
||||
if all(side_lists.values()):
|
||||
margins = {k: statistics.median(v) for k, v in side_lists.items()}
|
||||
|
||||
try:
|
||||
normal = doc.styles["Normal"].font
|
||||
default_name = normal.name
|
||||
default_size = float(normal.size.pt) if normal.size is not None else None
|
||||
except Exception: # noqa: BLE001
|
||||
default_name, default_size = None, None
|
||||
|
||||
font_w: dict[str, int] = {}
|
||||
size_w: dict[float, int] = {}
|
||||
ratios: list[float] = []
|
||||
fig_caps = tab_caps = 0
|
||||
paras = doc.paragraphs
|
||||
scan_count = min(len(paras), _MAX_PARAS)
|
||||
|
||||
for i in range(scan_count):
|
||||
para = paras[i]
|
||||
text = para.text
|
||||
if not text.strip():
|
||||
continue
|
||||
ls = para.paragraph_format.line_spacing
|
||||
if isinstance(ls, Length):
|
||||
# 精确行距(exactly)以 EMU 存储,Length 是 int 子类——直接当倍数会得出
|
||||
# 十万级的假值;用正文默认字号换算成倍数
|
||||
if default_size:
|
||||
r = float(ls.pt) / default_size
|
||||
if 0.5 <= r <= 4.0:
|
||||
ratios.append(r)
|
||||
elif isinstance(ls, (int, float)) and not isinstance(ls, bool) and ls > 0:
|
||||
r = float(ls) # multiple 行距本身就是倍数
|
||||
if 0.5 <= r <= 4.0:
|
||||
ratios.append(r)
|
||||
if _FIG_RE.match(text):
|
||||
fig_caps += 1
|
||||
elif _TAB_RE.match(text):
|
||||
tab_caps += 1
|
||||
for run in para.runs:
|
||||
if not run.text:
|
||||
continue
|
||||
name = run.font.name or default_name or "unknown"
|
||||
pt = run.font.size.pt if run.font.size is not None else default_size
|
||||
chars = len(run.text)
|
||||
font_w[str(name)] = font_w.get(str(name), 0) + chars
|
||||
if pt:
|
||||
size_w[round(float(pt), 1)] = size_w.get(round(float(pt), 1), 0) + chars
|
||||
|
||||
# 参考文献节:标题段落之后全部文本
|
||||
refs_text, refs_found = None, False
|
||||
for i, para in enumerate(paras):
|
||||
if _HEADING_RE.match((para.text or "").strip()):
|
||||
tail = "\n".join(p.text for p in paras[i + 1:] if (p.text or "").strip())
|
||||
if len(tail) >= 50:
|
||||
refs_text, refs_found = tail, True
|
||||
break
|
||||
|
||||
return {
|
||||
"source_kind": "docx",
|
||||
"pages": _docx_app_pages(data), # Word 未记录 → None(检查项降级为 warn)
|
||||
"margins_mm": margins,
|
||||
"font_weights": font_w,
|
||||
"size_weights": size_w,
|
||||
"line_spacing_median": statistics.median(ratios) if ratios else None,
|
||||
"figures": max(len(doc.inline_shapes), fig_caps),
|
||||
"tables": max(len(doc.tables), tab_caps),
|
||||
"refs_text": refs_text,
|
||||
"refs_found": refs_found,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- LaTeX 解档(正则+状态机,零新依赖)
|
||||
#
|
||||
# 决策前提:只做"解析 + 检查 + 建议代码片段",不自动改写 LaTeX(用户可能有自定义宏,
|
||||
# 自动改写会引错;LaTeX 应由用户自己编辑)。不引入 pylatexenc/texsoup。
|
||||
|
||||
# 会相互覆盖的字体类宏包:≥2 个同时引入 → warn(times + mathptmx 类冲突)
|
||||
_TEX_FONT_PKGS = {"times", "mathptmx", "newtx", "newpx", "txfonts", "pxfonts", "mathpazo"}
|
||||
|
||||
|
||||
def _strip_tex_comments(src: str) -> str:
|
||||
r"""去行内 % 注释(r-string,避免 \% 被解释为转义序列)。"""
|
||||
return re.sub(r"(?<!\\)%.*", "", src)
|
||||
|
||||
|
||||
def _parse_geometry(src: str) -> dict[str, float] | None:
|
||||
"""\\usepackage[...]{geometry} 或 \\geometry{...} → {边: mm}(可缺边,检查端容缺)。"""
|
||||
m = re.search(r"\\usepackage\s*\[([^\]]*)\]\s*\{\s*geometry\s*\}", src)
|
||||
if not m:
|
||||
m = re.search(r"\\geometry\s*\{([^}]*)\}", src)
|
||||
if not m:
|
||||
return None
|
||||
opts = m.group(1)
|
||||
|
||||
def to_mm(val: str) -> float | None:
|
||||
mm = re.match(r"\s*([\d.]+)\s*(mm|cm|in|pt)?\s*$", val)
|
||||
if not mm:
|
||||
return None
|
||||
x = float(mm.group(1))
|
||||
unit = mm.group(2) or "pt" # geometry 裸数字按 TeX 惯例为 pt
|
||||
return {"mm": x, "cm": x * 10, "in": x * 25.4, "pt": x * 25.4 / 72.0}[unit]
|
||||
|
||||
kv = {}
|
||||
for pair in opts.split(","):
|
||||
if "=" in pair:
|
||||
k, v = pair.split("=", 1)
|
||||
kv[k.strip().lower()] = v.strip()
|
||||
out: dict[str, float] = {}
|
||||
if "margin" in kv:
|
||||
v = to_mm(kv["margin"])
|
||||
if v is not None:
|
||||
return {"top": v, "right": v, "bottom": v, "left": v}
|
||||
for k in ("top", "right", "bottom", "left"):
|
||||
if k in kv:
|
||||
v = to_mm(kv[k])
|
||||
if v is not None:
|
||||
out[k] = v
|
||||
if "inner" in kv:
|
||||
v = to_mm(kv["inner"])
|
||||
if v is not None:
|
||||
out.setdefault("left", v)
|
||||
if "outer" in kv:
|
||||
v = to_mm(kv["outer"])
|
||||
if v is not None:
|
||||
out.setdefault("right", v)
|
||||
return out or None
|
||||
|
||||
|
||||
def extract_tex_profile(data: bytes) -> dict[str, Any]:
|
||||
"""正则 + 轻量状态机解析 .tex 源文件 → 与 DocProfile 同构(+tex 扩展字段)。"""
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = data.decode("latin-1", errors="replace")
|
||||
src = _strip_tex_comments(text)
|
||||
|
||||
# \documentclass[10pt,a4paper]{article}
|
||||
m_cls = re.search(r"\\documentclass\s*(?:\[[^\]]*\])?\s*\{\s*([\w*]+)\s*\}", src)
|
||||
cls = m_cls.group(1) if m_cls else None
|
||||
m_opt = re.search(r"\\documentclass\s*\[([^\]]*)\]", src)
|
||||
cls_opts = m_opt.group(1) if m_opt else ""
|
||||
m_pt = re.search(r"(\d+)\s*pt", cls_opts)
|
||||
# 标准类未写选项时 LaTeX 默认 10pt;无 documentclass → None(检查项降级 warn)
|
||||
fontsize = int(m_pt.group(1)) if m_pt else (10 if cls else None)
|
||||
paper = "a4paper" if "a4paper" in cls_opts else ("letterpaper" if "letterpaper" in cls_opts else None)
|
||||
|
||||
# \usepackage 列表(支持 {a,b} 逗号多包)
|
||||
packages: list[str] = []
|
||||
for m in re.finditer(r"\\usepackage\s*(?:\[[^\]]*\])?\s*\{\s*([^}]+?)\s*\}", src):
|
||||
packages += [p.strip().lower() for p in m.group(1).split(",") if p.strip()]
|
||||
|
||||
# 行距:\linespread{...} 或 \setstretch{...}(值直接按"行距倍数"口径评估——
|
||||
# 按步骤4 规格:低于 min_line_spacing 即 fail;LaTeX 实际倍数还含 baselinestretch
|
||||
# 的字号比,本引擎取显式设置值做保守判定)
|
||||
line_spread: float | None = None
|
||||
m = re.search(r"\\linespread\s*\{\s*([\d.]+)\s*\}", src)
|
||||
if not m:
|
||||
m = re.search(r"\\setstretch\s*\{\s*([\d.]+)\s*\}", src)
|
||||
if m:
|
||||
line_spread = float(m.group(1))
|
||||
|
||||
# 页边距(geometry)
|
||||
margins = _parse_geometry(src)
|
||||
|
||||
# 计数
|
||||
figures = len(re.findall(r"\\begin\s*\{\s*figure\*?\s*\}", src))
|
||||
tables = len(re.findall(r"\\begin\s*\{\s*table\*?\s*\}", src))
|
||||
cites = len(re.findall(r"\\cite\w*\s*\{", src))
|
||||
|
||||
# 参考文献:\bibliography{...}(BibTeX)/ \printbibliography(biblatex)/ thebibliography
|
||||
has_bibtex = bool(re.search(r"\\bibliography\s*\{", src))
|
||||
has_printbib = bool(re.search(r"\\printbibliography", src))
|
||||
has_thebib = bool(re.search(r"\\begin\s*\{\s*thebibliography\s*\}", src))
|
||||
has_bibstyle = bool(re.search(r"\\bibliographystyle\s*\{", src)) # BibTeX 必需;biblatex 不需要
|
||||
has_bib = has_bibtex or has_printbib or has_thebib
|
||||
refs_text: str | None = None
|
||||
m_bib = re.search(r"\\begin\s*\{\s*thebibliography\s*\}[\s\S]*?\\end\s*\{\s*thebibliography\s*\}", src)
|
||||
if m_bib:
|
||||
refs_text = m_bib.group(0)
|
||||
|
||||
# 宏表(\newcommand{\xxx} / \renewcommand)——用于解析准确性(识别自定义宏)
|
||||
macros = re.findall(r"\\(?:new|re)command\s*\{\s*\\(\w+)\s*\}", src)
|
||||
|
||||
# 匿名评审命令探测(规则集 anonymous:true 时检查)
|
||||
m_anon = re.search(r"\\(?:anonymi[sz]e|anon\b|hideauthor|ifdoubleblind)", src, re.IGNORECASE)
|
||||
|
||||
return {
|
||||
"source_kind": "tex",
|
||||
"pages": None, # LaTeX 源文件页数需编译后才有 → 检查项降级 warn(宽容化)
|
||||
"margins_mm": margins,
|
||||
"font_weights": {},
|
||||
"size_weights": {float(fontsize): 100} if fontsize else {},
|
||||
"line_spacing_median": line_spread,
|
||||
"figures": figures,
|
||||
"tables": tables,
|
||||
"refs_text": refs_text,
|
||||
"refs_found": has_bib,
|
||||
"tex": {
|
||||
"class": cls,
|
||||
"fontsize_pt": fontsize,
|
||||
"paper": paper,
|
||||
"packages": packages,
|
||||
"cites": cites,
|
||||
"macros": macros,
|
||||
"anon_cmd": m_anon.group(0) if m_anon else None,
|
||||
"bib_bibtex": has_bibtex,
|
||||
"bib_style": has_bibstyle,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 参考文献样式检测
|
||||
|
||||
def detect_refs_style(text: str | None) -> tuple[str, str]:
|
||||
"""宽容化检测:返回 (detected, sample_line)。含糊 → unknown(后续只 warn)。"""
|
||||
if not text:
|
||||
return "unknown", ""
|
||||
lines = [l.strip() for l in text.splitlines() if l.strip()][:40]
|
||||
if not lines:
|
||||
return "unknown", ""
|
||||
n = len(lines)
|
||||
gb = sum(1 for l in lines if _GB_TAG_RE.search(l))
|
||||
year = sum(1 for l in lines if _PAREN_YEAR_RE.search(l))
|
||||
num = sum(1 for l in lines if _BRACKET_NUM_RE.match(l))
|
||||
doi = sum(1 for l in lines if _DOI_RE.search(l))
|
||||
sample = lines[0][:80]
|
||||
|
||||
if gb / n >= 0.4:
|
||||
return "gb7714", sample
|
||||
if num / n >= 0.4 and year / n < 0.3:
|
||||
return "numbered", sample
|
||||
if year / n >= 0.3 or doi / n >= 0.3:
|
||||
return "author_year", sample
|
||||
return "unknown", sample
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 检查项构建
|
||||
|
||||
def _chk(key: str, name: str, status: str, detail: str, suggestion: str = "") -> dict[str, str]:
|
||||
return {"key": key, "name": name, "status": status, "detail": detail, "suggestion": suggestion}
|
||||
|
||||
|
||||
def _check_pages(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
limit = int(rules.get("max_pages", 10))
|
||||
pages = profile.get("pages")
|
||||
kind = profile.get("source_kind") or "doc"
|
||||
if pages is None:
|
||||
# 宽容化:Word/LaTeX 页数缺失绝不 fail
|
||||
if kind == "tex":
|
||||
return _chk(
|
||||
"pages", "页数", "warn",
|
||||
f"LaTeX 源无法直接统计页数(规则上限 {limit} 页),需编译后核对",
|
||||
"编译后确认(如 xelatex main.tex)总页数 ≤ 上限",
|
||||
)
|
||||
return _chk(
|
||||
"pages", "页数", "warn",
|
||||
"Word 未记录页数,请自行核对",
|
||||
f"在 Word 中按 Ctrl+End 查看总页数,或导出 PDF 后重查(规则上限 {limit} 页)",
|
||||
)
|
||||
if pages <= limit:
|
||||
return _chk("pages", "页数", "pass", f"共 {pages} 页,规则上限 {limit} 页")
|
||||
return _chk(
|
||||
"pages", "页数", "fail", f"共 {pages} 页,超出规则上限 {limit} 页",
|
||||
f"压缩至 ≤{limit} 页,或将方法/图表移入补充材料",
|
||||
)
|
||||
|
||||
|
||||
def _check_fonts(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
min_font = float(rules.get("min_font_pt", 9))
|
||||
# LaTeX:字号来自 \documentclass 选项(10/11/12pt,粒度粗)
|
||||
if profile.get("source_kind") == "tex":
|
||||
tex = profile.get("tex") or {}
|
||||
cls = tex.get("class") or "documentclass"
|
||||
fs = tex.get("fontsize_pt")
|
||||
pkgs = tex.get("packages") or []
|
||||
font_pkgs = sorted(p for p in pkgs if p in _TEX_FONT_PKGS or p in ("helvet", "courier", "charter", "fourier"))
|
||||
detail = (
|
||||
f"\\documentclass 选项字号 {fs}pt(类 {cls});"
|
||||
f"字体宏包:{'、'.join(font_pkgs) if font_pkgs else '未引入'}"
|
||||
+ (f";纸张 {tex['paper']}" if tex.get("paper") else "")
|
||||
)
|
||||
if fs is None:
|
||||
return _chk("fonts", "字体字号", "warn", detail + "\n建议:\n \\documentclass[11pt]{article}", "补充 \\documentclass 声明")
|
||||
if fs < min_font:
|
||||
# LaTeX 粒度粗 → 按步骤4 规格降为 warn(非 fail)
|
||||
return _chk(
|
||||
"fonts", "字体字号", "warn", detail + f"\n建议:\n \\documentclass[11pt]{{{cls}}}",
|
||||
f"正文字号低于下限 {min_font:.0f}pt,请改用 11pt/12pt 选项",
|
||||
)
|
||||
return _chk("fonts", "字体字号", "pass", detail)
|
||||
|
||||
size_w: dict[float, int] = profile.get("size_weights") or {}
|
||||
font_w: dict[str, int] = profile.get("font_weights") or {}
|
||||
if not size_w:
|
||||
return _chk("fonts", "字体字号", "warn", "未能提取字体信息", "确认文档含可选中文本(扫描件无法检查字体)")
|
||||
min_font = float(rules.get("min_font_pt", 9))
|
||||
max_fonts = int(rules.get("max_fonts", 6))
|
||||
total = sum(size_w.values())
|
||||
body_pt = max(size_w, key=size_w.get)
|
||||
body_ratio = int(size_w[body_pt] / total * 100)
|
||||
distinct = len(font_w)
|
||||
top_fonts = sorted(font_w.items(), key=lambda kv: -kv[1])[:3]
|
||||
top_text = "、".join(f"{k}({v}字)" for k, v in top_fonts)
|
||||
detail = f"主要字号 {body_pt}pt(约 {body_ratio}%),共 {distinct} 种字体;高频字体:{top_text}"
|
||||
if body_pt < min_font:
|
||||
return _chk(
|
||||
"fonts", "字体字号", "fail", detail,
|
||||
f"将正文字号提高至 ≥{min_font}pt(当前主字号 {body_pt}pt)",
|
||||
)
|
||||
if distinct > max_fonts:
|
||||
return _chk(
|
||||
"fonts", "字体字号", "warn", detail,
|
||||
f"字体种类偏多({distinct} > {max_fonts}),建议全文统一为 1–2 种正文字体",
|
||||
)
|
||||
return _chk("fonts", "字体字号", "pass", detail)
|
||||
|
||||
|
||||
def _check_margins(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
limit = float(rules.get("min_margin_mm", 15))
|
||||
margins = profile.get("margins_mm")
|
||||
# LaTeX:页边距来自 geometry;低于下限按步骤4 规格给 warn(非 fail),附建议片段
|
||||
if profile.get("source_kind") == "tex":
|
||||
if not margins:
|
||||
return _chk(
|
||||
"margins", "页边距", "warn",
|
||||
"未通过 geometry 设置页边距(按类默认评估)"
|
||||
+ f"\n建议:\n \\usepackage[margin=25mm]{{geometry}}",
|
||||
"在导言区显式声明页边距",
|
||||
)
|
||||
lo = min(margins.values())
|
||||
detail = f"geometry 页边距最小 {lo:.0f}mm(" + "、".join(f"{k}{v:.0f}" for k, v in margins.items()) + f"),规则下限 {limit:.0f}mm"
|
||||
if lo < limit:
|
||||
return _chk(
|
||||
"margins", "页边距", "warn",
|
||||
detail + f"\n建议:\n \\usepackage[margin={limit:.0f}mm]{{geometry}}",
|
||||
f"调大页边距至 ≥{limit:.0f}mm",
|
||||
)
|
||||
return _chk("margins", "页边距", "pass", detail)
|
||||
if not margins:
|
||||
return _chk("margins", "页边距", "warn", "未能提取页边距信息", "检查文档是否含可定位文本")
|
||||
vals = [margins.get(k) for k in ("top", "right", "bottom", "left")]
|
||||
vals = [v for v in vals if v is not None]
|
||||
if not vals:
|
||||
return _chk("margins", "页边距", "warn", "未能提取页边距信息", "检查文档是否含可定位文本")
|
||||
t, r = margins.get("top"), margins.get("right")
|
||||
b, l = margins.get("bottom"), margins.get("left")
|
||||
lo = min(vals)
|
||||
sides = "、".join(
|
||||
f"{name}{v:.0f}" for name, v in (("上", t), ("右", r), ("下", b), ("左", l)) if v is not None
|
||||
)
|
||||
detail = f"四边最小 {lo:.0f}mm({sides}),规则下限 {limit:.0f}mm"
|
||||
if lo >= limit:
|
||||
return _chk("margins", "页边距", "pass", detail)
|
||||
if lo >= limit * 0.8:
|
||||
return _chk("margins", "页边距", "warn", detail, f"将页边距调大至 ≥{limit:.0f}mm")
|
||||
return _chk("margins", "页边距", "fail", detail, f"页边距过小,请调至 ≥{limit:.0f}mm(版式设置)")
|
||||
|
||||
|
||||
def _check_spacing(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
lo = float(rules.get("min_line_spacing", 1.15))
|
||||
ratio = profile.get("line_spacing_median")
|
||||
# LaTeX:\linespread/\setstretch 显式设置值;低于下限按步骤4 规格直接 fail
|
||||
if profile.get("source_kind") == "tex":
|
||||
if ratio is None:
|
||||
return _chk(
|
||||
"spacing", "行距", "pass",
|
||||
"未显式设置 \\linespread/\\setstretch,LaTeX 类默认约 1.2 倍(如 10pt→12pt baselineskip)",
|
||||
)
|
||||
detail = f"\\linespread/\\setstretch = {ratio:.2f},规则下限 {lo:.2f}"
|
||||
if ratio < lo:
|
||||
return _chk(
|
||||
"spacing", "行距", "fail",
|
||||
detail + f"\n建议:\n \\linespread{{{lo:.2f}}} % 或 \\setstretch{{{lo:.2f}}}(导言区)",
|
||||
"在导言区调大行距设置",
|
||||
)
|
||||
return _chk("spacing", "行距", "pass", detail)
|
||||
if ratio is None:
|
||||
return _chk("spacing", "行距", "warn", "未能提取行距信息", "确认段落使用固定/多倍行距设置")
|
||||
lo = float(rules.get("min_line_spacing", 1.15))
|
||||
detail = f"正文中位行距约 {ratio:.2f} 倍,规则下限 {lo:.2f} 倍"
|
||||
if ratio >= lo:
|
||||
return _chk("spacing", "行距", "pass", detail)
|
||||
if ratio >= lo * 0.9:
|
||||
return _chk("spacing", "行距", "warn", detail, f"段落行距设为 ≥{lo:.2f} 倍(如 1.5 倍行距)")
|
||||
return _chk("spacing", "行距", "fail", detail, f"行距过密,请设为 ≥{lo:.2f} 倍行距")
|
||||
|
||||
|
||||
def _check_figures(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
figures = int(profile.get("figures") or 0)
|
||||
tables = int(profile.get("tables") or 0)
|
||||
min_f = int(rules.get("min_figures", 1))
|
||||
detail = f"检测到图 {figures} 张、表 {tables} 个(图注/表注正则 + 内嵌对象启发式)"
|
||||
if figures >= min_f:
|
||||
return _chk("figures", "图表数量", "pass", detail)
|
||||
# 启发式检测不可靠 → 低于阈值只 warn 不 fail
|
||||
return _chk(
|
||||
"figures", "图表数量", "warn", detail,
|
||||
f"常规研究论文建议 ≥{min_f} 图;若为综述/短文可忽略(本项为启发式检测)",
|
||||
)
|
||||
|
||||
|
||||
# 仅"明确不符"才 fail 的 (expected, detected) 组合(宽容化:其余含糊组合一律 warn)
|
||||
_REF_FAIL_PAIRS = {
|
||||
("apa", "gb7714"), # 找到 [J] 标引但规则要求 APA
|
||||
("gb7714", "author_year"), # 无 [J] 标引的作者-年份制,但规则要求 GB/T 7714
|
||||
("apa", "numbered"), # 编号制引文但规则要求 APA
|
||||
}
|
||||
|
||||
|
||||
def _check_refs(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
expected = str(rules.get("refs_style", "apa")).lower()
|
||||
exp_label = _STYLE_LABEL.get(expected, expected)
|
||||
if not profile.get("refs_found"):
|
||||
return _chk(
|
||||
"refs", "参考文献格式", "warn",
|
||||
"未找到参考文献节(References/参考文献 / \\bibliography / thebibliography)",
|
||||
"确认存在参考文献声明后重查",
|
||||
)
|
||||
if profile.get("source_kind") == "tex":
|
||||
tex = profile.get("tex") or {}
|
||||
# BibTeX:\bibliography 存在但缺 \bibliographystyle → warn(样式引擎未知)
|
||||
if tex.get("bib_bibtex") and not tex.get("bib_style"):
|
||||
return _chk(
|
||||
"refs", "参考文献格式", "warn",
|
||||
f"规则要求 {exp_label};检测到 \\bibliography 但缺少 \\bibliographystyle(BibTeX 无法确定引用样式)"
|
||||
+ "\n建议:\n \\bibliographystyle{plain} % 按期刊/模板指定实际样式",
|
||||
"在 \\bibliography 前补 \\bibliographystyle",
|
||||
)
|
||||
if not profile.get("refs_text"):
|
||||
# 条目在 .bbl/编译产物,源文件无文本 → 宽容化只 warn
|
||||
return _chk(
|
||||
"refs", "参考文献格式", "warn",
|
||||
f"规则要求 {exp_label};检测到参考文献声明(条目由编译生成,源文件无条目文本)",
|
||||
"编译后核对参考文献条目样式",
|
||||
)
|
||||
detected, sample = detect_refs_style(profile.get("refs_text"))
|
||||
det_label = _STYLE_LABEL[detected]
|
||||
detail = f"规则要求 {exp_label};检测到 {det_label};样例:{sample}"
|
||||
# 宽容化:仅"明确不符"(_REF_FAIL_PAIRS)才 fail;含糊/不确定一律 warn
|
||||
if (expected, detected) in _REF_FAIL_PAIRS:
|
||||
return _chk("refs", "参考文献格式", "fail", detail, f"按 {exp_label} 调整全部文献条目(可用文献管理工具一键转换)")
|
||||
if detected == "unknown":
|
||||
return _chk("refs", "参考文献格式", "warn", detail, f"请人工核对是否符合 {exp_label}")
|
||||
if expected == "gb7714" and detected == "numbered":
|
||||
return _chk("refs", "参考文献格式", "warn", detail, "GB/T 7714 顺序编码制也用编号,需人工确认条目含 [J] 等类型标引")
|
||||
if expected == "author-year-journal" and detected in ("gb7714", "numbered"):
|
||||
return _chk("refs", "参考文献格式", "warn", detail, f"请对照期刊投稿指南核对是否符合 {exp_label}")
|
||||
return _chk("refs", "参考文献格式", "pass", detail)
|
||||
|
||||
|
||||
def _check_font_packages(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
"""LaTeX 特有:多个相互覆盖的字体宏包(times + mathptmx 类)→ warn。"""
|
||||
pkgs = set((profile.get("tex") or {}).get("packages") or [])
|
||||
clash = sorted(pkgs & _TEX_FONT_PKGS)
|
||||
if len(clash) >= 2:
|
||||
detail = (
|
||||
f"检测到多个相互覆盖的字体宏包:{'、'.join(clash)}(后加载者覆盖前者,易产生冲突)"
|
||||
+ "\n建议:\n %只保留一套字体方案,例如:\n \\usepackage{newtx} % newtxtext + newtxmath 一体"
|
||||
)
|
||||
return _chk("fontpkgs", "字体包兼容", "warn", detail, "移除重复/冲突的字体宏包,只保留一套")
|
||||
return _chk(
|
||||
"fontpkgs", "字体包兼容", "pass",
|
||||
"字体宏包:" + ("、".join(clash) if clash else "未引入冲突类字体包"),
|
||||
)
|
||||
|
||||
|
||||
def _check_anonymous(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
|
||||
"""LaTeX 特有且规则集 anonymous:true 时才出:源文件需含匿名处理命令。"""
|
||||
tex = profile.get("tex") or {}
|
||||
cmd = tex.get("anon_cmd")
|
||||
if cmd:
|
||||
return _chk("anonymous", "匿名评审", "pass", f"检测到匿名处理命令 {cmd}")
|
||||
return _chk(
|
||||
"anonymous", "匿名评审", "warn",
|
||||
"规则要求匿名评审,源文件未检测到匿名处理命令"
|
||||
+ "\n建议:\n %隐去作者/致谢/自引,或定义匿名宏:\n \\newcommand{\\anon}[1]{[ANON]}",
|
||||
"按期刊要求对作者信息做匿名处理",
|
||||
)
|
||||
|
||||
|
||||
def build_checks(profile: dict[str, Any], rules: dict[str, Any]) -> list[dict[str, str]]:
|
||||
checks = [
|
||||
_check_pages(profile, rules),
|
||||
_check_fonts(profile, rules),
|
||||
_check_margins(profile, rules),
|
||||
_check_spacing(profile, rules),
|
||||
]
|
||||
if profile.get("source_kind") == "tex":
|
||||
checks.append(_check_font_packages(profile, rules))
|
||||
checks.append(_check_figures(profile, rules))
|
||||
checks.append(_check_refs(profile, rules))
|
||||
if profile.get("source_kind") == "tex" and rules.get("anonymous"):
|
||||
checks.append(_check_anonymous(profile, rules))
|
||||
return checks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 入口
|
||||
|
||||
def check_document(data: bytes, kind: str, target_journal: str) -> dict[str, Any]:
|
||||
"""kind ∈ pdf|docx|tex。返回 {checks, summary, meta}(filename 由路由器补入 meta)。"""
|
||||
if kind == "pdf":
|
||||
profile = extract_profile_pdf(data)
|
||||
elif kind == "docx":
|
||||
profile = extract_profile_docx(data)
|
||||
elif kind == "tex":
|
||||
profile = extract_tex_profile(data)
|
||||
else:
|
||||
raise FormatCheckError(f"不支持的文件类型:{kind}")
|
||||
|
||||
rules, ruleset_meta = load_ruleset(target_journal)
|
||||
checks = build_checks(profile, rules)
|
||||
summary = {"pass": 0, "warn": 0, "fail": 0}
|
||||
for c in checks:
|
||||
summary[c["status"]] = summary.get(c["status"], 0) + 1
|
||||
|
||||
detected, _ = detect_refs_style(profile.get("refs_text")) if profile.get("refs_found") else ("unknown", "")
|
||||
meta = {
|
||||
**ruleset_meta,
|
||||
"file_type": kind,
|
||||
"pages": profile.get("pages"),
|
||||
"refs_style_expected": str(rules.get("refs_style", "apa")).lower(),
|
||||
"refs_style_detected": detected,
|
||||
}
|
||||
if kind == "tex":
|
||||
tex = profile.get("tex") or {}
|
||||
meta["tex"] = {
|
||||
"class": tex.get("class"),
|
||||
"fontsize_pt": tex.get("fontsize_pt"),
|
||||
"paper": tex.get("paper"),
|
||||
"packages": tex.get("packages"),
|
||||
"cites": tex.get("cites"),
|
||||
"macros": (tex.get("macros") or [])[:10],
|
||||
}
|
||||
return {"checks": checks, "summary": summary, "meta": meta}
|
||||
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"version": 2,
|
||||
"updated": "2026-10-10",
|
||||
"notes": "会议种子数据 v2。deadline_source=placeholder 表示日期为首版占位数据(按各会议公开周期窗口/历年规律填写,未经官方渠道逐条核验),接入官方数据源前须人工核验;严禁以 AI 推测日期替代核验。deadline_confidence 标注日期置信度:high=官方已公布或历年极稳定;medium=按历年规律区间推算;low=新一届尚未公告或历届波动大,待核验。ccf_rank 按 CCF 推荐目录(第七版,2026-03 发布)标注,非目录内会议为 null(含全部生物信息/植物类会议)。timezone_note 默认 AoE (UTC-12)。首版不做自动同步。",
|
||||
"conferences": [
|
||||
{
|
||||
"id": "naacl-2027",
|
||||
"name": "NAACL 2027",
|
||||
"full_name": "North American Chapter of the Association for Computational Linguistics",
|
||||
"domain_tags": ["AI/ML", "自然语言处理"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "B",
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2026-10-12T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-06-01 ~ 2027-06-05",
|
||||
"location": "San Francisco, California, USA",
|
||||
"official_url": "https://www.naacl.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "coling-2027",
|
||||
"name": "COLING 2027",
|
||||
"full_name": "International Conference on Computational Linguistics",
|
||||
"domain_tags": ["AI/ML", "自然语言处理"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "B",
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2026-10-12T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-05-09 ~ 2027-05-14",
|
||||
"location": "Macau SAR, China",
|
||||
"official_url": null,
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "www-2027",
|
||||
"name": "WWW 2027",
|
||||
"full_name": "The Web Conference (ACM WWW)",
|
||||
"domain_tags": ["AI/ML", "数据挖掘"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2026-10-18T23:59:59+00:00",
|
||||
"deadline_full_paper": "2026-10-25T23:59:59+00:00",
|
||||
"notification_date": "2027-01-04",
|
||||
"conference_date": "2027-05-10 ~ 2027-05-14",
|
||||
"location": "Dublin, Ireland",
|
||||
"official_url": "https://www2027.thewebconf.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "recomb-2027",
|
||||
"name": "RECOMB 2027",
|
||||
"full_name": "International Conference on Research in Computational Molecular Biology",
|
||||
"domain_tags": ["生物信息学"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2026-11-06T23:59:59+00:00",
|
||||
"deadline_full_paper": "2026-11-13T23:59:59+00:00",
|
||||
"notification_date": "2027-02-01",
|
||||
"conference_date": "2027-04-25 ~ 2027-04-28",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://recomb.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "cvpr-2027",
|
||||
"name": "CVPR 2027",
|
||||
"full_name": "IEEE/CVF Conference on Computer Vision and Pattern Recognition",
|
||||
"domain_tags": ["AI/ML", "计算机视觉"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2026-11-10T23:59:59+00:00",
|
||||
"deadline_full_paper": "2026-11-16T23:59:59+00:00",
|
||||
"notification_date": "2027-02-25",
|
||||
"conference_date": "2027-06-20 ~ 2027-06-25",
|
||||
"location": "Seattle, USA",
|
||||
"official_url": "https://cvpr.thecvf.com/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "wsdm-2027",
|
||||
"name": "WSDM 2027",
|
||||
"full_name": "International Conference on Web Search and Data Mining",
|
||||
"domain_tags": ["AI/ML", "数据挖掘"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "B",
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2026-11-17T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-02-15 ~ 2027-02-19",
|
||||
"location": "Hong Kong SAR, China",
|
||||
"official_url": "https://www.wsdm-conference.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "pag-2027",
|
||||
"name": "PAG 2027",
|
||||
"full_name": "Plant & Animal Genome Conference",
|
||||
"domain_tags": ["植物/农学", "基因组学"],
|
||||
"application_tags": ["种业", "分子育种"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2026-11-30T23:59:59+00:00",
|
||||
"deadline_full_paper": "2026-12-15T23:59:59+00:00",
|
||||
"notification_date": "2027-01-05",
|
||||
"conference_date": "2027-01-08 ~ 2027-01-12",
|
||||
"location": "San Diego, USA",
|
||||
"official_url": "https://www.intlpag.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "acl-2027",
|
||||
"name": "ACL 2027",
|
||||
"full_name": "Annual Meeting of the Association for Computational Linguistics",
|
||||
"domain_tags": ["AI/ML", "自然语言处理"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2027-01-04T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-08-20 ~ 2027-08-22",
|
||||
"location": "Kyoto, Japan",
|
||||
"official_url": "https://2027.aclweb.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "plantbio-2027",
|
||||
"name": "Plant Biology 2027",
|
||||
"full_name": "ASPB Annual Meeting — Plant Biology",
|
||||
"domain_tags": ["植物/农学", "分子生物学"],
|
||||
"application_tags": ["分子育种"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-01-08T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-01-08T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-07(待官方公布)",
|
||||
"location": "Albuquerque, New Mexico, USA",
|
||||
"official_url": "https://plantbiology.aspb.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "ijcai-2027",
|
||||
"name": "IJCAI 2027",
|
||||
"full_name": "International Joint Conference on Artificial Intelligence",
|
||||
"domain_tags": ["AI/ML"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "B",
|
||||
"deadline_abstract": "2027-01-11T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-01-18T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-08-07 ~ 2027-08-17",
|
||||
"location": "Kyoto, Japan / Hengqin, China",
|
||||
"official_url": "https://www.ijcai.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "sigir-2027",
|
||||
"name": "SIGIR 2027",
|
||||
"full_name": "ACM SIGIR Conference on Research and Development in Information Retrieval",
|
||||
"domain_tags": ["AI/ML", "信息检索"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2027-01-14T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-01-21T23:59:59+00:00",
|
||||
"notification_date": "2027-04-05",
|
||||
"conference_date": "2027-07-20 ~ 2027-07-24",
|
||||
"location": "San Jose, California, USA",
|
||||
"official_url": "https://sigir2027.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "high"
|
||||
},
|
||||
{
|
||||
"id": "icml-2027",
|
||||
"name": "ICML 2027",
|
||||
"full_name": "International Conference on Machine Learning",
|
||||
"domain_tags": ["AI/ML", "深度学习"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2027-01-15T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-01-22T23:59:59+00:00",
|
||||
"notification_date": "2027-05-01",
|
||||
"conference_date": "2027-07-19 ~ 2027-07-25",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://icml.cc/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "chil-2027",
|
||||
"name": "CHIL 2027",
|
||||
"full_name": "ACM Conference on Health, Inference, and Learning",
|
||||
"domain_tags": ["AI/ML", "健康医疗"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-01-15T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-01-29T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-06(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://dl.acm.org/conference/chil",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "kdd-2027",
|
||||
"name": "KDD 2027 (Cycle 2)",
|
||||
"full_name": "ACM SIGKDD Conference on Knowledge Discovery and Data Mining",
|
||||
"domain_tags": ["AI/ML", "数据挖掘"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2027-01-31T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-02-07T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-08-01 ~ 2027-08-05",
|
||||
"location": "San Jose, California, USA",
|
||||
"official_url": "https://kdd2027.kdd.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "ismb-eccb-2027",
|
||||
"name": "ISMB/ECCB 2027",
|
||||
"full_name": "Intelligent Systems for Molecular Biology / European Conference on Computational Biology",
|
||||
"domain_tags": ["生物信息学"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-02-12T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-02-19T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-07-18 ~ 2027-07-22",
|
||||
"location": "Copenhagen, Denmark",
|
||||
"official_url": "https://www.iscb.org/ismbeccb2027/home",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "ipmbc-2027",
|
||||
"name": "IPMB 2027",
|
||||
"full_name": "International Congress on Plant Molecular Biology (14th)",
|
||||
"domain_tags": ["植物/农学", "分子生物学"],
|
||||
"application_tags": ["分子育种"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-02-15T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-02-22T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-07(待官方公布)",
|
||||
"location": "Wuhan, China",
|
||||
"official_url": "https://www.ipmb2027.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "uai-2027",
|
||||
"name": "UAI 2027",
|
||||
"full_name": "Conference on Uncertainty in Artificial Intelligence",
|
||||
"domain_tags": ["AI/ML"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "B",
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2027-02-24T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-08-16 ~ 2027-08-20",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://www.auai.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "miccai-2027",
|
||||
"name": "MICCAI 2027",
|
||||
"full_name": "International Conference on Medical Image Computing and Computer-Assisted Intervention",
|
||||
"domain_tags": ["AI/ML", "医学影像"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-02-11T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-02-25T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-09-26 ~ 2027-10-01",
|
||||
"location": "Auckland, New Zealand",
|
||||
"official_url": "https://miccai.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "iccv-2027",
|
||||
"name": "ICCV 2027",
|
||||
"full_name": "IEEE/CVF International Conference on Computer Vision",
|
||||
"domain_tags": ["AI/ML", "计算机视觉"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2027-02-26T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-03-05T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-10-02 ~ 2027-10-08",
|
||||
"location": "Hong Kong SAR, China",
|
||||
"official_url": null,
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "bosc-2027",
|
||||
"name": "BOSC 2027",
|
||||
"full_name": "Bioinformatics Open Source Conference",
|
||||
"domain_tags": ["生物信息学"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2027-03-26T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-07-18 ~ 2027-07-22",
|
||||
"location": "Copenhagen, Denmark",
|
||||
"official_url": "https://openbioinformatics.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "acm-bcb-2027",
|
||||
"name": "ACM-BCB 2027",
|
||||
"full_name": "ACM Conference on Bioinformatics, Computational Biology and Health Informatics",
|
||||
"domain_tags": ["生物信息学", "AI/ML"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-04-16T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-04-23T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-08(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://acm-bcb.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "ieee-bibm-2027",
|
||||
"name": "IEEE BIBM 2027",
|
||||
"full_name": "IEEE International Conference on Bioinformatics and Bioengineering",
|
||||
"domain_tags": ["生物信息学"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": "2027-05-07T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-05-14T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-11(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": null,
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "emnlp-2027",
|
||||
"name": "EMNLP 2027",
|
||||
"full_name": "Conference on Empirical Methods in Natural Language Processing",
|
||||
"domain_tags": ["AI/ML", "自然语言处理"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "B",
|
||||
"deadline_abstract": "2027-05-14T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-05-21T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-11(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": null,
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "neurips-2027",
|
||||
"name": "NeurIPS 2027",
|
||||
"full_name": "Conference on Neural Information Processing Systems",
|
||||
"domain_tags": ["AI/ML", "深度学习"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2027-05-14T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-05-21T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-12(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://neurips.cc/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "psb-2028",
|
||||
"name": "PSB 2028",
|
||||
"full_name": "Pacific Symposium on Biocomputing",
|
||||
"domain_tags": ["生物信息学"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2027-07-01T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2028-01(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://psb.stanford.edu/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "pag-europe-2027",
|
||||
"name": "PAG Europe 2027",
|
||||
"full_name": "Plant & Animal Genome Conference Europe",
|
||||
"domain_tags": ["植物/农学", "基因组学"],
|
||||
"application_tags": ["种业", "分子育种"],
|
||||
"ccf_rank": null,
|
||||
"deadline_abstract": null,
|
||||
"deadline_full_paper": "2027-07-09T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2027-10(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": null,
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "low"
|
||||
},
|
||||
{
|
||||
"id": "aaai-2028",
|
||||
"name": "AAAI 2028",
|
||||
"full_name": "AAAI Conference on Artificial Intelligence",
|
||||
"domain_tags": ["AI/ML"],
|
||||
"application_tags": ["交叉学科"],
|
||||
"ccf_rank": "A",
|
||||
"deadline_abstract": "2027-07-20T23:59:59+00:00",
|
||||
"deadline_full_paper": "2027-07-27T23:59:59+00:00",
|
||||
"notification_date": null,
|
||||
"conference_date": "2028-02(待官方公布)",
|
||||
"location": "TBD(待官方公布)",
|
||||
"official_url": "https://aaai.org/",
|
||||
"timezone_note": "AoE (UTC-12)",
|
||||
"deadline_source": "placeholder",
|
||||
"last_verified": "2026-10-10",
|
||||
"deadline_confidence": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
journals directory 导入对账报告
|
||||
生成时间: 2026-10-10T15:37:11
|
||||
源文件: /home/xzq/5090/teamWorkspace/wjq/PlantAI-RAG-new-latest/PlantAI-rag-new/review_service/data/journals/source_v4.xlsx(193386 bytes)
|
||||
阶段: 完成
|
||||
|
||||
=== 计数 ===
|
||||
读取有效行: 国际 779 / 中文 277 / 合计 1056
|
||||
成功: 1056 跳过(名称空): 0 失败: 0
|
||||
写入后 count(*): en=779 zh=277 total=1056
|
||||
期望: en=779 zh=277 total=1056 → ✓ 相符
|
||||
|
||||
=== 分区分布(国际)===
|
||||
1区 233 / 2区 214 / 3区 181 / 4区 134 / 空白 17
|
||||
分区空白(→null 无note): 17
|
||||
分区未检得(→null + note): 0
|
||||
|
||||
=== JIF 缺失 13 条(jif=null + note=核对状态)===
|
||||
- eLife [2025 JIF未检得/未给值]
|
||||
- Heliyon [2025 JIF未检得/未给值]
|
||||
- Australian Journal of Crop Science [2025 JIF未检得/未给值]
|
||||
- International Journal of Agriculture and Biology [2025 JIF未检得/未给值]
|
||||
- Journal of Agriculture and Rural Development in the Tropics and Subtropics [2025 JIF未检得/未给值]
|
||||
- Fruits [2025 JIF未检得/未给值]
|
||||
- RANGELAND JOURNAL [2025 JIF未检得/未给值]
|
||||
- Science of The Total Environment [2025 JIF未检得/未给值]
|
||||
- Chemosphere [2025 JIF未检得/未给值]
|
||||
- Environmental Science and Pollution Research [2025 JIF未检得/未给值]
|
||||
- Bioengineered [2025 JIF未检得/未给值]
|
||||
- Cell Biomaterials [2025 JIF未检得/未给值]
|
||||
- National Science Open [2025 JIF未检得/未给值]
|
||||
|
||||
=== 中文复合IF 缺失 0 条 ===
|
||||
|
||||
=== 分区未检得 期刊名 ===
|
||||
(无——源表以空白表示未检得,见上方'分区空白')
|
||||
|
||||
=== 重复 id 冲突(应为空)===
|
||||
(无)
|
||||
|
||||
=== id 策略 ===
|
||||
ISSN 型: 692 条 slug 型: 364 条
|
||||
|
||||
=== 来源超链接 ===
|
||||
国际 JIF来源: 779/779 分区来源: 762/779 中文数据来源: 277/277
|
||||
|
||||
=== 结构迁移(ALTER TABLE,幂等)===
|
||||
本次加列: official_check_status, jcr_release_year
|
||||
已存在跳过: official_url
|
||||
|
||||
=== 官网映射(决策:official_source 不入库)===
|
||||
识别列: 官网网址
|
||||
official_url 填充: 1053/1056 official_check_status 填充: 1056/1056
|
||||
jcr_release_year 填充: 779(仅国际=2026;中文表无此列→null)
|
||||
官网缺失 3 条(official_url=null,前端显示「—」不生成链接):
|
||||
- [zh] 中国食用菌
|
||||
- [zh] 水土保持应用技术
|
||||
- [zh] 粮食与饲料工业
|
||||
|
||||
=== 幂等性验证 ===
|
||||
第二次 upsert 后 count 不变: ✓ 通过
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updated": "2026-10-09",
|
||||
"notes": "植物基因组学领域种子期刊。acceptance_rate 无公开权威数据一律为 null(不编造);impact_factor / review_time_weeks 为公开 JCR/期刊官网常识近似值,以 last_updated 标注刷新时间,接入真实数据源前需人工核验。scope_desc 为英文范围描述,供 vstore 向量匹配使用。",
|
||||
"notes": "植物基因组学领域种子期刊。acceptance_rate 无公开权威数据一律为 null(不编造);impact_factor / review_time_weeks 为公开 JCR/期刊官网常识近似值,以 last_updated 标注刷新时间,接入真实数据源前需人工核验。scope_desc 为英文范围描述,供 vstore 向量匹配使用。 format_rules_default 为通用默认规则(rules_source=default);各条目 format_rules 暂为占位(rules_source=placeholder),接入真实投稿指南核验前不填期刊专属数字。 新增标签字段(不进 SQLite,接口按 id 从本文件合并):scope_tags / application_tags / cross_domain 按 scope_desc 归类的标签;cas_partition / jcr_quartile 首版占位(partition_source=placeholder),核实不确定的一律 null、不猜测,接入真实分区数据前需人工核验。",
|
||||
"format_rules_default": {
|
||||
"rules_source": "default",
|
||||
"max_pages": 10,
|
||||
"min_font_pt": 9,
|
||||
"min_margin_mm": 15,
|
||||
"min_line_spacing": 1.15,
|
||||
"min_figures": 1,
|
||||
"max_fonts": 6,
|
||||
"refs_style": "apa"
|
||||
},
|
||||
"journals": [
|
||||
{
|
||||
"id": "plant-cell",
|
||||
@@ -12,7 +22,16 @@
|
||||
"acceptance_rate": null,
|
||||
"submission_deadline": null,
|
||||
"official_url": "https://academic.oup.com/plcell",
|
||||
"last_updated": "2026-10-09"
|
||||
"last_updated": "2026-10-09",
|
||||
"scope_tags": ["植物基因组学", "分子生物学", "细胞生物学", "信号转导"],
|
||||
"application_tags": ["基础研究"],
|
||||
"cas_partition": "1区",
|
||||
"jcr_quartile": "Q1",
|
||||
"partition_source": "placeholder",
|
||||
"cross_domain": ["系统生物学", "植物-微生物互作"],
|
||||
"format_rules": {
|
||||
"rules_source": "placeholder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "genome-biology",
|
||||
@@ -23,7 +42,16 @@
|
||||
"acceptance_rate": null,
|
||||
"submission_deadline": null,
|
||||
"official_url": "https://genomebiology.biomedcentral.com",
|
||||
"last_updated": "2026-10-09"
|
||||
"last_updated": "2026-10-09",
|
||||
"scope_tags": ["基因组学", "生物信息学", "多组学", "计算生物学"],
|
||||
"application_tags": ["基础研究", "算法与方法"],
|
||||
"cas_partition": "1区",
|
||||
"jcr_quartile": "Q1",
|
||||
"partition_source": "placeholder",
|
||||
"cross_domain": ["进化生物学", "单细胞组学"],
|
||||
"format_rules": {
|
||||
"rules_source": "placeholder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "plant-journal",
|
||||
@@ -34,7 +62,16 @@
|
||||
"acceptance_rate": null,
|
||||
"submission_deadline": null,
|
||||
"official_url": "https://onlinelibrary.wiley.com/journal/1365313X",
|
||||
"last_updated": "2026-10-09"
|
||||
"last_updated": "2026-10-09",
|
||||
"scope_tags": ["分子生物学", "植物基因组学", "细胞与发育生物学", "逆境生物学"],
|
||||
"application_tags": ["基础研究", "分子育种"],
|
||||
"cas_partition": null,
|
||||
"jcr_quartile": "Q1",
|
||||
"partition_source": "placeholder",
|
||||
"cross_domain": ["表观遗传学", "植物生物技术"],
|
||||
"format_rules": {
|
||||
"rules_source": "placeholder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "theoretical-applied-genetics",
|
||||
@@ -45,7 +82,16 @@
|
||||
"acceptance_rate": null,
|
||||
"submission_deadline": null,
|
||||
"official_url": "https://link.springer.com/journal/338",
|
||||
"last_updated": "2026-10-09"
|
||||
"last_updated": "2026-10-09",
|
||||
"scope_tags": ["遗传学", "作物育种", "数量遗传学", "基因组选择"],
|
||||
"application_tags": ["种业", "分子育种", "基础研究"],
|
||||
"cas_partition": null,
|
||||
"jcr_quartile": null,
|
||||
"partition_source": "placeholder",
|
||||
"cross_domain": ["统计遗传学", "全基因组关联分析"],
|
||||
"format_rules": {
|
||||
"rules_source": "placeholder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "plant-genome",
|
||||
@@ -56,7 +102,16 @@
|
||||
"acceptance_rate": null,
|
||||
"submission_deadline": null,
|
||||
"official_url": "https://acsess.onlinelibrary.wiley.com/journal/1940-3372",
|
||||
"last_updated": "2026-10-09"
|
||||
"last_updated": "2026-10-09",
|
||||
"scope_tags": ["植物基因组学", "作物基因组学", "结构变异", "关联分析"],
|
||||
"application_tags": ["种业", "分子育种"],
|
||||
"cas_partition": null,
|
||||
"jcr_quartile": null,
|
||||
"partition_source": "placeholder",
|
||||
"cross_domain": ["转化基因组学", "标记辅助选择"],
|
||||
"format_rules": {
|
||||
"rules_source": "placeholder"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,815 @@
|
||||
{
|
||||
"ok": true,
|
||||
"items": [
|
||||
{
|
||||
"id": "en:0028-0836",
|
||||
"lang": "en",
|
||||
"name": "NATURE",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 48.5,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "Nature Portfolio(含合作刊)",
|
||||
"journal_mark": "经典专业刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "0028-0836",
|
||||
"eissn": "1476-4687",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:0036-8075",
|
||||
"lang": "en",
|
||||
"name": "SCIENCE",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 45.8,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "Science / AAAS",
|
||||
"journal_mark": "经典专业刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "0036-8075",
|
||||
"eissn": "1095-9203",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:0092-8674",
|
||||
"lang": "en",
|
||||
"name": "CELL",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 42.5,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "Cell Press(含合作刊)",
|
||||
"journal_mark": "经典专业刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "0092-8674",
|
||||
"eissn": "1097-4172",
|
||||
"jcr_disciplines": [
|
||||
"BIOCHEMISTRY & MOLECULAR BIOLOGY(SCIE)",
|
||||
"CELL BIOLOGY(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2666-6758",
|
||||
"lang": "en",
|
||||
"name": "Innovation",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 25.7,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2666-6758",
|
||||
"eissn": "2666-6758",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(ESCI)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2095-9273",
|
||||
"lang": "en",
|
||||
"name": "Science Bulletin",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 21.1,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2095-9273",
|
||||
"eissn": "2095-9281",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2095-5138",
|
||||
"lang": "en",
|
||||
"name": "National Science Review",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 17.1,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2095-5138",
|
||||
"eissn": "2053-714X",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:nature-communications",
|
||||
"lang": "en",
|
||||
"name": "Nature Communications",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 15.7,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "Nature Portfolio(含合作刊)",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": null,
|
||||
"eissn": "2041-1723",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:advanced-science",
|
||||
"lang": "en",
|
||||
"name": "Advanced Science",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 14.1,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": null,
|
||||
"eissn": "2198-3844",
|
||||
"jcr_disciplines": [
|
||||
"CHEMISTRY, MULTIDISCIPLINARY(SCIE)",
|
||||
"MATERIALS SCIENCE, MULTIDISCIPLINARY(SCIE)",
|
||||
"NANOSCIENCE & NANOTECHNOLOGY(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2375-2548",
|
||||
"lang": "en",
|
||||
"name": "Science Advances",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 12.5,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "Science / AAAS",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2375-2548",
|
||||
"eissn": "2375-2548",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2096-5168",
|
||||
"lang": "en",
|
||||
"name": "Research",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 10.9,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已按Clarivate勘误更新",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "Science Partner Journals(合作刊)",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2096-5168",
|
||||
"eissn": "2639-5274",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": "2024 JIF采用2025-10-15官方重载修订值。",
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://journalcitationreports.zendesk.com/hc/en-gb/articles/36310787349521-2025",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:0027-8424",
|
||||
"lang": "en",
|
||||
"name": "PROCEEDINGS OF THE NATIONAL ACADEMY OF SCIENCES OF THE UNITED STATES OF AMERICA",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 9.1,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "经典专业刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "0027-8424",
|
||||
"eissn": "1091-6490",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:scientific-data",
|
||||
"lang": "en",
|
||||
"name": "Scientific Data",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "2区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 6.9,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "Nature Portfolio(含合作刊)",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": null,
|
||||
"eissn": "2052-4463",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:iscience",
|
||||
"lang": "en",
|
||||
"name": "iScience",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "2区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 4.1,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "Cell Press(含合作刊)",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": null,
|
||||
"eissn": "2589-0042",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2047-217X",
|
||||
"lang": "en",
|
||||
"name": "GigaScience",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "2区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 3.9,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2047-217X",
|
||||
"eissn": "2047-217X",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2045-2322",
|
||||
"lang": "en",
|
||||
"name": "Scientific Reports",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "3区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 3.9,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "Nature Portfolio(含合作刊)",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2045-2322",
|
||||
"eissn": "2045-2322",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:pnas-nexus",
|
||||
"lang": "en",
|
||||
"name": "PNAS Nexus",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "3区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 3.8,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": null,
|
||||
"eissn": "2752-6542",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(ESCI)",
|
||||
"SOCIAL SCIENCES, INTERDISCIPLINARY(ESCI)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:heliyon",
|
||||
"lang": "en",
|
||||
"name": "Heliyon",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "4区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 3.6,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": null,
|
||||
"eissn": "2405-8440",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2054-5703",
|
||||
"lang": "en",
|
||||
"name": "Royal Society Open Science",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "3区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 2.9,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2054-5703",
|
||||
"eissn": "2054-5703",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:1932-6203",
|
||||
"lang": "en",
|
||||
"name": "PLoS One",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "3区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 2.6,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "1932-6203",
|
||||
"eissn": "1932-6203",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2167-8359",
|
||||
"lang": "en",
|
||||
"name": "PeerJ",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "3区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": 2.4,
|
||||
"jif_year": 2024,
|
||||
"jif_note": null,
|
||||
"jif_check_status": "已匹配公开汇编",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "否",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2167-8359",
|
||||
"eissn": "2167-8359",
|
||||
"jcr_disciplines": [
|
||||
"MULTIDISCIPLINARY SCIENCES(SCIE)"
|
||||
],
|
||||
"supplement_note": null,
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": "official",
|
||||
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
},
|
||||
{
|
||||
"id": "en:2050-084X",
|
||||
"lang": "en",
|
||||
"name": "eLife",
|
||||
"research_direction": "综合与交叉",
|
||||
"cas_partition": "1区",
|
||||
"cas_partition_note": null,
|
||||
"cas_partition_year": 2025,
|
||||
"jif": null,
|
||||
"jif_year": 2024,
|
||||
"jif_note": "未被2024 JCR收录",
|
||||
"jif_check_status": "源表标N/A",
|
||||
"composite_impact_factor": null,
|
||||
"comprehensive_impact_factor": null,
|
||||
"metric_year": null,
|
||||
"metric_report_year": null,
|
||||
"metric_caliber": null,
|
||||
"annual_report_page": null,
|
||||
"top_flag": "是",
|
||||
"publisher_series": "其他",
|
||||
"journal_mark": "领域期刊/新兴刊",
|
||||
"article_type": "研究/方法/综合",
|
||||
"issn": "2050-084X",
|
||||
"eissn": "2050-084X",
|
||||
"jcr_disciplines": [
|
||||
"BIOLOGY(ESCI)"
|
||||
],
|
||||
"supplement_note": "源表2024 JIF为N/A;不可用历史IF代填。",
|
||||
"evidence_note": null,
|
||||
"official_url": null,
|
||||
"partition_source": "official",
|
||||
"jif_source": null,
|
||||
"jif_source_url": null,
|
||||
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
|
||||
"data_source_label": "JIF来源",
|
||||
"data_source_url": null
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"total": 21,
|
||||
"page": 1,
|
||||
"size": 50,
|
||||
"lang": "en",
|
||||
"domain": "综合与交叉",
|
||||
"cas_partition": "",
|
||||
"q": "",
|
||||
"domains": [
|
||||
"细胞分子发育与生理",
|
||||
"生态环境与保护",
|
||||
"微生物真菌与生物技术",
|
||||
"农学作物与种质",
|
||||
"遗传组学与计算生物",
|
||||
"植物科学与分子育种",
|
||||
"水产渔业与海洋生物",
|
||||
"食品营养与安全",
|
||||
"植物保护与昆虫",
|
||||
"畜牧动物营养与繁殖",
|
||||
"土壤肥料与地球生物化学",
|
||||
"林业木材与草业",
|
||||
"兽医与寄生虫",
|
||||
"综合与交叉",
|
||||
"园艺果树与采后"
|
||||
],
|
||||
"data_source_note": "分区:2025年中科院公开表(official);JIF:2024 JCR,由2025版JCR发布(official);整理日期2026-10-10。空白=来源未给值≠0;研究方向/期刊标记/文章类型为导航标签,非官方评级。",
|
||||
"data_imported": true
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"ok": false,
|
||||
"items": [],
|
||||
"meta": null,
|
||||
"error": {
|
||||
"code": "VALIDATION",
|
||||
"message": "size 必须在 1-200,收到 500",
|
||||
"degraded": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"ok": false,
|
||||
"items": [],
|
||||
"meta": null,
|
||||
"error": {
|
||||
"code": "VALIDATION",
|
||||
"message": "不支持的 lang='xx',可选:en / zh",
|
||||
"degraded": false
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,579 @@
|
||||
#!/usr/bin/env python3
|
||||
"""scripts/import_journals.py —— 期刊目录导入(source_v4.xlsx → SQLite journal_directory)。
|
||||
|
||||
数据流(本脚本独立拥有 journal_directory 表的 schema,app 层只读不建表):
|
||||
openpyxl 非 read_only 加载(需要读 cell.hyperlink.target 来源链接)
|
||||
→ 「国际期刊」「中文期刊」两 sheet 按【列名】映射(不按索引,防列序漂移)
|
||||
→ 行级清洗(空→None 绝不填 0;分区数字→"N区";JCR学科→JSON 数组)
|
||||
→ 按 id 幂等 upsert(ON CONFLICT DO UPDATE,重跑 count 不变)
|
||||
→ 对账报告 data/journals/import_report.txt
|
||||
|
||||
v4 源(2026-10-10):JIF=2025 统计年/2026 JCR 发布;中文 IF=2024 统计年/
|
||||
年报 2025 版;新增官网三列 + JCR发布年;JCR学科列改名「JCR学科(2024源表)」。
|
||||
旧文件 source.xlsx(v2)原样保留为回退点,勿覆盖。
|
||||
|
||||
schema 迁移(决策①,幂等 + 备份):
|
||||
既有表缺列 → PRAGMA table_info 探测后 ALTER TABLE ADD COLUMN,缺哪加哪、
|
||||
重复跑不报错;跑前先 cp review.db review.db.bak-<日期>。
|
||||
新增列:official_url / official_check_status / jcr_release_year。
|
||||
official_source 不入库(值全为字面量"官网来源",超链接=官网本身,无信息量)。
|
||||
|
||||
id 策略(拍板-补充1):
|
||||
有 ISSN → "{lang}:{ISSN原样(strip)}"(如 en:0028-0836)
|
||||
无 ISSN → "{lang}:{slug(name)}"(casefold + 非词字符折叠为连字符,保留 CJK)
|
||||
同 lang 内 id 冲突 → 报告冲突列表并【中止导入】(非零退出,不写库)。
|
||||
|
||||
"未检得"/空白 区分(拍板-补充2):
|
||||
分区空白 → cas_partition=NULL, cas_partition_note=NULL(前端 "—")
|
||||
分区"未检得" → cas_partition=NULL, cas_partition_note="未检得"(前端 "— ⓘ")
|
||||
JIF 空 → jif=NULL + jif_note=核对状态原文(如"2025 JIF未检得/未给值")
|
||||
官网空(3 本中文刊未检得)→ official_url=NULL(前端 "—",不生成 <a>)
|
||||
|
||||
官网列:v4 已含「官网网址」列(国际 779 + 中文 274),按列名自动映射
|
||||
official_url;同时映射 官网核对状态 → official_check_status、
|
||||
JCR发布年 → jcr_release_year(仅国际;中文表无此列 → NULL)。
|
||||
|
||||
禁区:不 import app.*(db.py / recommend / services 零触碰);只用 stdlib sqlite3 + openpyxl。
|
||||
用法:python scripts/import_journals.py (cwd 任意;路径按本文件位置推导)
|
||||
退出码:0 成功;1 id 冲突中止;2 源数据行数与口径不符;3 结构/文件错误。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import openpyxl
|
||||
|
||||
# ---- 路径(review_service/scripts/ → parents[1] = review_service)----
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_XLSX = ROOT / "data" / "journals" / "source_v4.xlsx" # v4;v2=source.xlsx 保留回退
|
||||
REPORT_TXT = ROOT / "data" / "journals" / "import_report.txt"
|
||||
|
||||
SHEET_INTL = "国际期刊"
|
||||
SHEET_ZH = "中文期刊"
|
||||
EXPECT_INTL = 779 # 口径 sheet 声明(v4 不变)
|
||||
EXPECT_ZH = 277
|
||||
|
||||
# 官网列(v4 实际列名「官网网址」在首位;其余为历史/备用别名)
|
||||
OFFICIAL_URL_HEADERS = ("官网网址", "官网", "官方网址", "期刊官网")
|
||||
# JCR 学科列别名:v4 改名「JCR学科(2024源表)」,v2 为「JCR学科(源表)」
|
||||
JCR_DISCIPLINE_HEADERS = ("JCR学科(2024源表)", "JCR学科(源表)")
|
||||
|
||||
# 既有表缺列时的幂等迁移(决策①:缺哪加哪,重复跑不报错)
|
||||
MIGRATE_COLS = (
|
||||
("official_url", "TEXT"),
|
||||
("official_check_status", "TEXT"),
|
||||
("jcr_release_year", "INTEGER"),
|
||||
)
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS journal_directory (
|
||||
id TEXT PRIMARY KEY,
|
||||
lang TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
research_direction TEXT,
|
||||
cas_partition TEXT,
|
||||
cas_partition_note TEXT,
|
||||
cas_partition_year INTEGER,
|
||||
jif REAL,
|
||||
jif_year INTEGER,
|
||||
jif_note TEXT,
|
||||
jif_check_status TEXT,
|
||||
composite_impact_factor REAL,
|
||||
comprehensive_impact_factor REAL,
|
||||
metric_year INTEGER,
|
||||
metric_report_year INTEGER,
|
||||
metric_caliber TEXT,
|
||||
annual_report_page INTEGER,
|
||||
top_flag TEXT,
|
||||
publisher_series TEXT,
|
||||
journal_mark TEXT,
|
||||
article_type TEXT,
|
||||
issn TEXT,
|
||||
eissn TEXT,
|
||||
jcr_disciplines TEXT,
|
||||
supplement_note TEXT,
|
||||
evidence_note TEXT,
|
||||
official_url TEXT,
|
||||
official_check_status TEXT,
|
||||
jcr_release_year INTEGER,
|
||||
partition_source TEXT,
|
||||
jif_source TEXT,
|
||||
jif_source_url TEXT,
|
||||
partition_source_url TEXT,
|
||||
data_source_label TEXT,
|
||||
data_source_url TEXT,
|
||||
imported_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dir_lang_name ON journal_directory(lang, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_dir_lang_jif ON journal_directory(lang, jif);
|
||||
CREATE INDEX IF NOT EXISTS idx_dir_lang_cif ON journal_directory(lang, composite_impact_factor);
|
||||
CREATE INDEX IF NOT EXISTS idx_dir_lang_part ON journal_directory(lang, cas_partition);
|
||||
CREATE INDEX IF NOT EXISTS idx_dir_lang_domain ON journal_directory(lang, research_direction);
|
||||
"""
|
||||
|
||||
# upsert 列序(除 id 外全部 ON CONFLICT DO UPDATE)
|
||||
COLS = (
|
||||
"lang", "name", "research_direction", "cas_partition", "cas_partition_note",
|
||||
"cas_partition_year", "jif", "jif_year", "jif_note", "jif_check_status",
|
||||
"composite_impact_factor", "comprehensive_impact_factor", "metric_year",
|
||||
"metric_report_year", "metric_caliber", "annual_report_page", "top_flag",
|
||||
"publisher_series", "journal_mark", "article_type", "issn", "eissn",
|
||||
"jcr_disciplines", "supplement_note", "evidence_note", "official_url",
|
||||
"official_check_status", "jcr_release_year",
|
||||
"partition_source", "jif_source", "jif_source_url", "partition_source_url",
|
||||
"data_source_label", "data_source_url", "imported_at",
|
||||
)
|
||||
UPSERT_SQL = (
|
||||
"INSERT INTO journal_directory (id, " + ", ".join(COLS) + ") VALUES ("
|
||||
+ ", ".join("?" for _ in range(len(COLS) + 1)) + ") "
|
||||
+ "ON CONFLICT(id) DO UPDATE SET "
|
||||
+ ", ".join(f"{c}=excluded.{c}" for c in COLS)
|
||||
)
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection) -> tuple[list[str], list[str]]:
|
||||
"""幂等加列(决策①):PRAGMA 探测现有列,缺哪个 ALTER ADD 哪个。"""
|
||||
existing = {r[1] for r in conn.execute("PRAGMA table_info(journal_directory)")}
|
||||
added: list[str] = []
|
||||
skipped: list[str] = []
|
||||
for col, decl in MIGRATE_COLS:
|
||||
if col in existing:
|
||||
skipped.append(col)
|
||||
else:
|
||||
conn.execute(f"ALTER TABLE journal_directory ADD COLUMN {col} {decl}")
|
||||
added.append(col)
|
||||
conn.commit()
|
||||
return added, skipped
|
||||
|
||||
|
||||
# ---- 清洗原语 ----
|
||||
|
||||
def text(v: Any) -> str | None:
|
||||
"""单元格 → strip 后的文本;空/纯空白 → None(绝不填 0/空串)。"""
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def to_float(v: Any) -> float | None:
|
||||
if v is None or (isinstance(v, str) and not v.strip()):
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def to_int(v: Any) -> int | None:
|
||||
f = to_float(v)
|
||||
if f is None:
|
||||
return None
|
||||
return int(f)
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""小写 + 非词字符折叠为连字符;\\w 含 CJK(中文期刊无 ISSN 时的 id 兜底)。"""
|
||||
s = unicodedata.normalize("NFC", name.strip().casefold())
|
||||
s = re.sub(r"[^\w]+", "-", s, flags=re.UNICODE)
|
||||
s = re.sub(r"-{2,}", "-", s)
|
||||
return s.strip("-")
|
||||
|
||||
|
||||
def make_id(lang: str, name: str, issn: str | None) -> str:
|
||||
if issn:
|
||||
return f"{lang}:{issn}"
|
||||
return f"{lang}:{slugify(name)}"
|
||||
|
||||
|
||||
def parse_partition(raw: Any) -> tuple[str | None, str | None]:
|
||||
"""分区 → (cas_partition, note)。数字 1-4 → 'N区';'未检得' → note;空白 → 双 None。"""
|
||||
if raw is None:
|
||||
return None, None
|
||||
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
|
||||
n = int(raw)
|
||||
if 1 <= n <= 4:
|
||||
return f"{n}区", None
|
||||
return None, None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None, None
|
||||
if "未检得" in s:
|
||||
return None, "未检得"
|
||||
m = re.fullmatch(r"([1-4])\s*区?", s)
|
||||
if m:
|
||||
return f"{m.group(1)}区", None
|
||||
return s, None # 未知文本:原样保留(不丢信息)
|
||||
|
||||
|
||||
def split_disciplines(raw: Any) -> str | None:
|
||||
"""'A(SCIE);B(SCIE)' → JSON 数组字符串。"""
|
||||
s = text(raw)
|
||||
if not s:
|
||||
return None
|
||||
parts = [p.strip() for p in s.split(";") if p.strip()]
|
||||
return json.dumps(parts, ensure_ascii=False) if parts else None
|
||||
|
||||
|
||||
def hyperlink_target(cell) -> str | None:
|
||||
"""超链接单元格 → target URL;无链接 → None(需要非 read_only 模式)。"""
|
||||
try:
|
||||
target = cell.hyperlink.target if cell.hyperlink is not None else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
return text(target)
|
||||
|
||||
|
||||
# ---- sheet 解析 ----
|
||||
|
||||
def header_index(ws) -> dict[str, int]:
|
||||
headers = [text(c.value) for c in ws[1]]
|
||||
return {h: i for i, h in enumerate(headers) if h is not None}
|
||||
|
||||
|
||||
def read_intl(ws, imported_at: str, stats: dict) -> list[tuple]:
|
||||
"""国际期刊 sheet → 行元组(顺序同 COLS 前置 id)。"""
|
||||
ix = header_index(ws)
|
||||
for req in ("期刊名称", "研究方向", "中科院分区", "JIF影响因子"):
|
||||
if req not in ix:
|
||||
raise SystemExit(f"[结构错误] {SHEET_INTL} 缺列:{req}(实际列:{list(ix)})")
|
||||
url_col = next((ix[h] for h in OFFICIAL_URL_HEADERS if h in ix), None)
|
||||
jcr_hdr = next((h for h in JCR_DISCIPLINE_HEADERS if h in ix), None)
|
||||
stats["official_url_detected"] = url_col is not None
|
||||
if url_col is not None:
|
||||
stats["official_url_headers"] = next(h for h in OFFICIAL_URL_HEADERS if h in ix)
|
||||
|
||||
rows: list[tuple] = []
|
||||
for row in ws.iter_rows(min_row=2):
|
||||
if not any(c.value is not None for c in row):
|
||||
continue # 尾部幻影空行:不计入 skipped
|
||||
name = text(row[ix["期刊名称"]].value) if ix["期刊名称"] < len(row) else None
|
||||
if not name:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
issn = text(row[ix["ISSN"]].value) if "ISSN" in ix else None
|
||||
jif = to_float(row[ix["JIF影响因子"]].value)
|
||||
check_status = text(row[ix["JIF核对状态"]].value) if "JIF核对状态" in ix else None
|
||||
official_check = text(row[ix["官网核对状态"]].value) if "官网核对状态" in ix else None
|
||||
jcr_year = to_int(row[ix["JCR发布年"]].value) if "JCR发布年" in ix else None
|
||||
part, part_note = parse_partition(row[ix["中科院分区"]].value)
|
||||
|
||||
# JIF 空 → note = 核对状态原文("未检得"/"源表标N/A" 等说明,拍板-补充2)
|
||||
jif_note = check_status if jif is None else None
|
||||
|
||||
jif_url = hyperlink_target(row[ix["JIF数据来源"]]) if "JIF数据来源" in ix else None
|
||||
part_url = hyperlink_target(row[ix["分区数据来源"]]) if "分区数据来源" in ix else None
|
||||
official = None
|
||||
if url_col is not None and url_col < len(row):
|
||||
official = text(row[url_col].value) or hyperlink_target(row[url_col])
|
||||
|
||||
rows.append((
|
||||
make_id("en", name, issn), "en", name,
|
||||
text(row[ix["研究方向"]].value),
|
||||
part, part_note,
|
||||
to_int(row[ix["分区版本年"]].value) if "分区版本年" in ix else None,
|
||||
jif,
|
||||
to_int(row[ix["JIF统计年"]].value) if "JIF统计年" in ix else None,
|
||||
jif_note, check_status,
|
||||
None, None, None, None, None, None,
|
||||
text(row[ix["Top(源表)"]].value) if "Top(源表)" in ix else None,
|
||||
text(row[ix["出版系列"]].value) if "出版系列" in ix else None,
|
||||
text(row[ix["期刊标记"]].value) if "期刊标记" in ix else None,
|
||||
text(row[ix["文章类型"]].value) if "文章类型" in ix else None,
|
||||
issn,
|
||||
text(row[ix["eISSN"]].value) if "eISSN" in ix else None,
|
||||
split_disciplines(row[ix[jcr_hdr]].value) if jcr_hdr else None,
|
||||
text(row[ix["补充说明"]].value) if "补充说明" in ix else None,
|
||||
text(row[ix["补充证据"]].value) if "补充证据" in ix else None,
|
||||
official,
|
||||
official_check,
|
||||
jcr_year,
|
||||
"official", "official",
|
||||
jif_url, part_url,
|
||||
text(row[ix["JIF数据来源"]].value) if "JIF数据来源" in ix else None,
|
||||
None, # data_source_url(国际表来源即 jif/partition 两链接)
|
||||
imported_at,
|
||||
))
|
||||
# 统计
|
||||
stats["official_total"] += 1
|
||||
stats["official_filled"] += 1 if official else 0
|
||||
stats["official_check_filled"] += 1 if official_check else 0
|
||||
stats["jcr_filled"] += 1 if jcr_year else 0
|
||||
if not official:
|
||||
stats["official_missing"].append(("en", name))
|
||||
if jif is None:
|
||||
stats["jif_missing"].append((name, check_status or ""))
|
||||
if part is None and part_note == "未检得":
|
||||
stats["part_notfound"].append(name)
|
||||
elif part is None:
|
||||
stats["part_blank"].append(name)
|
||||
stats["hl_jif"] += 1 if jif_url else 0
|
||||
stats["hl_part"] += 1 if part_url else 0
|
||||
stats["id_issn" if issn else "id_slug"] += 1
|
||||
return rows
|
||||
|
||||
|
||||
def read_zh(ws, imported_at: str, stats: dict) -> list[tuple]:
|
||||
"""中文期刊 sheet → 行元组。"""
|
||||
ix = header_index(ws)
|
||||
for req in ("中文期刊名称", "复合影响因子", "综合影响因子"):
|
||||
if req not in ix:
|
||||
raise SystemExit(f"[结构错误] {SHEET_ZH} 缺列:{req}(实际列:{list(ix)})")
|
||||
url_col = next((ix[h] for h in OFFICIAL_URL_HEADERS if h in ix), None)
|
||||
stats["official_url_detected_zh"] = url_col is not None
|
||||
|
||||
rows: list[tuple] = []
|
||||
for row in ws.iter_rows(min_row=2):
|
||||
if not any(c.value is not None for c in row):
|
||||
continue # 尾部幻影空行:不计入 skipped
|
||||
name = text(row[ix["中文期刊名称"]].value)
|
||||
if not name:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
composite = to_float(row[ix["复合影响因子"]].value)
|
||||
comprehensive = to_float(row[ix["综合影响因子"]].value)
|
||||
check_status = text(row[ix["核对状态"]].value) if "核对状态" in ix else None
|
||||
|
||||
src_label = text(row[ix["数据来源"]].value) if "数据来源" in ix else None
|
||||
src_url = hyperlink_target(row[ix["数据来源"]]) if "数据来源" in ix else None
|
||||
official = None
|
||||
if url_col is not None and url_col < len(row):
|
||||
official = text(row[url_col].value) or hyperlink_target(row[url_col])
|
||||
official_check = text(row[ix["官网核对状态"]].value) if "官网核对状态" in ix else None
|
||||
|
||||
rows.append((
|
||||
make_id("zh", name, None), "zh", name,
|
||||
text(row[ix["研究方向"]].value),
|
||||
# 中文分区原样(常量"不按此口径列示"),不分区 → partition_source=None
|
||||
text(row[ix["中科院分区"]].value) if "中科院分区" in ix else None,
|
||||
None, # cas_partition_note:中文表无"未检得"语义(口径=不按此口径列示)
|
||||
None, # cas_partition_year
|
||||
None, None, None, check_status,
|
||||
composite, comprehensive,
|
||||
to_int(row[ix["指标统计年"]].value) if "指标统计年" in ix else None,
|
||||
to_int(row[ix["年报版本年"]].value) if "年报版本年" in ix else None,
|
||||
text(row[ix["指标口径"]].value) if "指标口径" in ix else None,
|
||||
to_int(row[ix["年报PDF页码"]].value) if "年报PDF页码" in ix else None,
|
||||
None, None, None, None, None, None, None,
|
||||
text(row[ix["备注"]].value) if "备注" in ix else None,
|
||||
None, # evidence_note(中文表无此列)
|
||||
official,
|
||||
official_check,
|
||||
None, # jcr_release_year:中文表无 JCR 发布年列(前端中文 Tab 不显示)
|
||||
None, # partition_source:中文不按中科院口径
|
||||
"official", # jif_source:国内年报(official)
|
||||
None, None,
|
||||
src_label, src_url,
|
||||
imported_at,
|
||||
))
|
||||
stats["official_total"] += 1
|
||||
stats["official_filled"] += 1 if official else 0
|
||||
stats["official_check_filled"] += 1 if official_check else 0
|
||||
if not official:
|
||||
stats["official_missing"].append(("zh", name))
|
||||
if composite is None:
|
||||
stats["cif_missing"].append((name, check_status or ""))
|
||||
stats["hl_zh"] += 1 if src_url else 0
|
||||
stats["id_slug"] += 1
|
||||
return rows
|
||||
|
||||
|
||||
# ---- 对账报告 ----
|
||||
|
||||
def build_report(
|
||||
stage: str,
|
||||
stats: dict,
|
||||
counts: dict[str, int] | None,
|
||||
conflicts: list[str],
|
||||
idempotent_ok: bool | None,
|
||||
exit_note: str = "",
|
||||
) -> str:
|
||||
L: list[str] = []
|
||||
L.append("journals directory 导入对账报告")
|
||||
L.append(f"生成时间: {datetime.now().isoformat(timespec='seconds')}")
|
||||
L.append(f"源文件: {SOURCE_XLSX}({SOURCE_XLSX.stat().st_size if SOURCE_XLSX.exists() else 0} bytes)")
|
||||
L.append(f"阶段: {stage}")
|
||||
L.append("")
|
||||
L.append("=== 计数 ===")
|
||||
L.append(f"读取有效行: 国际 {stats['read_intl']} / 中文 {stats['read_zh']} / 合计 {stats['read_intl'] + stats['read_zh']}")
|
||||
L.append(f"成功: {stats['success']} 跳过(名称空): {stats['skipped']} 失败: {stats['failed']}")
|
||||
if counts is not None:
|
||||
L.append(f"写入后 count(*): en={counts.get('en', 0)} zh={counts.get('zh', 0)} total={counts.get('total', 0)}")
|
||||
L.append(f"期望: en={EXPECT_INTL} zh={EXPECT_ZH} total={EXPECT_INTL + EXPECT_ZH} → {'✓ 相符' if counts.get('total') == EXPECT_INTL + EXPECT_ZH else '✗ 不符'}")
|
||||
L.append("")
|
||||
L.append("=== 分区分布(国际)===")
|
||||
L.append(" / ".join(f"{k} {v}" for k, v in sorted(stats["part_dist"].items())))
|
||||
L.append(f"分区空白(→null 无note): {len(stats['part_blank'])}")
|
||||
L.append(f"分区未检得(→null + note): {len(stats['part_notfound'])}")
|
||||
L.append("")
|
||||
L.append(f"=== JIF 缺失 {len(stats['jif_missing'])} 条(jif=null + note=核对状态)===")
|
||||
for n, s in stats["jif_missing"]:
|
||||
L.append(f" - {n} [{s}]")
|
||||
L.append("")
|
||||
L.append(f"=== 中文复合IF 缺失 {len(stats['cif_missing'])} 条 ===")
|
||||
for n, s in stats["cif_missing"]:
|
||||
L.append(f" - {n} [{s}]")
|
||||
L.append("")
|
||||
L.append("=== 分区未检得 期刊名 ===")
|
||||
if stats["part_notfound"]:
|
||||
for n in stats["part_notfound"]:
|
||||
L.append(f" - {n}")
|
||||
else:
|
||||
L.append(" (无——源表以空白表示未检得,见上方'分区空白')")
|
||||
L.append("")
|
||||
L.append("=== 重复 id 冲突(应为空)===")
|
||||
if conflicts:
|
||||
for c in conflicts:
|
||||
L.append(f" !! {c}")
|
||||
else:
|
||||
L.append(" (无)")
|
||||
L.append("")
|
||||
L.append("=== id 策略 ===")
|
||||
L.append(f"ISSN 型: {stats['id_issn']} 条 slug 型: {stats['id_slug']} 条")
|
||||
L.append("")
|
||||
L.append("=== 来源超链接 ===")
|
||||
L.append(f"国际 JIF来源: {stats['hl_jif']}/{stats['read_intl']} 分区来源: {stats['hl_part']}/{stats['read_intl']} 中文数据来源: {stats['hl_zh']}/{stats['read_zh']}")
|
||||
L.append("")
|
||||
L.append("=== 结构迁移(ALTER TABLE,幂等)===")
|
||||
added = stats.get("migrated_cols") or []
|
||||
skipped = stats.get("migrated_skipped") or []
|
||||
L.append(f" 本次加列: {', '.join(added) if added else '无(列已齐)'}")
|
||||
L.append(f" 已存在跳过: {', '.join(skipped) if skipped else '无'}")
|
||||
L.append("")
|
||||
L.append("=== 官网映射(决策:official_source 不入库)===")
|
||||
L.append(f" 识别列: {stats.get('official_url_headers', '未识别')}")
|
||||
tot = stats.get("official_total", 0)
|
||||
L.append(f" official_url 填充: {stats.get('official_filled', 0)}/{tot}"
|
||||
f" official_check_status 填充: {stats.get('official_check_filled', 0)}/{tot}")
|
||||
L.append(f" jcr_release_year 填充: {stats.get('jcr_filled', 0)}(仅国际=2026;中文表无此列→null)")
|
||||
missing = stats.get("official_missing") or []
|
||||
if missing:
|
||||
L.append(f" 官网缺失 {len(missing)} 条(official_url=null,前端显示「—」不生成链接):")
|
||||
for lg, n in missing:
|
||||
L.append(f" - [{lg}] {n}")
|
||||
else:
|
||||
L.append(" 官网缺失: (无)")
|
||||
L.append("")
|
||||
L.append("=== 幂等性验证 ===")
|
||||
if idempotent_ok is None:
|
||||
L.append(" 未执行")
|
||||
else:
|
||||
L.append(f" 第二次 upsert 后 count 不变: {'✓ 通过' if idempotent_ok else '✗ 失败'}")
|
||||
L.append("")
|
||||
if exit_note:
|
||||
L.append(f"!!! {exit_note}")
|
||||
return "\n".join(L) + "\n"
|
||||
|
||||
|
||||
# ---- 主流程 ----
|
||||
|
||||
def main() -> int:
|
||||
if not SOURCE_XLSX.exists():
|
||||
print(f"[错误] 源文件不存在: {SOURCE_XLSX}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
stats: dict[str, Any] = {
|
||||
"read_intl": 0, "read_zh": 0, "success": 0, "skipped": 0, "failed": 0,
|
||||
"jif_missing": [], "cif_missing": [], "part_blank": [], "part_notfound": [],
|
||||
"part_dist": Counter(), "hl_jif": 0, "hl_part": 0, "hl_zh": 0,
|
||||
"id_issn": 0, "id_slug": 0, "official_url_detected": False,
|
||||
"migrated_cols": [], "migrated_skipped": [],
|
||||
"official_total": 0, "official_filled": 0, "official_check_filled": 0,
|
||||
"jcr_filled": 0, "official_missing": [],
|
||||
}
|
||||
imported_at = datetime.now().isoformat(timespec="seconds")
|
||||
t0 = time.time()
|
||||
|
||||
# 1) 读 Excel(非 read_only:要超链接)
|
||||
try:
|
||||
wb = openpyxl.load_workbook(SOURCE_XLSX, data_only=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
REPORT_TXT.write_text(build_report("读取失败", stats, None, [], None, str(exc)), encoding="utf-8")
|
||||
print(f"[错误] 无法打开 Excel: {exc}", file=sys.stderr)
|
||||
return 3
|
||||
if SHEET_INTL not in wb.sheetnames or SHEET_ZH not in wb.sheetnames:
|
||||
msg = f"缺少 sheet:需要 {SHEET_INTL}/{SHEET_ZH},实际 {wb.sheetnames}"
|
||||
REPORT_TXT.write_text(build_report("结构错误", stats, None, [], None, msg), encoding="utf-8")
|
||||
print(f"[错误] {msg}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
# 2) 解析
|
||||
rows_en = read_intl(wb[SHEET_INTL], imported_at, stats)
|
||||
rows_zh = read_zh(wb[SHEET_ZH], imported_at, stats)
|
||||
stats["read_intl"], stats["read_zh"] = len(rows_en), len(rows_zh)
|
||||
for r in rows_en:
|
||||
stats["part_dist"][r[4] or ("未检得" if r[5] else "空白")] += 1
|
||||
|
||||
# 3) 口径行数校验(不符 → 中止,不写库)
|
||||
if len(rows_en) != EXPECT_INTL or len(rows_zh) != EXPECT_ZH:
|
||||
note = (f"行数与口径不符:国际 {len(rows_en)}/{EXPECT_INTL}、"
|
||||
f"中文 {len(rows_zh)}/{EXPECT_ZH},中止导入")
|
||||
REPORT_TXT.write_text(build_report("行数校验失败", stats, None, [], None, note), encoding="utf-8")
|
||||
print(f"[中止] {note}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# 4) id 冲突检测(同 lang 内重复 → 报告并中止)
|
||||
ids = [r[0] for r in rows_en + rows_zh]
|
||||
dup = [i for i, c in Counter(ids).items() if c > 1]
|
||||
conflicts = [f"{i} × {Counter(ids)[i]}" for i in sorted(dup)]
|
||||
if conflicts:
|
||||
note = f"发现 {len(conflicts)} 个重复 id,中止导入(未写库)"
|
||||
REPORT_TXT.write_text(build_report("id 冲突中止", stats, None, conflicts, None, note), encoding="utf-8")
|
||||
print(f"[中止] {note}", file=sys.stderr)
|
||||
for c in conflicts:
|
||||
print(f" !! {c}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 5) 写 SQLite(独立 stdlib 连接;表由本脚本创建,db.py 零触碰)
|
||||
db_path = Path(os.environ.get("REVIEW_DB_PATH") or (ROOT / "review.db"))
|
||||
if not db_path.is_absolute():
|
||||
db_path = ROOT / db_path
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
stats["migrated_cols"], stats["migrated_skipped"] = migrate(conn)
|
||||
all_rows = rows_en + rows_zh
|
||||
conn.executemany(UPSERT_SQL, all_rows)
|
||||
conn.commit()
|
||||
stats["success"] = len(all_rows)
|
||||
|
||||
def count() -> dict[str, int]:
|
||||
cur = conn.execute(
|
||||
"SELECT lang, COUNT(*) FROM journal_directory GROUP BY lang")
|
||||
d = {lang: n for lang, n in cur.fetchall()}
|
||||
total = sum(d.values())
|
||||
return {"en": d.get("en", 0), "zh": d.get("zh", 0), "total": total}
|
||||
|
||||
counts = count()
|
||||
# 6) 幂等验证:同数据第二次 upsert → count 不变
|
||||
conn.executemany(UPSERT_SQL, all_rows)
|
||||
conn.commit()
|
||||
idempotent_ok = count() == counts
|
||||
|
||||
# 分区分布来自实际库(防统计与写入脱节;未检得 note 单列)
|
||||
stats["part_dist"] = Counter(dict(conn.execute(
|
||||
"SELECT COALESCE(cas_partition, "
|
||||
"CASE WHEN cas_partition_note='未检得' THEN '未检得' ELSE '空白' END), COUNT(*) "
|
||||
"FROM journal_directory WHERE lang='en' GROUP BY 1").fetchall()))
|
||||
|
||||
report = build_report("完成", stats, counts, conflicts, idempotent_ok)
|
||||
REPORT_TXT.write_text(report, encoding="utf-8")
|
||||
print(report)
|
||||
print(f"[完成] 耗时 {time.time() - t0:.2f}s db={db_path} 报告={REPORT_TXT}")
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -63,7 +63,7 @@ async def _proxy(request: Request, path: str) -> Any:
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
try:
|
||||
upstream = await client.build_request(
|
||||
upstream = client.build_request( # build_request 是同步方法,返回 httpx.Request,不可 await
|
||||
request.method, url, content=body, headers=headers
|
||||
)
|
||||
resp = await client.send(upstream)
|
||||
|
||||
+1
-3
@@ -1,3 +1 @@
|
||||
# frontend/.gitignore(最小版,B1 可扩展)
|
||||
node_modules/
|
||||
dist/
|
||||
.env.local
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default {}
|
||||
+7
-2
@@ -1,11 +1,14 @@
|
||||
<!-- App.vue —— B1 归属。挂载三栏 AppShell。 -->
|
||||
<!-- App.vue —— B1 归属。挂载三栏 AppShell + 教育科研覆盖层(步骤5;v-show 切换不卸载)。 -->
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import AppShell from './components/layout/AppShell.vue';
|
||||
import EduWorkspace from './components/edu/EduWorkspace.vue';
|
||||
import { useHealthStore } from './stores/health.js';
|
||||
import { useAppStore } from './stores/app.js';
|
||||
|
||||
// FR-SHELL-007 首屏初始化:并行请求健康与 QA 元信息(失败不阻塞渲染)
|
||||
const health = useHealthStore();
|
||||
const appStore = useAppStore();
|
||||
onMounted(() => {
|
||||
health.start();
|
||||
health.fetchQaInfo();
|
||||
@@ -16,5 +19,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell />
|
||||
<!-- 覆盖层语义(D1):两者始终挂载,仅 display 切换——RAG 会话/上传/滚动零丢失 -->
|
||||
<AppShell v-show="appStore.isChat" />
|
||||
<EduWorkspace v-show="appStore.isEdu" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// api/review.js —— 科研审稿端点封装(BFF 8090 → review_service 8100)
|
||||
// 相对路径 /api/review/* → vite proxy(vite.config.js bffTarget)→ BFF 反代(须 REVIEW_ENABLED=1)。
|
||||
//
|
||||
// 错误契约适配:review_service 业务错误是 **HTTP 200 + {ok:false,error:{...}}**
|
||||
// (VALIDATION/PARSE_FAILED/UPSTREAM_DOWN/SEED_UNAVAILABLE 均实测 200),
|
||||
// 而 http.js 只对非 2xx 抛 ApiError —— 200 信封会正常 resolve。
|
||||
// unwrap() 把两条错误路径(200 信封 / 非 2xx http.js 抛出)归一成 ApiError,
|
||||
// 调用方只需 catch + reviewErrorMessage(err) 一个口径。
|
||||
import { get, post, ApiError } from './http.js';
|
||||
|
||||
const BASE = '/api/review';
|
||||
|
||||
function unwrap(promise) {
|
||||
return promise.then((payload) => {
|
||||
if (payload && payload.ok === false && payload.error) {
|
||||
throw new ApiError(payload.error.message || '服务错误', {
|
||||
code: payload.error.code || 'INTERNAL',
|
||||
status: 200,
|
||||
degraded: !!payload.error.degraded,
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
}
|
||||
|
||||
/** ① GET /api/review/journals?field=&order=
|
||||
* → {ok, items:[{id,name,scope_desc,impact_factor,review_time_weeks,acceptance_rate,
|
||||
* submission_deadline,official_url,last_updated}],
|
||||
* meta:{count,last_updated,data_source_note}} */
|
||||
export function getJournals(params = {}) {
|
||||
return unwrap(get(`${BASE}/journals`, params));
|
||||
}
|
||||
|
||||
/** ①' GET /api/review/journals/directory?lang=&domain=&cas_partition=&q=&page=&size=&field=&order=
|
||||
* → {ok, items[{id,lang,name,research_direction,cas_partition,cas_partition_note,
|
||||
* cas_partition_year,jif,jif_year,jif_note,jif_check_status,composite_impact_factor,
|
||||
* comprehensive_impact_factor,metric_year,metric_report_year,metric_caliber,
|
||||
* annual_report_page,top_flag,publisher_series,journal_mark,article_type,issn,eissn,
|
||||
* jcr_disciplines[],supplement_note,evidence_note,official_url,partition_source,
|
||||
* jif_source,jif_source_url,partition_source_url,data_source_label,data_source_url}],
|
||||
* meta:{total,page,size,lang,domain,cas_partition,q,domains[],
|
||||
* data_source_note,data_imported}}
|
||||
* 1056 本目录(779 国际 + 277 中文);只读分页,lang 强制单语(en/zh 永不混排);
|
||||
* cas_partition 仅 lang=en 过滤;field 排序白名单 name/jif/composite_impact_factor/
|
||||
* comprehensive_impact_factor/research_direction/cas_partition;缺值 source 已归零。 */
|
||||
export function getDirectory(params = {}) {
|
||||
return unwrap(get(`${BASE}/journals/directory`, params));
|
||||
}
|
||||
|
||||
/** ⑦' GET /api/review/conferences?domain=&ccf=&upcoming_days=
|
||||
* → {ok, items:[{id,name,full_name,domain_tags,application_tags,ccf_rank,
|
||||
* deadline_abstract,deadline_full_paper,notification_date,conference_date,
|
||||
* location,official_url,timezone_note,deadline_source,last_verified}],
|
||||
* meta:{count,last_verified,data_source_note,query}}
|
||||
* 只读静态种子(全 placeholder);排序/过滤由后端做,upcoming_days=0 表示不过滤。 */
|
||||
export function getConferences(params = {}) {
|
||||
return unwrap(get(`${BASE}/conferences`, params));
|
||||
}
|
||||
|
||||
/** ③ POST /api/review/presubmit(multipart PDF)→ {ok, report, meta}
|
||||
* FormData 自动 multipart(http.js 不设 Content-Type,浏览器带 boundary);
|
||||
* opts 可传 {onUploadProgress, signal, timeout}(FormData+进度走 XHR 分支)。
|
||||
* 读超时沿用默认 600s(> BFF 360s),不先于上游自断。 */
|
||||
export function presubmit(formData, opts = {}) {
|
||||
return unwrap(post(`${BASE}/presubmit`, formData, opts));
|
||||
}
|
||||
|
||||
/** ② POST /api/review/recommend → {ok, tiers:[{tier,journals:[...]}], meta} */
|
||||
export function recommend(payload, opts = {}) {
|
||||
return unwrap(post(`${BASE}/recommend`, payload, opts));
|
||||
}
|
||||
|
||||
/** ⑤ POST /api/review/format-check(multipart PDF/Word)
|
||||
* → {ok, checks:[{key,name,status,detail,suggestion}], summary:{pass,warn,fail},
|
||||
* meta:{filename,file_type,pages,ruleset_source,rules_source,journal_*,
|
||||
* refs_style_expected,refs_style_detected}}
|
||||
* 纯规则引擎,无 LLM,同步秒回(无需 onUploadProgress)。 */
|
||||
export function formatCheck(formData, opts = {}) {
|
||||
return unwrap(post(`${BASE}/format-check`, formData, opts));
|
||||
}
|
||||
|
||||
/** ⑤ format-check 专用错误文案:解档/校验类 PARSE_FAILED/VALIDATION 用后端原话
|
||||
* (与 presubmit 的"扫描件"提示是不同语境,不能复用 CODE_HINTS),其余走统一口径。 */
|
||||
export function formatErrorMessage(err) {
|
||||
if (err instanceof ApiError && (err.code === 'PARSE_FAILED' || err.code === 'VALIDATION') && err.message) {
|
||||
return err.message;
|
||||
}
|
||||
return reviewErrorMessage(err);
|
||||
}
|
||||
|
||||
/** 后端 error.code → 用户可读提示。
|
||||
* null = 不覆盖、直接用后端 message(VALIDATION 入参问题后端原话更具体)。 */
|
||||
const CODE_HINTS = {
|
||||
VALIDATION: null,
|
||||
PARSE_FAILED: '论文解析失败:可能是扫描件/图片型 PDF,请换文本型 PDF。',
|
||||
UPSTREAM_DOWN: '审稿模型暂时不可用,请稍后重试。',
|
||||
SEED_UNAVAILABLE: '期刊库未就绪(种子数据缺失),请联系管理员。',
|
||||
TIMEOUT: '请求超时,请稍后重试。',
|
||||
NETWORK: '网络异常,请检查连接后重试。',
|
||||
};
|
||||
|
||||
/** ApiError(或任意错误)→ 用户可读文案。degraded 由调用方读 err.degraded 走降级横幅样式。 */
|
||||
export function reviewErrorMessage(err) {
|
||||
if (err instanceof ApiError) {
|
||||
const hint = CODE_HINTS[err.code];
|
||||
if (hint) return hint;
|
||||
if (err.message) return err.message; // VALIDATION 等:后端原话
|
||||
}
|
||||
return '服务异常,请稍后重试';
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<!-- EduSideNav.vue —— 教育科研左侧菜单(步骤4)。
|
||||
7 项与 edu store SECTIONS 一致;顶部「← 返回问答」回 chat 视图
|
||||
(AppShell 含 TopBar 在 edu 视图整体 display:none,此为唯一显式返回入口)。
|
||||
高亮仅用现成 --pa-* 变量;无 icon 组件 → emoji。 -->
|
||||
<script setup>
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
import { useEduStore } from '../../stores/edu.js';
|
||||
|
||||
const app = useAppStore();
|
||||
const edu = useEduStore();
|
||||
|
||||
const ITEMS = [
|
||||
{ key: 'journals', label: '学术期刊与会议情报', icon: '📰' },
|
||||
{ key: 'recommend', label: '智能投稿推荐', icon: '🎯' },
|
||||
{ key: 'presubmit', label: 'AI 预审稿系统', icon: '🔍' },
|
||||
{ key: 'report', label: '专业审稿意见报告', icon: '📝' },
|
||||
{ key: 'format', label: '论文排版与规范', icon: '📐' },
|
||||
{ key: 'tracker', label: '投稿进度与返修', icon: '📮' },
|
||||
{ key: 'calendar', label: '会议与投稿日历', icon: '📅' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="edu-sidenav">
|
||||
<button class="back-btn" type="button" title="返回问答" @click="app.gotoChat()">
|
||||
← 返回问答
|
||||
</button>
|
||||
<ul class="edu-menu">
|
||||
<li v-for="it in ITEMS" :key="it.key">
|
||||
<button
|
||||
class="edu-item"
|
||||
:class="{ active: edu.section === it.key }"
|
||||
type="button"
|
||||
:title="it.label"
|
||||
@click="edu.setSection(it.key)"
|
||||
>
|
||||
<span class="edu-icon" aria-hidden="true">{{ it.icon }}</span>
|
||||
<span class="edu-label">{{ it.label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.edu-sidenav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
padding: 14px 10px;
|
||||
background: var(--pa-surface-1);
|
||||
border-right: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: transparent;
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color .15s var(--pa-ease), background .15s var(--pa-ease);
|
||||
}
|
||||
.back-btn:hover {
|
||||
color: var(--pa-ink);
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.edu-menu {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.edu-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: transparent;
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 12.5px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color .15s var(--pa-ease), background .15s var(--pa-ease);
|
||||
}
|
||||
.edu-item:hover {
|
||||
color: var(--pa-ink);
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.edu-item.active {
|
||||
color: var(--pa-ink);
|
||||
font-weight: 600;
|
||||
background: var(--pa-surface-2);
|
||||
box-shadow: inset 2px 0 0 var(--pa-g-from);
|
||||
}
|
||||
.edu-icon {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.edu-label {
|
||||
min-width: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<!-- EduWorkspace.vue —— 教育科研全屏工作台(步骤4;覆盖层语义 D1)。
|
||||
App.vue 中用 v-show 挂载(始终存活,仅 display 切换):
|
||||
左 200px EduSideNav + 右侧 <component :is> 按 edu.section 动态切面板。
|
||||
面板进行中请求/草稿存于 edu store(面板卸载不中断、切菜单不丢)。 -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useEduStore } from '../../stores/edu.js';
|
||||
import EduSideNav from './EduSideNav.vue';
|
||||
import JournalsPanel from './panels/JournalsPanel.vue';
|
||||
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 TrackerPanel from './panels/TrackerPanel.vue';
|
||||
import CalendarPanel from './panels/CalendarPanel.vue';
|
||||
|
||||
const edu = useEduStore();
|
||||
|
||||
/** section → 面板组件映射(与 edu store SECTIONS 一致;
|
||||
* training → calendar 换版,TrainingPanel.vue 保留在 panels/ 下未删除,仅解除映射) */
|
||||
const panelMap = {
|
||||
journals: JournalsPanel,
|
||||
recommend: RecommendPanel,
|
||||
presubmit: PresubmitPanel,
|
||||
report: ReportPanel,
|
||||
format: FormatPanel,
|
||||
tracker: TrackerPanel,
|
||||
calendar: CalendarPanel,
|
||||
};
|
||||
|
||||
const currentPanel = computed(() => panelMap[edu.section] || JournalsPanel);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="edu-workspace">
|
||||
<EduSideNav class="edu-nav" />
|
||||
<main class="edu-panel">
|
||||
<component :is="currentPanel" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.edu-workspace {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: var(--pa-canvas);
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.edu-nav {
|
||||
width: 200px;
|
||||
flex: none;
|
||||
}
|
||||
.edu-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,418 @@
|
||||
<!-- CalendarPanel.vue —— ⑦' 会议与投稿日历(会议时间线;步骤 3.1)。
|
||||
数据链:onMounted → edu.fetchConferences({domain, ccf, upcoming_days}) →
|
||||
review.js getConferences() → GET /api/review/conferences
|
||||
(过滤/排序在后端:domain 对 domain_tags 精确匹配、ccf ∈ A/B/C、
|
||||
upcoming_days=180 窗口 / 0=不过滤;按 deadline_full_paper 升序)。
|
||||
数据诚实性铁律:deadline_source=placeholder 的会议挂「⚠ 日期待核验」黄徽章,
|
||||
并附 deadline_confidence 置信度(high=官方已公布/历年极稳定,medium=按历年
|
||||
规律推算,low=待核验);领域标签按族着色:AI/ML 蓝 / 生物信息学 绿 /
|
||||
植物·农学 黄 / 交叉·健康医疗 紫。面板不生成任何日期,倒计时仅由种子 ISO
|
||||
时间与本机时钟相减得出。
|
||||
倒计时色阶:≤0 灰「已结束」/ ≤7 红 / 8–30 橙 / >30 绿。
|
||||
筛选为 panel-local refs(不进 store/drafts);每次进面板按当前筛选重查
|
||||
(27 条静态种子,成本可忽略,保证筛选 UI 与列表一致)。
|
||||
空态文案:「暂无可投会议,或数据待核验」。样式 cl- 前缀。 -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useEduStore } from '../../../stores/edu.js';
|
||||
|
||||
const edu = useEduStore();
|
||||
|
||||
/* —— 筛选(panel-local)—— */
|
||||
const domain = ref('');
|
||||
const ccf = ref('');
|
||||
const upcomingOnly = ref(true); // 默认开 → upcoming_days=180;关 → 0(含已过期)
|
||||
|
||||
const CCF_OPTIONS = ['A', 'B', 'C'];
|
||||
/* 领域下拉从已加载条目累积(切筛选不清空,避免选项随过滤缩水) */
|
||||
const domainOptions = ref([]);
|
||||
|
||||
function noteDomains(items) {
|
||||
for (const c of items || []) {
|
||||
for (const t of (c.domain_tags || [])) {
|
||||
if (!domainOptions.value.includes(t)) domainOptions.value.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
edu
|
||||
.fetchConferences({
|
||||
domain: domain.value,
|
||||
ccf: ccf.value,
|
||||
upcoming_days: upcomingOnly.value ? 180 : 0,
|
||||
})
|
||||
.then(noteDomains);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// loading = 切菜单期间请求在飞(store 持有,不重发);其余状态按当前筛选重查
|
||||
if (edu.conferencesStatus !== 'loading') reload();
|
||||
else noteDomains(edu.conferences);
|
||||
});
|
||||
|
||||
/* —— 倒计时(本机时钟计算;后端只负责窗口过滤)—— */
|
||||
function countdown(iso) {
|
||||
if (!iso) return { cls: 'ended', text: '日期缺失' };
|
||||
const t = Date.parse(iso);
|
||||
if (Number.isNaN(t)) return { cls: 'ended', text: '日期缺失' };
|
||||
const d = Math.ceil((t - Date.now()) / 86400000);
|
||||
if (d <= 0) return { cls: 'ended', text: '已结束' };
|
||||
if (d <= 7) return { cls: 'red', text: `还有 ${d} 天` };
|
||||
if (d <= 30) return { cls: 'orange', text: `还有 ${d} 天` };
|
||||
return { cls: 'green', text: `还有 ${d} 天` };
|
||||
}
|
||||
|
||||
const day = (iso) => (iso ? String(iso).slice(0, 10) : '—');
|
||||
const isPlaceholder = (c) => c.deadline_source === 'placeholder';
|
||||
|
||||
/* —— 置信度:deadline_confidence → 中文(徽章文案/悬浮提示)—— */
|
||||
const CONF_LABEL = { high: '高', medium: '中', low: '低' };
|
||||
const confLabel = (v) => CONF_LABEL[String(v || '').toLowerCase()] || '—';
|
||||
const confTitle = (v) =>
|
||||
`deadline_source=placeholder,日期未经官方核验;置信度:${v || '—'}` +
|
||||
'(high=官方已公布/历年极稳定,medium=按历年规律推算,low=待核验)';
|
||||
|
||||
/* —— 领域标签族着色:ai=蓝 / bio=绿 / plant=黄 / cross=紫 —— */
|
||||
const DOMAIN_FAMILY = {
|
||||
'AI/ML': 'ai', '深度学习': 'ai', '计算机视觉': 'ai', '自然语言处理': 'ai',
|
||||
'数据挖掘': 'ai', '信息检索': 'ai',
|
||||
'生物信息学': 'bio', '医学影像': 'bio',
|
||||
'植物/农学': 'plant', '基因组学': 'plant', '分子生物学': 'plant',
|
||||
'健康医疗': 'cross',
|
||||
};
|
||||
const famClass = (t) => `cl-chip--${DOMAIN_FAMILY[t] || 'ai'}`;
|
||||
const appClass = (t) => (t === '交叉学科' ? 'cl-chip--cross' : 'cl-chip--app');
|
||||
|
||||
const count = computed(() => edu.conferences.length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="cl">
|
||||
<!-- 头部 -->
|
||||
<header class="cl-head">
|
||||
<h1 class="cl-title">会议与投稿日历</h1>
|
||||
<span v-if="edu.conferencesStatus === 'ready'" class="cl-count">共 {{ count }} 个会议</span>
|
||||
</header>
|
||||
|
||||
<!-- 时区说明(固定一行)+ 诚实性提示 -->
|
||||
<p class="cl-tz">所有时间按 AoE (UTC-12) 时区 · 未经核验的日期以 ⚠ 标注并附置信度</p>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="cl-filters">
|
||||
<label class="cl-field">
|
||||
<span class="cl-field-label">领域</span>
|
||||
<select v-model="domain" class="cl-select" @change="reload">
|
||||
<option value="">全部领域</option>
|
||||
<option v-for="d in domainOptions" :key="d" :value="d">{{ d }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cl-field">
|
||||
<span class="cl-field-label">CCF</span>
|
||||
<select v-model="ccf" class="cl-select" @change="reload">
|
||||
<option value="">全部等级</option>
|
||||
<option v-for="r in CCF_OPTIONS" :key="r" :value="r">CCF-{{ r }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cl-check">
|
||||
<input v-model="upcomingOnly" type="checkbox" @change="reload" />
|
||||
仅显示最近可投
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- ERROR -->
|
||||
<div v-if="edu.conferencesStatus === 'error'" class="cl-state" role="alert">
|
||||
<p class="cl-state-msg">{{ edu.conferencesError }}</p>
|
||||
<button class="cl-retry" type="button" @click="reload">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- LOADING -->
|
||||
<div v-else-if="edu.conferencesStatus === 'loading'" class="cl-state" aria-busy="true">
|
||||
<div v-for="n in 3" :key="n" class="cl-skel"></div>
|
||||
<p class="cl-state-msg">会议数据加载中…</p>
|
||||
</div>
|
||||
|
||||
<!-- READY + 空态 -->
|
||||
<div v-else-if="!edu.conferences.length" class="cl-state">
|
||||
<p class="cl-state-msg">暂无可投会议,或数据待核验</p>
|
||||
</div>
|
||||
|
||||
<!-- READY + 列表(后端已按 deadline_full_paper 升序) -->
|
||||
<ul v-else class="cl-list">
|
||||
<li v-for="c in edu.conferences" :key="c.id" class="cl-card">
|
||||
<div class="cl-row1">
|
||||
<span class="cl-name">{{ c.name }}</span>
|
||||
<span v-if="c.ccf_rank" class="cl-chip cl-chip--ccf">CCF-{{ c.ccf_rank }}</span>
|
||||
<span class="cl-cd" :class="`cl-cd--${countdown(c.deadline_full_paper).cls}`">
|
||||
{{ countdown(c.deadline_full_paper).text }}
|
||||
</span>
|
||||
<span v-if="isPlaceholder(c)" class="cl-chip cl-chip--warn" :title="confTitle(c.deadline_confidence)">
|
||||
⚠ 日期待核验(置信度:{{ confLabel(c.deadline_confidence) }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="cl-full">{{ c.full_name }}</p>
|
||||
|
||||
<div class="cl-tags">
|
||||
<span v-for="t in c.domain_tags" :key="`d-${t}`" class="cl-chip cl-chip--tag" :class="famClass(t)">{{ t }}</span>
|
||||
<span v-for="t in c.application_tags" :key="`a-${t}`" class="cl-chip cl-chip--tag" :class="appClass(t)">{{ t }}</span>
|
||||
</div>
|
||||
|
||||
<dl class="cl-meta">
|
||||
<div class="cl-meta-item">
|
||||
<dt>截稿(摘要)</dt>
|
||||
<dd>{{ day(c.deadline_abstract) }}</dd>
|
||||
</div>
|
||||
<div class="cl-meta-item">
|
||||
<dt>截稿(全文)</dt>
|
||||
<dd class="cl-deadline">{{ day(c.deadline_full_paper) }}</dd>
|
||||
</div>
|
||||
<div class="cl-meta-item">
|
||||
<dt>通知日</dt>
|
||||
<dd>{{ day(c.notification_date) }}</dd>
|
||||
</div>
|
||||
<div class="cl-meta-item">
|
||||
<dt>会期</dt>
|
||||
<dd>{{ c.conference_date || '—' }}</dd>
|
||||
</div>
|
||||
<div class="cl-meta-item">
|
||||
<dt>地点</dt>
|
||||
<dd>{{ c.location || '—' }}</dd>
|
||||
</div>
|
||||
<div class="cl-meta-item">
|
||||
<dt>官网</dt>
|
||||
<dd>
|
||||
<a v-if="c.official_url" class="cl-link" :href="c.official_url" target="_blank" rel="noopener">
|
||||
{{ String(c.official_url).replace(/^https?:\/\//, '').replace(/\/$/, '') }}
|
||||
</a>
|
||||
<span v-else>—</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.cl-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.cl-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.cl-count {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.cl-tz {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
/* 筛选栏 */
|
||||
.cl-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 12px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
}
|
||||
.cl-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.cl-field-label {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.cl-select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--pa-hairline-strong);
|
||||
border-radius: var(--pa-r-sm);
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cl-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
/* 状态区 */
|
||||
.cl-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 0;
|
||||
}
|
||||
.cl-state-msg {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.cl-retry {
|
||||
align-self: flex-start;
|
||||
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;
|
||||
transition: background .15s var(--pa-ease);
|
||||
}
|
||||
.cl-retry:hover {
|
||||
background: var(--pa-surface-3);
|
||||
}
|
||||
.cl-skel {
|
||||
height: 72px;
|
||||
border-radius: var(--pa-r-sm);
|
||||
background: var(--pa-surface-2);
|
||||
animation: cl-pulse 1.2s var(--pa-ease) infinite alternate;
|
||||
}
|
||||
@keyframes cl-pulse {
|
||||
from { opacity: .55; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
/* 卡片列表 */
|
||||
.cl-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.cl-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
}
|
||||
.cl-row1 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cl-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.cl-full {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
/* 徽章 */
|
||||
.cl-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cl-chip--ccf {
|
||||
background: var(--pa-surface-3);
|
||||
color: var(--pa-ink);
|
||||
border: 1px solid var(--pa-hairline-strong);
|
||||
}
|
||||
.cl-chip--warn {
|
||||
background: color-mix(in srgb, var(--pa-warning) 14%, transparent);
|
||||
color: var(--pa-warning);
|
||||
border: 1px solid color-mix(in srgb, var(--pa-warning) 45%, transparent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.cl-chip--tag {
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.cl-chip--app {
|
||||
background: color-mix(in srgb, var(--pa-info) 10%, transparent);
|
||||
color: var(--pa-info);
|
||||
border-color: color-mix(in srgb, var(--pa-info) 35%, transparent);
|
||||
}
|
||||
/* 领域标签族色:ai 蓝 / bio 绿 / plant 黄 / cross 紫(置于 app 之后,交叉优先) */
|
||||
.cl-chip--ai {
|
||||
background: color-mix(in srgb, var(--pa-info) 12%, transparent);
|
||||
color: var(--pa-info);
|
||||
border-color: color-mix(in srgb, var(--pa-info) 35%, transparent);
|
||||
}
|
||||
.cl-chip--bio {
|
||||
background: color-mix(in srgb, var(--pa-success) 14%, transparent);
|
||||
color: var(--pa-success);
|
||||
border-color: color-mix(in srgb, var(--pa-success) 40%, transparent);
|
||||
}
|
||||
.cl-chip--plant {
|
||||
background: color-mix(in srgb, var(--pa-warning) 14%, transparent);
|
||||
color: var(--pa-warning);
|
||||
border-color: color-mix(in srgb, var(--pa-warning) 40%, transparent);
|
||||
}
|
||||
.cl-chip--cross {
|
||||
background: color-mix(in srgb, #7c5cbf 14%, transparent);
|
||||
color: #7c5cbf;
|
||||
border-color: color-mix(in srgb, #7c5cbf 40%, transparent);
|
||||
}
|
||||
/* 倒计时色阶:≤0 灰 / ≤7 红 / 8–30 橙 / >30 绿 */
|
||||
.cl-cd {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cl-cd--red { color: var(--pa-error); }
|
||||
.cl-cd--orange { color: var(--pa-warning); }
|
||||
.cl-cd--green { color: var(--pa-success); }
|
||||
.cl-cd--ended { color: var(--pa-ink-muted); }
|
||||
/* 元信息网格 */
|
||||
.cl-meta {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 6px 14px;
|
||||
}
|
||||
.cl-meta-item dt {
|
||||
font-size: 11px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.cl-meta-item dd {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.cl-deadline {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cl-link {
|
||||
color: var(--pa-g-from);
|
||||
text-decoration: none;
|
||||
}
|
||||
.cl-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,495 @@
|
||||
<!-- FormatPanel.vue —— ⑤ 论文排版与规范(④ 后端规则引擎前端)。
|
||||
链路:本地 PDF/Word/LaTeX(form-data)→ edu.submitFormatCheck →
|
||||
POST /api/review/format-check(file + target_journal=期刊id,纯规则 <1s)
|
||||
→ {ok, checks[{key,name,status,detail,suggestion}], summary{pass,warn,fail},
|
||||
meta{filename,file_type,pages,ruleset_source,rules_source,journal_*,
|
||||
refs_style_*,tex{class,fontsize_pt,paper,packages,cites,macros}}}
|
||||
(真实响应结构写入前已 curl 8100 实测:PDF/Word/LaTeX 三类型 + 错误信封)。
|
||||
LaTeX:detail 中含 "\n建议:\n" 标记时拆分为 正文 + <pre class="fc-code"> 代码块
|
||||
(只展示建议片段,不自动改写)。
|
||||
优先级:formatReport 存在 → 结果区(不退回上传区);否则 loading > error > 上传区。
|
||||
rules_source="placeholder" → 结果区顶部黄字提示(诚实展示占位规则)。
|
||||
结果跨菜单保留(store formatReport);期刊下拉草稿 drafts.formatJournalId;
|
||||
文件为面板本地 ref。样式 fc- 前缀。 -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useEduStore } from '../../../stores/edu.js';
|
||||
|
||||
const edu = useEduStore();
|
||||
|
||||
const MAX_SIZE = 50 * 1024 * 1024;
|
||||
const STATUS_LABEL = { pass: '通过', warn: '警告', fail: '不通过' };
|
||||
const FILE_LABEL = { pdf: 'PDF', docx: 'Word', tex: 'LaTeX' };
|
||||
const DETAIL_CODE_MARKER = '\n建议:\n'; // 后端 LaTeX 建议片段统一标记
|
||||
|
||||
const fileInput = ref(null);
|
||||
const file = ref(null);
|
||||
const fileError = ref('');
|
||||
const dragging = ref(false);
|
||||
|
||||
const hasReport = computed(() => !!(edu.formatReport && edu.formatReport.ok));
|
||||
const loading = computed(() => edu.formatStatus === 'loading');
|
||||
const errored = computed(() => edu.formatStatus === 'error');
|
||||
const report = computed(() => edu.formatReport || null);
|
||||
const meta = computed(() => edu.formatReport?.meta || null);
|
||||
const summary = computed(() => edu.formatReport?.summary || { pass: 0, warn: 0, fail: 0 });
|
||||
const checks = computed(() => edu.formatReport?.checks || []);
|
||||
const placeholderRules = computed(() => meta.value?.rules_source === 'placeholder');
|
||||
|
||||
const fmtSize = (b) => (b / 1048576).toFixed(1) + ' MB';
|
||||
|
||||
/** detail 按 "\n建议:\n" 拆成 正文 + LaTeX 代码片段(无标记则整段为正文) */
|
||||
function splitDetail(detail) {
|
||||
const text = String(detail || '');
|
||||
const i = text.indexOf(DETAIL_CODE_MARKER);
|
||||
if (i < 0) return { text, code: '' };
|
||||
return { text: text.slice(0, i), code: text.slice(i + DETAIL_CODE_MARKER.length) };
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (edu.journalsStatus === 'idle') edu.fetchJournals(); // 目标期刊下拉数据源
|
||||
});
|
||||
|
||||
function acceptFile(f) {
|
||||
fileError.value = '';
|
||||
if (!f) return;
|
||||
const name = String(f.name || '').toLowerCase();
|
||||
if (!name.endsWith('.pdf') && !name.endsWith('.docx') && !name.endsWith('.tex')) {
|
||||
fileError.value = '仅支持 PDF / Word(.docx)/ LaTeX(.tex)文件';
|
||||
return;
|
||||
}
|
||||
if (!f.size) {
|
||||
fileError.value = '上传文件为空';
|
||||
return;
|
||||
}
|
||||
if (f.size > MAX_SIZE) {
|
||||
fileError.value = '上传文件超过 50MB 上限';
|
||||
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) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file.value);
|
||||
fd.append('target_journal', edu.drafts.formatJournalId || ''); // 期刊 id;空=通用规则
|
||||
await edu.submitFormatCheck(fd);
|
||||
}
|
||||
function reset() {
|
||||
edu.clearFormatReport();
|
||||
file.value = null;
|
||||
fileError.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="fc">
|
||||
<header class="fc-head">
|
||||
<h1 class="fc-title">论文排版与规范</h1>
|
||||
<span class="fc-sub">PDF / Word / LaTeX · 页数·字体·边距·行距·图表·参考文献</span>
|
||||
</header>
|
||||
|
||||
<!-- ========== ① 有结果 → 结果区(最高优先级) ========== -->
|
||||
<template v-if="hasReport">
|
||||
<p v-if="placeholderRules" class="fc-placeholder" role="note">
|
||||
⚠ 格式规则基于占位数据,建议核对期刊官网投稿指南
|
||||
</p>
|
||||
|
||||
<div class="fc-meta">
|
||||
📄 {{ meta?.filename || '—' }} · {{ FILE_LABEL[meta?.file_type] || meta?.file_type }} ·
|
||||
{{ meta?.ruleset_source === 'journal' ? meta?.journal_name : '通用规则' }} ·
|
||||
{{ meta?.pages != null ? `共 ${meta?.pages} 页` : '页数未记录' }}
|
||||
<span class="fc-meta-sub">
|
||||
· 参考文献要求 {{ meta?.refs_style_expected }} / 检出 {{ meta?.refs_style_detected }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 汇总条 -->
|
||||
<div class="fc-summary">
|
||||
<span class="fc-count pass">{{ summary.pass }} 项通过</span>
|
||||
<span class="fc-count warn">{{ summary.warn }} 项警告</span>
|
||||
<span class="fc-count fail">{{ summary.fail }} 项不通过</span>
|
||||
<button class="fc-again" type="button" @click="reset">重新检查</button>
|
||||
</div>
|
||||
|
||||
<!-- 逐项检查卡 -->
|
||||
<article v-for="c in checks" :key="c.key" class="fc-card" :class="c.status">
|
||||
<span class="fc-badge" :class="c.status">{{ STATUS_LABEL[c.status] || c.status }}</span>
|
||||
<div class="fc-card-body">
|
||||
<div class="fc-card-name">{{ c.name }}</div>
|
||||
<p class="fc-card-detail">{{ splitDetail(c.detail).text }}</p>
|
||||
<pre v-if="splitDetail(c.detail).code" class="fc-code">{{ splitDetail(c.detail).code }}</pre>
|
||||
<p v-if="c.suggestion" class="fc-card-sug">建议:{{ c.suggestion }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<!-- ========== ② loading ========== -->
|
||||
<div v-else-if="loading" class="fc-state" aria-busy="true">
|
||||
<div class="fc-skel"></div>
|
||||
<div class="fc-skel fc-skel-mid"></div>
|
||||
<p class="fc-state-msg">版式检查中…(纯规则,通常 <1 秒)</p>
|
||||
</div>
|
||||
|
||||
<!-- ========== ③ error ========== -->
|
||||
<div v-else-if="errored" class="fc-state" role="alert">
|
||||
<p class="fc-state-msg fc-state-error">{{ edu.formatError }}</p>
|
||||
<div class="fc-err-btns">
|
||||
<button v-if="file" class="fc-retry" type="button" @click="submit">重试</button>
|
||||
<button class="fc-retry" type="button" @click="reset">重新选择文件</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== ④ 上传区 ========== -->
|
||||
<div v-else class="fc-upload">
|
||||
<div
|
||||
class="fc-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="fc-file-input"
|
||||
type="file"
|
||||
accept=".pdf,.docx,.tex,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
@click.stop
|
||||
@change="onPick"
|
||||
/>
|
||||
<template v-if="file">
|
||||
<span class="fc-file-name">📄 {{ file.name }} · {{ fmtSize(file.size) }}</span>
|
||||
<button class="fc-remove" type="button" title="移除文件" @click.stop="removeFile">×</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="fc-drop-main">拖拽 PDF / Word(.docx) / LaTeX(.tex) 到此处,或点击选择</span>
|
||||
<span class="fc-hint">仅支持 PDF / Word(.docx) / LaTeX(.tex) · ≤ 50MB · 纯本地规则检查</span>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="fileError" class="fc-file-error">{{ fileError }}</p>
|
||||
|
||||
<label class="fc-label" for="fc-journal">目标期刊(选填,决定规则集)</label>
|
||||
<select
|
||||
id="fc-journal"
|
||||
v-model="edu.drafts.formatJournalId"
|
||||
class="fc-input fc-select"
|
||||
:disabled="loading"
|
||||
>
|
||||
<option value="">不指定(通用规则)</option>
|
||||
<option v-for="j in edu.journals" :key="j.id" :value="j.id">{{ j.name }}</option>
|
||||
</select>
|
||||
|
||||
<button class="fc-submit" type="button" :disabled="!file || loading" @click="submit">
|
||||
开始版式检查
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.fc-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.fc-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.fc-sub {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
|
||||
/* 占位规则黄字提示(仅 rules_source=placeholder 时渲染) */
|
||||
.fc-placeholder {
|
||||
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;
|
||||
}
|
||||
.fc-meta {
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.fc-meta-sub {
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
|
||||
/* 汇总条 */
|
||||
.fc-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fc-count {
|
||||
padding: 3px 12px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fc-count.pass { color: var(--pa-success); }
|
||||
.fc-count.warn { color: var(--pa-warning); }
|
||||
.fc-count.fail { color: var(--pa-error); }
|
||||
.fc-again {
|
||||
margin-left: auto;
|
||||
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;
|
||||
}
|
||||
.fc-again:hover {
|
||||
color: var(--pa-ink);
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
|
||||
/* 检查卡 */
|
||||
.fc-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--pa-r-md);
|
||||
}
|
||||
.fc-card.pass { border-left-color: var(--pa-success); }
|
||||
.fc-card.warn { border-left-color: var(--pa-warning); }
|
||||
.fc-card.fail { border-left-color: var(--pa-error); }
|
||||
.fc-badge {
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
min-width: 52px;
|
||||
text-align: center;
|
||||
padding: 2px 9px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.fc-badge.pass { color: var(--pa-success); }
|
||||
.fc-badge.warn { color: var(--pa-warning); }
|
||||
.fc-badge.fail { color: var(--pa-error); }
|
||||
.fc-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.fc-card-name {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.fc-card-detail {
|
||||
margin: 0;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
color: var(--pa-ink-muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
.fc-card-sug {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
/* LaTeX 建议代码片段(detail 中 "建议:" 标记后的部分):等宽 + 灰底块 */
|
||||
.fc-code {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
background: var(--pa-code-bg);
|
||||
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.65;
|
||||
color: var(--pa-ink);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* 状态区 */
|
||||
.fc-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.fc-skel {
|
||||
height: 64px;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-2);
|
||||
animation: fc-pulse 1.2s var(--pa-ease) infinite alternate;
|
||||
}
|
||||
.fc-skel-mid {
|
||||
height: 96px;
|
||||
}
|
||||
@keyframes fc-pulse {
|
||||
from { opacity: .55; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.fc-state-msg {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.fc-state-error {
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.fc-err-btns {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.fc-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;
|
||||
}
|
||||
.fc-retry:hover {
|
||||
background: var(--pa-surface-3);
|
||||
}
|
||||
|
||||
/* 上传区 */
|
||||
.fc-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.fc-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);
|
||||
}
|
||||
.fc-drop:hover,
|
||||
.fc-drop.dragging {
|
||||
border-color: var(--pa-g-from);
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.fc-drop.filled {
|
||||
border-style: solid;
|
||||
}
|
||||
.fc-file-input {
|
||||
display: none;
|
||||
}
|
||||
.fc-drop-main {
|
||||
font-size: 13.5px;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.fc-hint {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.fc-file-name {
|
||||
font-size: 13.5px;
|
||||
color: var(--pa-ink);
|
||||
word-break: break-all;
|
||||
}
|
||||
.fc-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;
|
||||
}
|
||||
.fc-remove:hover {
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.fc-file-error {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.fc-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.fc-input {
|
||||
width: 320px;
|
||||
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;
|
||||
}
|
||||
.fc-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--pa-g-from);
|
||||
}
|
||||
.fc-select:disabled {
|
||||
opacity: .6;
|
||||
}
|
||||
.fc-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;
|
||||
}
|
||||
.fc-submit:disabled {
|
||||
opacity: .45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,770 @@
|
||||
<!-- JournalsPanel.vue —— ① 学术期刊与会议情报(步骤5 目录化改造,D2 严格只读)。
|
||||
数据链:onMounted → edu.fetchDirectory() → review.js getDirectory()
|
||||
→ GET /api/review/journals/directory(相对路径 → vite proxy → BFF → 8100)。
|
||||
双 Tab 强制单语(🌍 en 779 本 / 🇨🇳 zh 277 本,后端 lang 过滤保证永不混排);
|
||||
搜索/研究方向/分区筛选 + 后端分页(每页 50)+ 行展开详情 + 四态
|
||||
(loading 骨架 / error 重试 / 未导入 / 无匹配)。
|
||||
v4 改造(决策2):主视图「来源」列移除,末列为「官网」列——official_url 有值
|
||||
→ <a>短域名(去协议+去尾斜杠)</a>,null → 「—」纯文本不生成链接;
|
||||
展开行承载凭证 4 项:JIF核对状态 / 官网核对状态(可靠=绿、未逐站复核=浅绿、
|
||||
未检得·待核实=橙)/ JIF数据来源链接 / 分区数据来源链接;zh 展开行无 JCR 字段
|
||||
(jcr_release_year 恒 null,年报版本年 metric_report_year 单独展示)。
|
||||
底部固定表C长版数据源声明;顶部黄字按 Tab 切换(en/zh 两套口径)。
|
||||
三态与 journals(FormatPanel 下拉的 5 本精选)完全隔离,edu.journals 链路零改动。
|
||||
读时归一化由后端承担:zh cas_partition 恒 null + note、jif_note 两类文案、
|
||||
缺值 source 归零(前端只管展示 tooltip/链接)。 -->
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useEduStore } from '../../../stores/edu.js';
|
||||
|
||||
const edu = useEduStore();
|
||||
|
||||
/* —— panel-local 纯视图态(丢弃无妨;查询条件在 store)—— */
|
||||
const searchInput = ref(''); // 输入框即时值(300ms 防抖后才进 store)
|
||||
const expandedId = ref(''); // 展开详情的期刊 id('' = 无)
|
||||
const jumpInput = ref(''); // 跳页输入
|
||||
let searchTimer = null;
|
||||
|
||||
const tab = computed(() => edu.journalsTab);
|
||||
const q = computed(() => edu.directoryQuery);
|
||||
|
||||
/* 数据源声明:meta 到达且 lang 匹配 → 服务端文案;否则本地兜底(切 Tab 即时切换)。
|
||||
顶部黄字按 Tab 切换;底部另有表C长版统一声明(FOOT_NOTE,双 Tab 相同)。 */
|
||||
const LOCAL_NOTE = {
|
||||
en: '分区:2025年中科院公开表(多表比对,2026年起停更);JIF:2025年度指标(2026年JCR发布,两份公开汇编一致或已核对出版社官方指标);整理日期2026-10-10;空白=来源未给值≠0',
|
||||
zh: '指标:国内自然科学期刊年报2025版(统计年2024,与JIF统计源不同、不混排);中文期刊不按中科院分区口径列示',
|
||||
};
|
||||
/* 底部数据源声明(表C 长版,拍板决策3) */
|
||||
const FOOT_NOTE =
|
||||
'JIF 取自 2025 年度指标(2026 年 JCR 发布)并经两份公开汇编交叉一致或出版社官方指标核对,' +
|
||||
'中科院分区采用 2025 版公开表(多表比对、2026 年起停更),' +
|
||||
'中文期刊复合/综合影响因子取自国内年报 2025 版(统计年 2024,与 JIF 统计源不同、不混排),' +
|
||||
'期刊官网均附核对状态(3 本中文刊未检得可靠官网留空待核实)。';
|
||||
|
||||
/* —— 凭证状态配色:可靠=绿 / 未逐站复核=浅绿 / 未检得·待核实=橙 —— */
|
||||
const JIF_OK = new Set(['两份公开汇编一致', '已核对出版社2025指标']);
|
||||
const URL_OK = new Set([
|
||||
'出版社/期刊主页匹配',
|
||||
'期刊/主办方官方入口匹配',
|
||||
'期刊/出版平台页面检索核对',
|
||||
]);
|
||||
const URL_SOFT = 'ISSN匹配公开元数据;未逐站复核';
|
||||
function checkCls(v, okSet) {
|
||||
if (!v) return '';
|
||||
if (okSet.has(v)) return 'jp-ok';
|
||||
if (v === URL_SOFT) return 'jp-ok-soft';
|
||||
if (v.includes('未检得') || v.includes('待核实')) return 'jp-warn';
|
||||
return '';
|
||||
}
|
||||
const sourceNote = computed(() =>
|
||||
edu.directoryMeta && edu.directoryMeta.lang === tab.value
|
||||
? edu.directoryMeta.data_source_note
|
||||
: LOCAL_NOTE[tab.value]
|
||||
);
|
||||
const countText = computed(() =>
|
||||
edu.directoryMeta && edu.directoryMeta.lang === tab.value
|
||||
? edu.directoryMeta.total
|
||||
: '—'
|
||||
);
|
||||
/* 研究方向 options(只认当前 lang 的 meta,防切 Tab 后旧词表残留) */
|
||||
const domains = computed(() =>
|
||||
edu.directoryMeta && edu.directoryMeta.lang === tab.value ? edu.directoryMeta.domains : []
|
||||
);
|
||||
|
||||
const total = computed(() => edu.directoryMeta?.total ?? 0);
|
||||
const totalPages = computed(() => Math.ceil(total.value / q.value.size) || 1);
|
||||
const hasFilter = computed(
|
||||
() => !!(q.value.q || q.value.domain || q.value.cas_partition)
|
||||
);
|
||||
|
||||
/* 排序游标:后端默认(en=jif desc / zh=复合IF desc)在 field='' 时生效,箭头如实回显 */
|
||||
const activeSort = computed(() => ({
|
||||
field: q.value.field || (tab.value === 'en' ? 'jif' : 'composite_impact_factor'),
|
||||
order: q.value.order || 'desc',
|
||||
}));
|
||||
|
||||
const CAS_OPTIONS = ['1', '2', '3', '4'];
|
||||
|
||||
/* —— 交互 —— */
|
||||
function onTab(next) {
|
||||
if (next === tab.value) return;
|
||||
clearTimeout(searchTimer);
|
||||
searchInput.value = '';
|
||||
jumpInput.value = '';
|
||||
expandedId.value = '';
|
||||
edu.switchDirectoryTab(next);
|
||||
}
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
edu.fetchDirectory({ q: searchInput.value.trim(), page: 1 });
|
||||
expandedId.value = '';
|
||||
}, 300);
|
||||
}
|
||||
function onDomainChange(e) {
|
||||
edu.fetchDirectory({ domain: e.target.value, page: 1 });
|
||||
expandedId.value = '';
|
||||
}
|
||||
function onCasChange(e) {
|
||||
edu.fetchDirectory({ cas_partition: e.target.value, page: 1 });
|
||||
expandedId.value = '';
|
||||
}
|
||||
function clearFilters() {
|
||||
clearTimeout(searchTimer);
|
||||
searchInput.value = '';
|
||||
edu.fetchDirectory({ q: '', domain: '', cas_partition: '', page: 1 });
|
||||
expandedId.value = '';
|
||||
}
|
||||
function gotoPage(p) {
|
||||
const n = Math.min(Math.max(1, p), totalPages.value);
|
||||
if (n === q.value.page) return;
|
||||
expandedId.value = '';
|
||||
edu.fetchDirectory({ page: n });
|
||||
}
|
||||
function jumpPage() {
|
||||
const n = parseInt(jumpInput.value, 10);
|
||||
jumpInput.value = '';
|
||||
if (Number.isNaN(n)) return;
|
||||
gotoPage(n);
|
||||
}
|
||||
function retry() {
|
||||
edu.fetchDirectory();
|
||||
}
|
||||
function toggleExpand(id) {
|
||||
expandedId.value = expandedId.value === id ? '' : id;
|
||||
}
|
||||
function toggleSort(field) {
|
||||
const cur = activeSort.value;
|
||||
if (cur.field === field) {
|
||||
// 同列切换升/降(不重置页码:total 不变,当前页仍有效)
|
||||
edu.fetchDirectory({ field, order: cur.order === 'asc' ? 'desc' : 'asc' });
|
||||
} else {
|
||||
edu.fetchDirectory({ field, order: field === 'name' ? 'asc' : 'desc' });
|
||||
}
|
||||
}
|
||||
const arrow = (f) => (activeSort.value.field === f ? (activeSort.value.order === 'asc' ? '▲' : '▼') : '');
|
||||
|
||||
const fmt = (v) => (v === null || v === undefined ? '—' : v);
|
||||
const fmtIf = (v, year) => (v === null || v === undefined ? '—' : year ? `${v} (${year})` : `${v}`);
|
||||
const shortUrl = (u) => String(u || '').replace(/^https?:\/\//, '').replace(/\/$/, '') || '—';
|
||||
|
||||
onMounted(() => {
|
||||
// idle 才查:ready 复用 store 缓存(切菜单回来筛选仍在);error 不自动重试防循环
|
||||
if (edu.directoryStatus === 'idle') edu.fetchDirectory();
|
||||
});
|
||||
onUnmounted(() => clearTimeout(searchTimer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="jp">
|
||||
<!-- 头部:标题 + 共 N 本(meta 与当前 Tab 匹配才回显,防切 Tab 瞬间旧计数) -->
|
||||
<header class="jp-head">
|
||||
<h1 class="jp-title">学术期刊与会议情报</h1>
|
||||
<span class="jp-count">共 {{ countText }} 本</span>
|
||||
</header>
|
||||
|
||||
<!-- 诚实性声明(黄字;两套口径按 Tab 切换) -->
|
||||
<p class="jp-note jp-note--warn">{{ sourceNote }}</p>
|
||||
|
||||
<!-- Tab:🌍 国际 / 🇨🇳 中文(切换全清筛选) -->
|
||||
<nav class="jp-tabs" role="tablist">
|
||||
<button
|
||||
class="jp-tab"
|
||||
:class="{ on: tab === 'en' }"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="tab === 'en'"
|
||||
@click="onTab('en')"
|
||||
>🌍 国际期刊</button>
|
||||
<button
|
||||
class="jp-tab"
|
||||
:class="{ on: tab === 'zh' }"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="tab === 'zh'"
|
||||
@click="onTab('zh')"
|
||||
>🇨🇳 中文期刊</button>
|
||||
</nav>
|
||||
|
||||
<!-- 搜索(防抖 300ms → q + page=1) -->
|
||||
<div class="jp-tools">
|
||||
<input
|
||||
v-model="searchInput"
|
||||
class="jp-search"
|
||||
type="search"
|
||||
placeholder="搜索期刊名(如 Nature、Plant Cell)"
|
||||
@input="onSearchInput"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏(domains 来自当前 lang 的 meta;分区下拉仅 en) -->
|
||||
<div v-if="domains.length" class="jp-filters">
|
||||
<div class="jp-fgroup">
|
||||
<span class="jp-flabel">研究方向</span>
|
||||
<select class="jp-select" :value="q.domain" @change="onDomainChange">
|
||||
<option value="">全部</option>
|
||||
<option v-for="d in domains" :key="d" :value="d">{{ d }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="tab === 'en'" class="jp-fgroup">
|
||||
<span class="jp-flabel">分区</span>
|
||||
<select class="jp-select" :value="q.cas_partition" @change="onCasChange">
|
||||
<option value="">全部</option>
|
||||
<option v-for="p in CAS_OPTIONS" :key="p" :value="p">{{ p }}区</option>
|
||||
</select>
|
||||
</div>
|
||||
<button v-if="hasFilter" class="jp-clear" type="button" @click="clearFilters">清除筛选</button>
|
||||
</div>
|
||||
|
||||
<!-- ERROR:directory 三态独立于 journals;reviewErrorMessage 已映射 -->
|
||||
<div v-if="edu.directoryStatus === 'error'" class="jp-state" role="alert">
|
||||
<p class="jp-state-msg">{{ edu.directoryError }}</p>
|
||||
<button class="jp-retry" type="button" @click="retry">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- LOADING:骨架行 -->
|
||||
<div v-else-if="edu.directoryStatus === 'loading'" class="jp-state" aria-busy="true">
|
||||
<div class="jp-skel-head"></div>
|
||||
<div v-for="n in 6" :key="n" class="jp-skel-row"></div>
|
||||
<p class="jp-state-msg">期刊目录加载中…</p>
|
||||
</div>
|
||||
|
||||
<!-- 空态 A:未导入(data_imported=false,HTTP 200 优雅降级路径) -->
|
||||
<div v-else-if="edu.directoryMeta && edu.directoryMeta.data_imported === false" class="jp-state">
|
||||
<p class="jp-state-msg">目录数据未导入(需先运行 review_service/scripts/import_journals.py)</p>
|
||||
</div>
|
||||
|
||||
<!-- 空态 B:有筛选 → 无匹配(≠ 无数据) -->
|
||||
<div v-else-if="!edu.directory.length && hasFilter" class="jp-state">
|
||||
<p class="jp-state-msg">无匹配结果,可清除筛选后重试</p>
|
||||
<button class="jp-retry" type="button" @click="clearFilters">清除筛选</button>
|
||||
</div>
|
||||
|
||||
<!-- 空态 C:兜底(已导入但整表为空) -->
|
||||
<div v-else-if="!edu.directory.length" class="jp-state">
|
||||
<p class="jp-state-msg">暂无期刊数据</p>
|
||||
</div>
|
||||
|
||||
<!-- READY + 表格(写交互 = 筛选/排序重查 + 分页 + 行展开;en/zh 各 5 列,末列官网) -->
|
||||
<table v-else class="jp-table">
|
||||
<thead>
|
||||
<tr v-if="tab === 'en'">
|
||||
<th class="jp-th-sort jp-c-name" :class="{ on: activeSort.field === 'name' }" @click="toggleSort('name')">
|
||||
期刊名 <span class="jp-arrow">{{ arrow('name') }}</span>
|
||||
</th>
|
||||
<th class="jp-th-sort jp-c-dir" :class="{ on: activeSort.field === 'research_direction' }" @click="toggleSort('research_direction')">
|
||||
研究方向 <span class="jp-arrow">{{ arrow('research_direction') }}</span>
|
||||
</th>
|
||||
<th class="jp-th-sort jp-c-part" :class="{ on: activeSort.field === 'cas_partition' }" @click="toggleSort('cas_partition')">
|
||||
分区 <span class="jp-arrow">{{ arrow('cas_partition') }}</span>
|
||||
</th>
|
||||
<th class="jp-th-sort jp-c-jif" :class="{ on: activeSort.field === 'jif' }" @click="toggleSort('jif')">
|
||||
JIF <span class="jp-arrow">{{ arrow('jif') }}</span>
|
||||
</th>
|
||||
<th class="jp-c-url">官网</th>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<th class="jp-th-sort jp-c-name" :class="{ on: activeSort.field === 'name' }" @click="toggleSort('name')">
|
||||
期刊名 <span class="jp-arrow">{{ arrow('name') }}</span>
|
||||
</th>
|
||||
<th class="jp-th-sort jp-c-dir" :class="{ on: activeSort.field === 'research_direction' }" @click="toggleSort('research_direction')">
|
||||
研究方向 <span class="jp-arrow">{{ arrow('research_direction') }}</span>
|
||||
</th>
|
||||
<th class="jp-th-sort jp-c-comp" :class="{ on: activeSort.field === 'composite_impact_factor' }" @click="toggleSort('composite_impact_factor')">
|
||||
复合IF <span class="jp-arrow">{{ arrow('composite_impact_factor') }}</span>
|
||||
</th>
|
||||
<th class="jp-th-sort jp-c-compr" :class="{ on: activeSort.field === 'comprehensive_impact_factor' }" @click="toggleSort('comprehensive_impact_factor')">
|
||||
综合IF <span class="jp-arrow">{{ arrow('comprehensive_impact_factor') }}</span>
|
||||
</th>
|
||||
<th class="jp-c-url">官网</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="j in edu.directory" :key="j.id">
|
||||
<tr class="jp-row" :class="{ open: expandedId === j.id }" @click="toggleExpand(j.id)">
|
||||
<!-- en:期刊名 / 研究方向 / 分区 / JIF(年) / 官网 -->
|
||||
<template v-if="tab === 'en'">
|
||||
<td class="jp-name jp-c-name">{{ j.name }}</td>
|
||||
<td class="jp-c-dir">{{ j.research_direction || '—' }}</td>
|
||||
<td class="jp-part jp-c-part">{{ j.cas_partition || '—' }}</td>
|
||||
<td class="jp-num jp-c-jif" :title="j.jif_note || ''">
|
||||
{{ fmtIf(j.jif, j.jif_year) }}
|
||||
</td>
|
||||
<td class="jp-c-url">
|
||||
<a v-if="j.official_url" class="jp-link" :href="j.official_url" target="_blank" rel="noopener" @click.stop>{{ shortUrl(j.official_url) }}</a>
|
||||
<span v-else class="jp-dash">—</span>
|
||||
</td>
|
||||
</template>
|
||||
<!-- zh:期刊名 / 研究方向 / 复合IF(统计年) / 综合IF / 官网 -->
|
||||
<template v-else>
|
||||
<td class="jp-name jp-c-name">{{ j.name }}</td>
|
||||
<td class="jp-c-dir">{{ j.research_direction || '—' }}</td>
|
||||
<td class="jp-num jp-c-comp" :title="j.jif_note || ''">{{ fmtIf(j.composite_impact_factor, j.metric_year) }}</td>
|
||||
<td class="jp-num jp-c-compr">{{ fmt(j.comprehensive_impact_factor) }}</td>
|
||||
<td class="jp-c-url">
|
||||
<a v-if="j.official_url" class="jp-link" :href="j.official_url" target="_blank" rel="noopener" @click.stop>{{ shortUrl(j.official_url) }}</a>
|
||||
<span v-else class="jp-dash">—</span>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<!-- 展开行(两 Tab 均 5 列) -->
|
||||
<tr v-if="expandedId === j.id" class="jp-xp-row">
|
||||
<td colspan="5">
|
||||
<!-- en 详情:年份/状态/凭证链接 -->
|
||||
<div v-if="tab === 'en'" class="jp-xp">
|
||||
<div class="jp-xp-group">
|
||||
<span class="jp-xp-label">分区年</span><span class="jp-xp-val">{{ fmt(j.cas_partition_year) }}</span>
|
||||
<span class="jp-xp-label">JIF年</span><span class="jp-xp-val">{{ fmt(j.jif_year) }}</span>
|
||||
<span class="jp-xp-label">JIF核对状态</span><span class="jp-xp-val" :class="checkCls(j.jif_check_status, JIF_OK)">{{ j.jif_check_status || '—' }}</span>
|
||||
<span class="jp-xp-label">Top</span><span class="jp-xp-val">{{ j.top_flag || '—' }}</span>
|
||||
</div>
|
||||
<div class="jp-xp-group">
|
||||
<span class="jp-xp-label">官网核对</span><span class="jp-xp-val" :class="checkCls(j.official_check_status, URL_OK)">{{ j.official_check_status || '—' }}</span>
|
||||
<span class="jp-xp-label">JCR发布年</span><span class="jp-xp-val">{{ fmt(j.jcr_release_year) }}</span>
|
||||
</div>
|
||||
<div class="jp-xp-group">
|
||||
<span class="jp-xp-label">出版系列</span><span class="jp-xp-val">{{ j.publisher_series || '—' }}</span>
|
||||
<span class="jp-xp-label">期刊标记</span><span class="jp-xp-val">{{ j.journal_mark || '—' }}</span>
|
||||
<span class="jp-xp-label">文章类型</span><span class="jp-xp-val">{{ j.article_type || '—' }}</span>
|
||||
</div>
|
||||
<div class="jp-xp-group">
|
||||
<span class="jp-xp-label">ISSN</span>
|
||||
<span class="jp-xp-val jp-mono">{{ j.issn || '—' }}<template v-if="j.eissn"> · eISSN {{ j.eissn }}</template></span>
|
||||
</div>
|
||||
<div class="jp-xp-group">
|
||||
<span class="jp-xp-label">JCR学科</span>
|
||||
<span v-for="d in j.jcr_disciplines || []" :key="d" class="jp-chip on">{{ d }}</span>
|
||||
<span v-if="!(j.jcr_disciplines || []).length" class="jp-dash">—</span>
|
||||
</div>
|
||||
<div class="jp-xp-group">
|
||||
<span class="jp-xp-label">来源</span>
|
||||
<a v-if="j.jif_source_url" class="jp-link" :href="j.jif_source_url" target="_blank" rel="noopener" @click.stop>
|
||||
JIF来源:{{ shortUrl(j.jif_source_url) }}
|
||||
</a>
|
||||
<span v-else class="jp-dash">JIF来源:无</span>
|
||||
<a v-if="j.partition_source_url" class="jp-link" :href="j.partition_source_url" target="_blank" rel="noopener" @click.stop>
|
||||
分区来源:{{ shortUrl(j.partition_source_url) }}
|
||||
</a>
|
||||
<span v-else class="jp-dash">分区来源:无</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- zh 详情:年报口径指标 + 不可混排黄字 -->
|
||||
<div v-else class="jp-xp">
|
||||
<div class="jp-xp-group">
|
||||
<span class="jp-xp-label">复合IF</span><span class="jp-xp-val jp-mono">{{ fmt(j.composite_impact_factor) }}</span>
|
||||
<span class="jp-xp-label">综合IF</span><span class="jp-xp-val jp-mono">{{ fmt(j.comprehensive_impact_factor) }}</span>
|
||||
<span class="jp-xp-label">统计年</span><span class="jp-xp-val">{{ fmt(j.metric_year) }}</span>
|
||||
</div>
|
||||
<div class="jp-xp-group">
|
||||
<span class="jp-xp-label">年报版本</span><span class="jp-xp-val">{{ j.metric_report_year ? `${j.metric_report_year}版` : '—' }}</span>
|
||||
<span class="jp-xp-label">年报页码</span><span class="jp-xp-val">{{ j.annual_report_page ? `第${j.annual_report_page}页` : '—' }}</span>
|
||||
<span class="jp-xp-label">核对状态</span><span class="jp-xp-val" :class="checkCls(j.jif_check_status, JIF_OK)">{{ j.jif_check_status || '—' }}</span>
|
||||
</div>
|
||||
<div class="jp-xp-group">
|
||||
<span class="jp-xp-label">官网核对</span><span class="jp-xp-val" :class="checkCls(j.official_check_status, URL_OK)">{{ j.official_check_status || '—' }}</span>
|
||||
</div>
|
||||
<div class="jp-xp-group">
|
||||
<span class="jp-xp-label">指标口径</span><span class="jp-xp-val">{{ j.metric_caliber || '—' }}</span>
|
||||
</div>
|
||||
<div class="jp-xp-group">
|
||||
<span class="jp-xp-label">来源</span>
|
||||
<a v-if="j.data_source_url" class="jp-link" :href="j.data_source_url" target="_blank" rel="noopener" @click.stop>
|
||||
年报原页:{{ shortUrl(j.data_source_url) }}
|
||||
</a>
|
||||
<span v-else class="jp-dash">年报原页:无</span>
|
||||
<a v-if="j.jif_source_url" class="jp-link" :href="j.jif_source_url" target="_blank" rel="noopener" @click.stop>
|
||||
JIF来源:{{ shortUrl(j.jif_source_url) }}
|
||||
</a>
|
||||
<span v-else class="jp-dash">JIF来源:无</span>
|
||||
<a v-if="j.partition_source_url" class="jp-link" :href="j.partition_source_url" target="_blank" rel="noopener" @click.stop>
|
||||
分区来源:{{ shortUrl(j.partition_source_url) }}
|
||||
</a>
|
||||
<span v-else class="jp-dash">分区来源:无</span>
|
||||
</div>
|
||||
<p class="jp-xp-note">⚠ 国内IF与JIF统计源不同,不可混排</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 分页(后端分页;total 超过 size 才显示) -->
|
||||
<footer v-if="edu.directoryStatus === 'ready' && totalPages > 1" class="jp-pager">
|
||||
<button class="jp-page-btn" type="button" :disabled="q.page <= 1" @click="gotoPage(q.page - 1)">‹ 上一页</button>
|
||||
<span class="jp-page-info">第 {{ q.page }} / {{ totalPages }} 页</span>
|
||||
<button class="jp-page-btn" type="button" :disabled="q.page >= totalPages" @click="gotoPage(q.page + 1)">下一页 ›</button>
|
||||
<label class="jp-page-jump">
|
||||
跳至
|
||||
<input v-model="jumpInput" class="jp-page-input" type="number" min="1" :max="totalPages" @change="jumpPage">
|
||||
页
|
||||
</label>
|
||||
<span class="jp-page-size">每页 {{ q.size }} 条</span>
|
||||
</footer>
|
||||
|
||||
<!-- 底部数据源声明(表C 长版,拍板决策3;双 Tab 统一) -->
|
||||
<p class="jp-foot">{{ FOOT_NOTE }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.jp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.jp-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.jp-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.jp-count {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.jp-note {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.jp-note--warn {
|
||||
color: var(--pa-warning);
|
||||
}
|
||||
/* —— Tab —— */
|
||||
.jp-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.jp-tab {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--pa-hairline-strong);
|
||||
border-radius: var(--pa-r-pill);
|
||||
background: var(--pa-surface-1);
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition: background .15s var(--pa-ease), color .15s var(--pa-ease), border-color .15s var(--pa-ease);
|
||||
}
|
||||
.jp-tab:hover {
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.jp-tab.on {
|
||||
background: color-mix(in srgb, var(--pa-g-from) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--pa-g-from) 50%, transparent);
|
||||
color: var(--pa-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* —— 搜索 —— */
|
||||
.jp-tools {
|
||||
display: flex;
|
||||
}
|
||||
.jp-search {
|
||||
width: min(360px, 100%);
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--pa-hairline-strong);
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-1);
|
||||
color: var(--pa-ink);
|
||||
font-size: 13px;
|
||||
}
|
||||
.jp-search:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--pa-g-from) 55%, transparent);
|
||||
}
|
||||
.jp-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 0;
|
||||
}
|
||||
.jp-state-msg {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.jp-retry {
|
||||
align-self: flex-start;
|
||||
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;
|
||||
transition: background .15s var(--pa-ease);
|
||||
}
|
||||
.jp-retry:hover {
|
||||
background: var(--pa-surface-3);
|
||||
}
|
||||
.jp-skel-head,
|
||||
.jp-skel-row {
|
||||
border-radius: var(--pa-r-sm);
|
||||
background: var(--pa-surface-2);
|
||||
animation: jp-pulse 1.2s var(--pa-ease) infinite alternate;
|
||||
}
|
||||
.jp-skel-head {
|
||||
height: 34px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.jp-skel-row {
|
||||
height: 30px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@keyframes jp-pulse {
|
||||
from { opacity: .55; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.jp-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.jp-table thead th {
|
||||
padding: 9px 12px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--pa-ink-muted);
|
||||
background: var(--pa-surface-1);
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.jp-th-sort {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color .15s var(--pa-ease);
|
||||
}
|
||||
.jp-th-sort:hover {
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.jp-th-sort.on {
|
||||
color: var(--pa-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.jp-arrow {
|
||||
font-size: 10px;
|
||||
color: var(--pa-g-from);
|
||||
}
|
||||
.jp-table tbody td {
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.jp-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.jp-table tbody tr:hover {
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.jp-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.jp-num {
|
||||
text-align: center;
|
||||
font-family: var(--pa-font-mono);
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.jp-link {
|
||||
color: var(--pa-g-from);
|
||||
text-decoration: none;
|
||||
}
|
||||
.jp-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.jp-dash {
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
/* —— 列宽白名单(table 自适应 + min-width,不硬截断)—— */
|
||||
.jp-c-name { min-width: 200px; }
|
||||
.jp-c-dir { min-width: 140px; }
|
||||
.jp-c-part { min-width: 64px; text-align: center; }
|
||||
.jp-c-jif { min-width: 110px; }
|
||||
.jp-c-url { min-width: 180px; }
|
||||
.jp-c-comp { min-width: 110px; }
|
||||
.jp-c-compr { min-width: 110px; }
|
||||
/* —— 凭证状态配色:可靠=绿 / 未逐站复核=浅绿 / 未检得·待核实=橙 —— */
|
||||
.jp-ok {
|
||||
color: var(--pa-success);
|
||||
font-weight: 500;
|
||||
}
|
||||
.jp-ok-soft {
|
||||
color: color-mix(in srgb, var(--pa-success) 60%, var(--pa-ink));
|
||||
}
|
||||
.jp-warn {
|
||||
color: var(--pa-warning);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* —— 筛选栏 —— */
|
||||
.jp-filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
}
|
||||
.jp-fgroup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.jp-flabel {
|
||||
flex: none;
|
||||
width: 56px;
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.jp-select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--pa-hairline-strong);
|
||||
border-radius: var(--pa-r-sm);
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
.jp-clear {
|
||||
align-self: flex-start;
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-3);
|
||||
color: var(--pa-ink);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background .15s var(--pa-ease);
|
||||
}
|
||||
.jp-clear:hover {
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
/* —— 行展开 —— */
|
||||
.jp-part {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
.jp-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
.jp-row.open {
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.jp-xp-row td {
|
||||
background: var(--pa-surface-2);
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.jp-xp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
.jp-xp-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.jp-xp-label {
|
||||
flex: none;
|
||||
width: 56px;
|
||||
font-size: 11px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.jp-xp-val {
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.jp-mono {
|
||||
font-family: var(--pa-font-mono);
|
||||
}
|
||||
.jp-xp-note {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--pa-warning);
|
||||
}
|
||||
.jp-chip {
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--pa-hairline-strong);
|
||||
border-radius: var(--pa-r-pill);
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.jp-chip.on {
|
||||
background: color-mix(in srgb, var(--pa-g-from) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--pa-g-from) 50%, transparent);
|
||||
color: var(--pa-ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* —— 分页 —— */
|
||||
.jp-pager {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.jp-page-btn {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--pa-hairline-strong);
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-1);
|
||||
color: var(--pa-ink);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background .15s var(--pa-ease);
|
||||
}
|
||||
.jp-page-btn:hover:not(:disabled) {
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.jp-page-btn:disabled {
|
||||
opacity: .4;
|
||||
cursor: default;
|
||||
}
|
||||
.jp-page-info {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.jp-page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.jp-page-input {
|
||||
width: 56px;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--pa-hairline-strong);
|
||||
border-radius: var(--pa-r-sm);
|
||||
background: var(--pa-surface-1);
|
||||
font-size: 12px;
|
||||
}
|
||||
.jp-page-size {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
/* —— 底部数据源声明(表C 长版) —— */
|
||||
.jp-foot {
|
||||
margin: 0;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--pa-hairline);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,614 @@
|
||||
<!-- PresubmitPanel.vue —— ③ AI 预审稿系统(步骤8,产品核心功能)。
|
||||
链路:本地 PDF(form-data)→ edu.submitPresubmit(fd, {onUploadProgress})
|
||||
→ POST /api/review/presubmit(file + target_journal;≤50MB,vstore 解析 + 六维 LLM)
|
||||
→ {ok, report{summary, major/minor_weaknesses[], presentation_format[],
|
||||
questions_for_authors[], recommendation}, meta{filename,target_journal,text_chars,truncated,model}}
|
||||
(真实响应结构写入前已 curl 8100 实测;self_review.py 六维 prompt 不动,本组件纯展示)。
|
||||
优先级:edu.report 存在 → 结果区(不退回上传区);否则 loading > error > 上传区。
|
||||
结果/targetJournal 草稿存 store 跨菜单保留;PDF 文件为面板本地 ref(切菜单需重选)。
|
||||
http.js FormData+onUploadProgress → XHR 分支,上传阶段显示真实百分比。 -->
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useEduStore } from '../../../stores/edu.js';
|
||||
|
||||
const edu = useEduStore();
|
||||
|
||||
const MAX_PDF = 50 * 1024 * 1024;
|
||||
const REC = {
|
||||
accept: { label: '接收', cls: 'ok' },
|
||||
minor_revision: { label: '小修', cls: 'minor' },
|
||||
major_revision: { label: '大修', cls: 'major' },
|
||||
reject: { label: '拒稿', cls: 'reject' },
|
||||
};
|
||||
|
||||
const fileInput = ref(null);
|
||||
const file = ref(null); // File 面板本地(不进 store)
|
||||
const fileError = ref('');
|
||||
const dragging = ref(false);
|
||||
const uploadPct = ref(0);
|
||||
|
||||
const hasReport = computed(() => !!(edu.report && edu.report.report));
|
||||
const loading = computed(() => edu.reportStatus === 'loading');
|
||||
const errored = computed(() => edu.reportStatus === 'error');
|
||||
const report = computed(() => edu.report?.report || null);
|
||||
const meta = computed(() => edu.report?.meta || null);
|
||||
const rec = computed(() => REC[report.value?.recommendation] || REC.major_revision);
|
||||
|
||||
/** 上传阶段(pct<1)显示真实百分比;上传完成后是解析 + LLM 长等待 */
|
||||
const phaseText = computed(() =>
|
||||
uploadPct.value < 1
|
||||
? `上传中 ${Math.round(uploadPct.value * 100)}%`
|
||||
: '审稿生成中…(约 15–60 秒,PDF 解析 + 六维 LLM)'
|
||||
);
|
||||
|
||||
const fmtSize = (b) => (b / 1048576).toFixed(1) + ' MB';
|
||||
|
||||
function acceptFile(f) {
|
||||
fileError.value = '';
|
||||
if (!f) return;
|
||||
if (!String(f.name || '').toLowerCase().endsWith('.pdf')) {
|
||||
fileError.value = '仅支持 PDF 文件';
|
||||
return;
|
||||
}
|
||||
if (!f.size) {
|
||||
fileError.value = '上传文件为空';
|
||||
return;
|
||||
}
|
||||
if (f.size > MAX_PDF) {
|
||||
fileError.value = 'PDF 超过 50MB 上限';
|
||||
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) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file.value); // 浏览器自动 multipart boundary(http.js 不设 Content-Type)
|
||||
fd.append('target_journal', (edu.drafts.targetJournal || '').trim());
|
||||
uploadPct.value = 0;
|
||||
await edu.submitPresubmit(fd, {
|
||||
onUploadProgress: (e) => {
|
||||
uploadPct.value = e.pct;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 重新上传(结果区)/ 重新选择(错误区):清结果回上传区,保留 targetJournal 草稿 */
|
||||
function reset() {
|
||||
edu.clearReport();
|
||||
file.value = null;
|
||||
uploadPct.value = 0;
|
||||
fileError.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pp">
|
||||
<header class="pp-head">
|
||||
<h1 class="pp-title">AI 预审稿系统</h1>
|
||||
<span class="pp-sub">植物基因组学六维清单 · PDF ≤ 50MB</span>
|
||||
</header>
|
||||
|
||||
<!-- ========== ① 有结果 → 结果区(最高优先级,不退回上传区) ========== -->
|
||||
<template v-if="hasReport">
|
||||
<div v-if="meta" class="pp-meta">
|
||||
📄 {{ meta.filename }}
|
||||
<template v-if="meta.target_journal"> · 目标期刊 {{ meta.target_journal }}</template>
|
||||
· {{ meta.text_chars }} 字符
|
||||
<span v-if="meta.truncated" class="pp-truncated">· 正文超限已截断至 6 万字符</span>
|
||||
</div>
|
||||
|
||||
<div class="pp-topline">
|
||||
<span class="pp-rec" :class="rec.cls">{{ rec.label }}</span>
|
||||
<button class="pp-again" type="button" @click="reset">重新上传</button>
|
||||
</div>
|
||||
|
||||
<p class="pp-summary">{{ report.summary }}</p>
|
||||
|
||||
<!-- 主要问题(重视觉) -->
|
||||
<h2 class="pp-h2">主要问题({{ report.major_weaknesses.length }})</h2>
|
||||
<article
|
||||
v-for="(it, i) in report.major_weaknesses"
|
||||
:key="'maj-' + i"
|
||||
class="pp-card major"
|
||||
>
|
||||
<div class="pp-card-title">
|
||||
<span class="pp-idx">{{ i + 1 }}</span>{{ it.title }}
|
||||
</div>
|
||||
<p class="pp-detail">{{ it.detail }}</p>
|
||||
<div v-if="it.evidence" class="pp-evidence">
|
||||
<span class="pp-tag">证据</span><code class="pp-evi-text">{{ it.evidence }}</code>
|
||||
</div>
|
||||
<div v-if="it.suggestion" class="pp-suggestion">
|
||||
<span class="pp-tag">建议</span>{{ it.suggestion }}
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!report.major_weaknesses.length" class="pp-empty">无主要问题</p>
|
||||
|
||||
<!-- 次要问题(轻视觉) -->
|
||||
<h2 class="pp-h2">次要问题({{ report.minor_weaknesses.length }})</h2>
|
||||
<article
|
||||
v-for="(it, i) in report.minor_weaknesses"
|
||||
:key="'min-' + i"
|
||||
class="pp-card minor"
|
||||
>
|
||||
<div class="pp-card-title">
|
||||
<span class="pp-idx light">{{ i + 1 }}</span>{{ it.title }}
|
||||
</div>
|
||||
<p class="pp-detail light">{{ it.detail }}</p>
|
||||
<div v-if="it.evidence" class="pp-evidence light">
|
||||
<span class="pp-tag">证据</span><code class="pp-evi-text">{{ it.evidence }}</code>
|
||||
</div>
|
||||
<div v-if="it.suggestion" class="pp-suggestion light">
|
||||
<span class="pp-tag">建议</span>{{ it.suggestion }}
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!report.minor_weaknesses.length" class="pp-empty">无次要问题</p>
|
||||
|
||||
<!-- 排版与规范(默认折叠) -->
|
||||
<details class="pp-details">
|
||||
<summary>排版与规范({{ report.presentation_format.length }})</summary>
|
||||
<article
|
||||
v-for="(it, i) in report.presentation_format"
|
||||
:key="'pre-' + i"
|
||||
class="pp-card minor"
|
||||
>
|
||||
<div class="pp-card-title">
|
||||
<span class="pp-idx light">{{ i + 1 }}</span>{{ it.title }}
|
||||
</div>
|
||||
<p class="pp-detail light">{{ it.detail }}</p>
|
||||
<div v-if="it.evidence" class="pp-evidence light">
|
||||
<span class="pp-tag">证据</span><code class="pp-evi-text">{{ it.evidence }}</code>
|
||||
</div>
|
||||
<div v-if="it.suggestion" class="pp-suggestion light">
|
||||
<span class="pp-tag">建议</span>{{ it.suggestion }}
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!report.presentation_format.length" class="pp-empty">无排版类意见</p>
|
||||
</details>
|
||||
|
||||
<!-- 给作者的问题 -->
|
||||
<h2 class="pp-h2">给作者的问题({{ report.questions_for_authors.length }})</h2>
|
||||
<ol class="pp-questions">
|
||||
<li v-for="(q, i) in report.questions_for_authors" :key="'q-' + i">{{ q }}</li>
|
||||
</ol>
|
||||
<p v-if="!report.questions_for_authors.length" class="pp-empty">无</p>
|
||||
</template>
|
||||
|
||||
<!-- ========== ② loading:上传进度 / 审稿长等待 ========== -->
|
||||
<div v-else-if="loading" class="pp-state" aria-busy="true">
|
||||
<div class="pp-bar">
|
||||
<div class="pp-bar-fill" :style="{ width: Math.round(uploadPct * 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="pp-skel"></div>
|
||||
<div class="pp-skel pp-skel-mid"></div>
|
||||
<p class="pp-state-msg">{{ phaseText }}</p>
|
||||
</div>
|
||||
|
||||
<!-- ========== ③ error ========== -->
|
||||
<div v-else-if="errored" class="pp-state" role="alert">
|
||||
<p class="pp-state-msg pp-state-error">{{ edu.reportError }}</p>
|
||||
<div class="pp-err-btns">
|
||||
<button v-if="file" class="pp-retry" type="button" @click="submit">重试</button>
|
||||
<button class="pp-retry" type="button" @click="reset">重新选择文件</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== ④ 上传区(report 为 null 时才显示) ========== -->
|
||||
<div v-else class="pp-upload">
|
||||
<div
|
||||
class="pp-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="pp-file-input"
|
||||
type="file"
|
||||
accept=".pdf,application/pdf"
|
||||
@click.stop
|
||||
@change="onPick"
|
||||
/>
|
||||
<template v-if="file">
|
||||
<span class="pp-file-name">📄 {{ file.name }} · {{ fmtSize(file.size) }}</span>
|
||||
<button class="pp-remove" type="button" title="移除文件" @click.stop="removeFile">×</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="pp-drop-main">拖拽 PDF 到此处,或点击选择</span>
|
||||
<span class="pp-hint">仅支持 PDF · ≤ 50MB</span>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="fileError" class="pp-file-error">{{ fileError }}</p>
|
||||
|
||||
<label class="pp-label" for="pp-tj">目标期刊(选填,影响格式类意见)</label>
|
||||
<input
|
||||
id="pp-tj"
|
||||
v-model="edu.drafts.targetJournal"
|
||||
class="pp-input"
|
||||
type="text"
|
||||
placeholder="如 The Plant Cell(留空则不限定)"
|
||||
/>
|
||||
|
||||
<button class="pp-submit" type="button" :disabled="!file || loading" @click="submit">
|
||||
{{ loading ? phaseText : '开始预审稿' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.pp-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.pp-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.pp-sub {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.pp-meta {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.pp-truncated {
|
||||
color: var(--pa-warning);
|
||||
}
|
||||
.pp-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.pp-rec {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 14px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pp-rec.ok { color: var(--pa-success); }
|
||||
.pp-rec.minor { color: var(--pa-info); }
|
||||
.pp-rec.major { color: var(--pa-warning); }
|
||||
.pp-rec.reject { color: var(--pa-error); }
|
||||
.pp-again {
|
||||
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;
|
||||
}
|
||||
.pp-again:hover {
|
||||
color: var(--pa-ink);
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.pp-summary {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.pp-h2 {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.pp-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--pa-r-md);
|
||||
}
|
||||
.pp-card.major {
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline-strong);
|
||||
}
|
||||
.pp-card.minor {
|
||||
background: transparent;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.pp-card-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.pp-idx {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--pa-surface-3);
|
||||
color: var(--pa-ink);
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.pp-idx.light {
|
||||
background: transparent;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.pp-detail {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.pp-detail.light {
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.pp-evidence {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--pa-code-bg);
|
||||
border-radius: var(--pa-r-sm);
|
||||
}
|
||||
.pp-evidence.light {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--pa-hairline);
|
||||
}
|
||||
.pp-evi-text {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--pa-ink);
|
||||
white-space: normal;
|
||||
}
|
||||
.pp-evidence.light .pp-evi-text {
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.pp-suggestion {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.pp-suggestion.light {
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.pp-tag {
|
||||
flex: none;
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--pa-r-sm);
|
||||
background: var(--pa-surface-3);
|
||||
font-size: 11px;
|
||||
color: var(--pa-ink-muted);
|
||||
vertical-align: 1px;
|
||||
}
|
||||
.pp-empty {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.pp-details {
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.pp-details summary {
|
||||
cursor: pointer;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.pp-details summary:hover {
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.pp-details[open] summary {
|
||||
margin-bottom: 10px;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.pp-questions {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.pp-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.pp-bar {
|
||||
height: 6px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
background: var(--pa-surface-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.pp-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--pa-g-from);
|
||||
transition: width .2s var(--pa-ease);
|
||||
}
|
||||
.pp-skel {
|
||||
height: 56px;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-2);
|
||||
animation: pp-pulse 1.2s var(--pa-ease) infinite alternate;
|
||||
}
|
||||
.pp-skel-mid {
|
||||
height: 88px;
|
||||
}
|
||||
@keyframes pp-pulse {
|
||||
from { opacity: .55; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.pp-state-msg {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.pp-state-error {
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.pp-err-btns {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.pp-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;
|
||||
}
|
||||
.pp-retry:hover {
|
||||
background: var(--pa-surface-3);
|
||||
}
|
||||
.pp-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.pp-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);
|
||||
}
|
||||
.pp-drop:hover,
|
||||
.pp-drop.dragging {
|
||||
border-color: var(--pa-g-from);
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.pp-drop.filled {
|
||||
border-style: solid;
|
||||
}
|
||||
.pp-file-input {
|
||||
display: none;
|
||||
}
|
||||
.pp-drop-main {
|
||||
font-size: 13.5px;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.pp-hint {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.pp-file-name {
|
||||
font-size: 13.5px;
|
||||
color: var(--pa-ink);
|
||||
word-break: break-all;
|
||||
}
|
||||
.pp-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;
|
||||
}
|
||||
.pp-remove:hover {
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.pp-file-error {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.pp-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.pp-input {
|
||||
width: 320px;
|
||||
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;
|
||||
}
|
||||
.pp-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--pa-g-from);
|
||||
}
|
||||
.pp-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;
|
||||
}
|
||||
.pp-submit:disabled {
|
||||
opacity: .45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,507 @@
|
||||
<!-- RecommendPanel.vue —— ② 智能投稿推荐(步骤7,产品核心功能)。
|
||||
链路:表单草稿 → edu.submitRecommend(payload) → POST /api/review/recommend(JSON)
|
||||
→ {ok, tiers{challenge,fit,safe}{label,items}, meta{vector_ok,llm_ok,...}}
|
||||
(真实响应结构已于写入前 curl 8100 核对,按真实字段渲染)。
|
||||
纯文本粘贴版(PDF 上传后续迭代);目标期刊字段首版砍掉(后端无此入参)。
|
||||
tier chips 为纯前端结果筛选(不改 payload 语义);结果与草稿存 edu store,
|
||||
切菜单往返不丢、mount 无请求。D2 同样只读。 -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useEduStore } from '../../../stores/edu.js';
|
||||
|
||||
const edu = useEduStore();
|
||||
|
||||
const TIERS = ['challenge', 'fit', 'safe'];
|
||||
const FILTERS = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'challenge', label: '挑战' },
|
||||
{ key: 'fit', label: '适配' },
|
||||
{ key: 'safe', label: '稳妥' },
|
||||
];
|
||||
|
||||
const abstractLen = computed(() => edu.drafts.abstract.trim().length);
|
||||
const abstractOk = computed(() => abstractLen.value >= 50);
|
||||
const loading = computed(() => edu.recommendStatus === 'loading');
|
||||
const errored = computed(() => edu.recommendStatus === 'error');
|
||||
const result = computed(() =>
|
||||
edu.recommend && edu.recommend.ok && edu.recommend.tiers ? edu.recommend : null
|
||||
);
|
||||
const meta = computed(() => result.value?.meta || null);
|
||||
const visibleTiers = computed(() => {
|
||||
if (!result.value) return [];
|
||||
const f = edu.drafts.tierFilter;
|
||||
return TIERS.filter((k) => f === 'all' || f === k);
|
||||
});
|
||||
const itemsOf = (key) => result.value?.tiers?.[key]?.items || [];
|
||||
const labelOf = (key) => result.value?.tiers?.[key]?.label || key;
|
||||
|
||||
function setFilter(k) {
|
||||
edu.drafts.tierFilter = k;
|
||||
}
|
||||
|
||||
/** prefs(同构草稿)→ preferences(接口字段):null 数值剔除,tier_focus 恒 'all'(chips 纯前端筛选) */
|
||||
function buildPayload() {
|
||||
const { max_review_weeks: w, min_impact_factor: inf, tier_focus: focus } = edu.drafts.prefs;
|
||||
const preferences = { tier_focus: focus || 'all' };
|
||||
const num = (v) => (v === null || v === undefined || v === '' || Number.isNaN(Number(v)) ? null : Number(v));
|
||||
const nw = num(w);
|
||||
const ninf = num(inf);
|
||||
// 本地钳制到接口 pydantic 范围(越界会触发 FastAPI 422 非信封响应)
|
||||
if (nw !== null) preferences.max_review_weeks = Math.min(104, Math.max(1, nw));
|
||||
if (ninf !== null) preferences.min_impact_factor = Math.min(200, Math.max(0, ninf));
|
||||
return { abstract: edu.drafts.abstract.trim(), preferences };
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!abstractOk.value || loading.value) return;
|
||||
await edu.submitRecommend(buildPayload());
|
||||
}
|
||||
|
||||
const fmt = (v) => (v === null || v === undefined ? '—' : v);
|
||||
const riskClass = (note) => {
|
||||
if (!note || note === '无') return 'none';
|
||||
return String(note).includes('⚠') ? 'warn' : '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rp">
|
||||
<header class="rp-head">
|
||||
<h1 class="rp-title">智能投稿推荐</h1>
|
||||
<span class="rp-sub">向量匹配期刊范围 + LLM 三档重排</span>
|
||||
</header>
|
||||
|
||||
<!-- ========== 表单区(草稿存 edu.drafts,切菜单不丢) ========== -->
|
||||
<div class="rp-form">
|
||||
<label class="rp-label" for="rp-abstract">
|
||||
论文摘要 <span class="rp-req">必填</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="rp-abstract"
|
||||
v-model="edu.drafts.abstract"
|
||||
class="rp-textarea"
|
||||
rows="7"
|
||||
maxlength="20000"
|
||||
placeholder="粘贴论文摘要(≥50 字符),或粘贴全文前 500 字"
|
||||
></textarea>
|
||||
<div class="rp-count" :class="{ ok: abstractOk }">
|
||||
{{ edu.drafts.abstract.trim().length }} / 20000 字符
|
||||
<span v-if="!abstractOk">· 至少需要 50 字符</span>
|
||||
</div>
|
||||
|
||||
<div class="rp-prefs">
|
||||
<div class="rp-field">
|
||||
<label class="rp-label" for="rp-weeks">最大审稿周期(周,选填)</label>
|
||||
<input
|
||||
id="rp-weeks"
|
||||
v-model.number="edu.drafts.prefs.max_review_weeks"
|
||||
class="rp-input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="104"
|
||||
placeholder="如 8"
|
||||
/>
|
||||
</div>
|
||||
<div class="rp-field">
|
||||
<label class="rp-label" for="rp-if">最低影响因子(选填)</label>
|
||||
<input
|
||||
id="rp-if"
|
||||
v-model.number="edu.drafts.prefs.min_impact_factor"
|
||||
class="rp-input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="200"
|
||||
step="0.1"
|
||||
placeholder="如 3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="rp-submit"
|
||||
type="button"
|
||||
:disabled="!abstractOk || loading"
|
||||
@click="submit"
|
||||
>
|
||||
{{ loading ? '推荐生成中…(约 10–30 秒)' : '开始推荐' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ========== 状态区 ========== -->
|
||||
<div v-if="loading" class="rp-state" aria-busy="true">
|
||||
<div class="rp-skel"></div>
|
||||
<div class="rp-skel rp-skel-mid"></div>
|
||||
<div class="rp-skel"></div>
|
||||
<p class="rp-state-msg">正在进行向量匹配与 LLM 重排,请稍候…</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="errored" class="rp-state" role="alert">
|
||||
<p class="rp-state-msg rp-state-error">{{ edu.recommendError }}</p>
|
||||
<button class="rp-retry" type="button" @click="submit">重试</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!result" class="rp-state">
|
||||
<p class="rp-state-msg">粘贴摘要后点击「开始推荐」:从期刊库语义匹配候选,按挑战/适配/稳妥三档给出匹配理由与风险提示。</p>
|
||||
</div>
|
||||
|
||||
<!-- ========== 结果区 ========== -->
|
||||
<template v-else>
|
||||
<!-- 降级横幅(meta 标志,非报错红) -->
|
||||
<div v-if="meta && (!meta.vector_ok || !meta.llm_ok)" class="rp-degraded">
|
||||
{{ !meta.llm_ok ? 'LLM 不可用:以下为影响因子/周期规则分档' : '向量匹配降级:按全量期刊范围重排' }}
|
||||
</div>
|
||||
|
||||
<!-- 结果元信息 -->
|
||||
<div v-if="meta" class="rp-meta">
|
||||
候选 {{ meta.candidate_count }} / 种子 {{ meta.seed_count }} 本
|
||||
· 幻觉丢弃 {{ meta.dropped_hallucinated }} · 规则补位 {{ meta.repaired }}
|
||||
</div>
|
||||
|
||||
<!-- 档位 chips(纯前端筛选,不重查) -->
|
||||
<div class="rp-chips" role="tablist" aria-label="档位筛选">
|
||||
<button
|
||||
v-for="f in FILTERS"
|
||||
:key="f.key"
|
||||
class="rp-chip"
|
||||
:class="{ on: edu.drafts.tierFilter === f.key }"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="edu.drafts.tierFilter === f.key"
|
||||
@click="setFilter(f.key)"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-for="key in visibleTiers" :key="key" class="rp-tier">
|
||||
<h2 class="rp-tier-head">
|
||||
<span class="rp-dot" :class="key" aria-hidden="true"></span>
|
||||
{{ labelOf(key) }}
|
||||
<span class="rp-tier-count">{{ itemsOf(key).length }} 本</span>
|
||||
</h2>
|
||||
|
||||
<p v-if="!itemsOf(key).length" class="rp-empty">本档暂无期刊</p>
|
||||
|
||||
<article v-for="it in itemsOf(key)" :key="it.id" class="rp-card">
|
||||
<header class="rp-card-head">
|
||||
<span class="rp-card-name">{{ it.name }}</span>
|
||||
<span class="rp-card-metrics">
|
||||
<span class="rp-metric">IF {{ fmt(it.impact_factor) }}</span>
|
||||
<span class="rp-metric">约 {{ fmt(it.review_time_weeks) }} 周</span>
|
||||
<a
|
||||
v-if="it.official_url"
|
||||
class="rp-link"
|
||||
:href="it.official_url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>官网 ↗</a>
|
||||
</span>
|
||||
</header>
|
||||
<p v-if="it.similarity !== null && it.similarity !== undefined" class="rp-sim">
|
||||
语义匹配 {{ Number(it.similarity).toFixed(2) }}
|
||||
</p>
|
||||
<p class="rp-reason">
|
||||
<span class="rp-k">匹配理由</span>{{ it.match_reason }}
|
||||
</p>
|
||||
<p class="rp-risk" :class="riskClass(it.risk_note)">
|
||||
<span class="rp-k">风险</span>{{ it.risk_note }}
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.rp-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.rp-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rp-sub {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rp-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
}
|
||||
.rp-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rp-req {
|
||||
margin-left: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--pa-warning);
|
||||
}
|
||||
.rp-textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink);
|
||||
font-family: var(--pa-font-ui);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.rp-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--pa-g-from);
|
||||
}
|
||||
.rp-count {
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
text-align: right;
|
||||
}
|
||||
.rp-count.ok {
|
||||
color: var(--pa-success);
|
||||
}
|
||||
.rp-prefs {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rp-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.rp-input {
|
||||
width: 150px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink);
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
.rp-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--pa-g-from);
|
||||
}
|
||||
.rp-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;
|
||||
transition: opacity .15s var(--pa-ease);
|
||||
}
|
||||
.rp-submit:disabled {
|
||||
opacity: .45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.rp-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.rp-skel {
|
||||
height: 56px;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-2);
|
||||
animation: rp-pulse 1.2s var(--pa-ease) infinite alternate;
|
||||
}
|
||||
.rp-skel-mid {
|
||||
height: 88px;
|
||||
}
|
||||
@keyframes rp-pulse {
|
||||
from { opacity: .55; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.rp-state-msg {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rp-state-error {
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.rp-retry {
|
||||
align-self: flex-start;
|
||||
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;
|
||||
}
|
||||
.rp-retry:hover {
|
||||
background: var(--pa-surface-3);
|
||||
}
|
||||
.rp-degraded {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-2);
|
||||
border-left: 3px solid var(--pa-warning);
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-warning);
|
||||
}
|
||||
.rp-meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rp-chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.rp-chip {
|
||||
padding: 4px 14px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-pill);
|
||||
background: transparent;
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color .15s var(--pa-ease), background .15s var(--pa-ease);
|
||||
}
|
||||
.rp-chip:hover {
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rp-chip.on {
|
||||
color: #fff;
|
||||
background: var(--pa-g-from);
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rp-tier {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.rp-tier-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rp-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.rp-dot.challenge { background: var(--pa-warning); }
|
||||
.rp-dot.fit { background: var(--pa-info); }
|
||||
.rp-dot.safe { background: var(--pa-success); }
|
||||
.rp-tier-count {
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rp-empty {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rp-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
transition: background .15s var(--pa-ease);
|
||||
}
|
||||
.rp-card:hover {
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.rp-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rp-card-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rp-card-metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.rp-metric {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rp-link {
|
||||
font-size: 12px;
|
||||
color: var(--pa-g-from);
|
||||
text-decoration: none;
|
||||
}
|
||||
.rp-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.rp-sim {
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rp-reason,
|
||||
.rp-risk {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.rp-reason {
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rp-risk {
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rp-risk.warn {
|
||||
color: var(--pa-warning);
|
||||
}
|
||||
.rp-risk.none {
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rp-k {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--pa-r-sm);
|
||||
background: var(--pa-surface-3);
|
||||
font-size: 11px;
|
||||
color: var(--pa-ink-muted);
|
||||
vertical-align: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,420 @@
|
||||
<!-- ReportPanel.vue —— ④ 专业审稿意见报告(读者视角,正式报告版式)。
|
||||
与 PresubmitPanel(作者视角·修改工作台)共用同一份 edu.report
|
||||
({ok, report{summary, major/minor_weaknesses[], presentation_format[],
|
||||
questions_for_authors[], recommendation}, meta{filename,target_journal,text_chars,truncated,model}}),
|
||||
生成时间取 edu.reportCreatedAt(store 盖章,payload 无时间字段)。
|
||||
无报告 → 引导去 section='presubmit'。
|
||||
工具栏:导出 PDF = window.print()(打印时 :global 隐藏 .edu-sidenav/.top-bar/.rp-toolbar);
|
||||
复制 Markdown = clipboard API(安全上下文)→ execCommand 兜底(LAN http:// 非安全上下文)。
|
||||
不动 presubmit 后端 / self_review.py / 六维 Prompt,纯前端只读展示。 -->
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref } from 'vue';
|
||||
import { useEduStore } from '../../../stores/edu.js';
|
||||
|
||||
const edu = useEduStore();
|
||||
|
||||
const REC = {
|
||||
accept: { label: '接收', cls: 'ok' },
|
||||
minor_revision: { label: '小修', cls: 'minor' },
|
||||
major_revision: { label: '大修', cls: 'major' },
|
||||
reject: { label: '拒稿', cls: 'reject' },
|
||||
};
|
||||
|
||||
const toast = ref(false);
|
||||
let toastTimer = null;
|
||||
|
||||
const report = computed(() => edu.report?.report || null);
|
||||
const meta = computed(() => edu.report?.meta || null);
|
||||
const rec = computed(() => REC[report.value?.recommendation] || REC.major_revision);
|
||||
const createdAtText = computed(() => {
|
||||
const s = edu.reportCreatedAt;
|
||||
return s ? String(s).slice(0, 16).replace('T', ' ') : '—';
|
||||
});
|
||||
|
||||
/* 五节结构:1 总体评价(summary 单列)+ 2..5 列表节(questions 映射为 {title} 条目) */
|
||||
const sections = computed(() => {
|
||||
const r = report.value;
|
||||
if (!r) return [];
|
||||
return [
|
||||
{ title: '主要问题', items: r.major_weaknesses || [] },
|
||||
{ title: '次要问题', items: r.minor_weaknesses || [] },
|
||||
{ title: '排版与规范', items: r.presentation_format || [] },
|
||||
{
|
||||
title: '给作者的问题',
|
||||
items: (r.questions_for_authors || []).map((q) => ({ title: q, detail: '', evidence: '', suggestion: '' })),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function exportPdf() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
function buildMarkdown() {
|
||||
const r = report.value;
|
||||
if (!r) return '';
|
||||
const m = meta.value || {};
|
||||
const lines = [
|
||||
'# AI 预审稿报告',
|
||||
'',
|
||||
`- 文件:${m.filename || '—'}`,
|
||||
`- 目标期刊:${m.target_journal || '未指定'}`,
|
||||
`- 生成时间:${createdAtText.value}`,
|
||||
`- 推荐决定:${rec.value.label}(${r.recommendation})`,
|
||||
'',
|
||||
'## 总体评价',
|
||||
'',
|
||||
r.summary || '无',
|
||||
'',
|
||||
];
|
||||
const weakSec = (title, items) => {
|
||||
lines.push(`## ${title}`, '');
|
||||
if (!items || !items.length) {
|
||||
lines.push('无', '');
|
||||
return;
|
||||
}
|
||||
items.forEach((it, i) => {
|
||||
lines.push(`${i + 1}. ${it.title}`, '');
|
||||
if (it.detail) lines.push(it.detail, '');
|
||||
if (it.evidence) lines.push(` - 证据:${it.evidence}`);
|
||||
if (it.suggestion) lines.push(` - 建议:${it.suggestion}`);
|
||||
lines.push('');
|
||||
});
|
||||
};
|
||||
weakSec('主要问题', r.major_weaknesses);
|
||||
weakSec('次要问题', r.minor_weaknesses);
|
||||
weakSec('排版与规范', r.presentation_format);
|
||||
lines.push('## 给作者的问题', '');
|
||||
const qs = r.questions_for_authors || [];
|
||||
if (!qs.length) lines.push('无', '');
|
||||
else qs.forEach((q, i) => lines.push(`${i + 1}. ${q}`));
|
||||
lines.push('', '---', `*正文 ${m.text_chars ?? '—'} 字符${m.truncated ? ' · 已截断' : ''}*`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function showToast() {
|
||||
toast.value = true;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => (toast.value = false), 1600);
|
||||
}
|
||||
|
||||
async function copyMarkdown() {
|
||||
const text = buildMarkdown();
|
||||
if (!text) return;
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
}
|
||||
} catch {
|
||||
/* fallthrough → execCommand 兜底 */
|
||||
}
|
||||
if (!ok) {
|
||||
// LAN http:// 非安全上下文 navigator.clipboard 不可用 → 隐藏 textarea + execCommand
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;top:-1000px;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (ok) showToast();
|
||||
}
|
||||
|
||||
onUnmounted(() => clearTimeout(toastTimer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rp">
|
||||
<!-- ========== ① 无报告 → 引导 ========== -->
|
||||
<div v-if="!edu.hasReport" class="rp-guide">
|
||||
<div class="rp-guide-icon" aria-hidden="true">📝</div>
|
||||
<p class="rp-guide-text">尚无审稿报告。先上传 PDF 运行 AI 预审稿,生成后在此查看正式报告。</p>
|
||||
<button class="rp-guide-btn" type="button" @click="edu.setSection('presubmit')">
|
||||
去预审稿 →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ========== ② 有报告 → 正式报告(读者视角) ========== -->
|
||||
<template v-else>
|
||||
<div class="rp-toolbar">
|
||||
<button class="rp-tool" type="button" @click="exportPdf">📥 导出 PDF</button>
|
||||
<button class="rp-tool" type="button" @click="copyMarkdown">📋 复制为 Markdown</button>
|
||||
<transition name="rp-fade">
|
||||
<span v-if="toast" class="rp-toast" role="status">已复制</span>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<article class="rp-sheet">
|
||||
<header class="rp-head">
|
||||
<h1 class="rp-title">AI 预审稿报告</h1>
|
||||
<div class="rp-headmeta">
|
||||
<span class="rp-meta-line">
|
||||
📄 {{ meta?.filename || '—' }}
|
||||
<template v-if="meta?.target_journal"> · 目标期刊 {{ meta.target_journal }}</template>
|
||||
· 生成于 {{ createdAtText }}
|
||||
</span>
|
||||
<span class="rp-rec" :class="rec.cls">{{ rec.label }} · {{ report.recommendation }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 1. 总体评价 -->
|
||||
<section class="rp-sec">
|
||||
<h2 class="rp-h2">1. 总体评价</h2>
|
||||
<p class="rp-summary">{{ report.summary || '无' }}</p>
|
||||
</section>
|
||||
|
||||
<!-- 2..5 列表节 -->
|
||||
<section v-for="(s, si) in sections" :key="s.title" class="rp-sec">
|
||||
<h2 class="rp-h2">{{ si + 2 }}. {{ s.title }}({{ s.items.length }})</h2>
|
||||
<div v-for="(it, i) in s.items" :key="i" class="rp-item">
|
||||
<p class="rp-item-title">{{ i + 1 }}. {{ it.title }}</p>
|
||||
<p v-if="it.detail" class="rp-item-detail">{{ it.detail }}</p>
|
||||
<p v-if="it.evidence" class="rp-item-evi"><b>证据:</b>{{ it.evidence }}</p>
|
||||
<p v-if="it.suggestion" class="rp-item-sug"><b>建议:</b>{{ it.suggestion }}</p>
|
||||
</div>
|
||||
<p v-if="!s.items.length" class="rp-none">无</p>
|
||||
</section>
|
||||
|
||||
<footer class="rp-foot">
|
||||
正文 {{ meta?.text_chars ?? '—' }} 字符<template
|
||||
v-if="meta?.truncated"
|
||||
> · 已截断至 6 万字符</template
|
||||
> · 报告生成于 {{ createdAtText }}
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ---------- 引导空态 ---------- */
|
||||
.rp-guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
}
|
||||
.rp-guide-icon {
|
||||
font-size: 30px;
|
||||
}
|
||||
.rp-guide-text {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--pa-ink-muted);
|
||||
max-width: 420px;
|
||||
}
|
||||
.rp-guide-btn {
|
||||
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;
|
||||
}
|
||||
.rp-guide-btn:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* ---------- 工具栏 ---------- */
|
||||
.rp-toolbar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.rp-tool {
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-1);
|
||||
color: var(--pa-ink);
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rp-tool:hover {
|
||||
background: var(--pa-surface-2);
|
||||
border-color: var(--pa-hairline-strong);
|
||||
}
|
||||
.rp-toast {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -8px;
|
||||
transform: translate(-50%, -100%);
|
||||
padding: 5px 14px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
background: var(--pa-raised);
|
||||
border: 1px solid var(--pa-success);
|
||||
color: var(--pa-success);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.rp-fade-enter-active,
|
||||
.rp-fade-leave-active {
|
||||
transition: opacity .2s var(--pa-ease);
|
||||
}
|
||||
.rp-fade-enter-from,
|
||||
.rp-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ---------- 纸面版式:等宽正式报告,左右留白宽、行距松 ---------- */
|
||||
.rp-sheet {
|
||||
max-width: 760px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 40px 56px;
|
||||
box-sizing: border-box;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
font-family: var(--pa-font-mono);
|
||||
color: var(--pa-ink);
|
||||
line-height: 1.85;
|
||||
}
|
||||
.rp-head {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid var(--pa-hairline-strong);
|
||||
}
|
||||
.rp-title {
|
||||
margin: 0 0 10px;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.rp-headmeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rp-meta-line {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rp-rec {
|
||||
display: inline-flex;
|
||||
padding: 3px 14px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rp-rec.ok { color: var(--pa-success); }
|
||||
.rp-rec.minor { color: var(--pa-info); }
|
||||
.rp-rec.major { color: var(--pa-warning); }
|
||||
.rp-rec.reject { color: var(--pa-error); }
|
||||
|
||||
.rp-sec {
|
||||
margin-top: 26px;
|
||||
}
|
||||
.rp-h2 {
|
||||
margin: 0 0 12px;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 14.5px;
|
||||
font-weight: 700;
|
||||
color: var(--pa-ink);
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
.rp-summary {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.95;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rp-item {
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.rp-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.rp-item-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
line-height: 1.7;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rp-item-detail {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rp-item-evi {
|
||||
margin: 9px 0 0;
|
||||
padding-left: 13px;
|
||||
border-left: 3px solid var(--pa-hairline-strong);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.8;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rp-item-sug {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.8;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rp-item-evi b,
|
||||
.rp-item-sug b {
|
||||
color: var(--pa-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
.rp-none {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.rp-foot {
|
||||
margin-top: 30px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed var(--pa-hairline);
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
|
||||
/* ---------- 打印(导出 PDF = window.print):只留报告纸面 ----------
|
||||
注:打印态下 #fff/#000/#bbb 为硬编码(仅 @media print 生效)——
|
||||
屏幕用暗色主题变量直接打印会输出大面积黑底,故打印强制白底黑字。 */
|
||||
@media print {
|
||||
:global(.edu-sidenav),
|
||||
:global(.top-bar),
|
||||
.rp-toolbar {
|
||||
display: none !important;
|
||||
}
|
||||
.rp-sheet {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: #fff;
|
||||
}
|
||||
.rp-sheet * {
|
||||
color: #000 !important;
|
||||
background: transparent !important;
|
||||
border-color: #bbb !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,394 @@
|
||||
<!-- TrackerPanel.vue —— ⑥ 投稿进度与返修(纯前端 localStorage 台账,无后端)。
|
||||
严格复用 edu store 现有 API:submissionsSorted(updatedAt 倒序)/ addSubmission /
|
||||
updateSubmission / removeSubmission / SUBMISSION_STATUS;不新增字段、不改 store。
|
||||
交互:新增(内联抽屉)/ 编辑(同一表单预填)/ 删除(行内二次确认)/ 空态引导。
|
||||
日期约定:表单为 'YYYY-MM-DD';存量 submittedAt 可能是 ISO → 读取统一 slice(0,10) 兼容;
|
||||
addSubmission 固定 submittedAt=now,故仅当用户改了日期时再 updateSubmission 补丁。 -->
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useEduStore, SUBMISSION_STATUS } from '../../../stores/edu.js';
|
||||
|
||||
const edu = useEduStore();
|
||||
|
||||
const STATUS_LABEL = {
|
||||
draft: '草稿',
|
||||
submitted: '已投稿',
|
||||
under_review: '审稿中',
|
||||
revision: '返修中',
|
||||
accepted: '录用',
|
||||
rejected: '拒稿',
|
||||
};
|
||||
|
||||
const formOpen = ref(false);
|
||||
const mode = ref('create'); // 'create' | 'edit'
|
||||
const editingId = ref(null);
|
||||
const formError = ref('');
|
||||
const pendingDeleteId = ref(null); // 删除行内二次确认
|
||||
const form = reactive({ journal: '', status: 'draft', date: '', notes: '' });
|
||||
|
||||
const todayStr = () => new Date().toISOString().slice(0, 10);
|
||||
const dateOf = (s) => String(s || '').slice(0, 10);
|
||||
const dtOf = (s) => String(s || '').slice(0, 16).replace('T', ' ');
|
||||
|
||||
function openCreate() {
|
||||
mode.value = 'create';
|
||||
editingId.value = null;
|
||||
form.journal = '';
|
||||
form.status = 'draft';
|
||||
form.date = todayStr();
|
||||
form.notes = '';
|
||||
formError.value = '';
|
||||
pendingDeleteId.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
function openEdit(row) {
|
||||
mode.value = 'edit';
|
||||
editingId.value = row.id;
|
||||
form.journal = row.journal;
|
||||
form.status = row.status;
|
||||
form.date = dateOf(row.submittedAt);
|
||||
form.notes = row.notes || '';
|
||||
formError.value = '';
|
||||
pendingDeleteId.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
function closeForm() {
|
||||
formOpen.value = false;
|
||||
formError.value = '';
|
||||
}
|
||||
function save() {
|
||||
const journal = form.journal.trim();
|
||||
if (!journal) {
|
||||
formError.value = '请输入期刊名';
|
||||
return;
|
||||
}
|
||||
if (mode.value === 'create') {
|
||||
const rec = edu.addSubmission({ journal, status: form.status, notes: form.notes });
|
||||
// addSubmission 固定 submittedAt=now(ISO);仅当用户改了投稿日期才补丁(复用 updateSubmission,不改 store)
|
||||
if (form.date && form.date !== todayStr()) {
|
||||
edu.updateSubmission(rec.id, { submittedAt: form.date });
|
||||
}
|
||||
} else {
|
||||
edu.updateSubmission(editingId.value, {
|
||||
journal,
|
||||
status: form.status,
|
||||
notes: form.notes,
|
||||
submittedAt: form.date,
|
||||
});
|
||||
}
|
||||
closeForm();
|
||||
}
|
||||
function confirmDelete() {
|
||||
if (pendingDeleteId.value) edu.removeSubmission(pendingDeleteId.value);
|
||||
pendingDeleteId.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="tk">
|
||||
<header class="tk-head">
|
||||
<h1 class="tk-title">投稿进度与返修</h1>
|
||||
<span class="tk-sub">本地台账 · 仅存于当前浏览器</span>
|
||||
<button class="tk-add" type="button" @click="openCreate">+ 新增投稿</button>
|
||||
</header>
|
||||
|
||||
<!-- ========== 新增/编辑共用抽屉 ========== -->
|
||||
<div v-if="formOpen" class="tk-form">
|
||||
<div class="tk-form-grid">
|
||||
<label class="tk-field">
|
||||
<span class="tk-label">期刊名<i class="tk-req">*</i></span>
|
||||
<input
|
||||
v-model="form.journal"
|
||||
class="tk-input"
|
||||
type="text"
|
||||
placeholder="如 The Plant Cell"
|
||||
@keydown.enter="save"
|
||||
/>
|
||||
</label>
|
||||
<label class="tk-field">
|
||||
<span class="tk-label">状态</span>
|
||||
<select v-model="form.status" class="tk-input tk-select">
|
||||
<option v-for="s in SUBMISSION_STATUS" :key="s" :value="s">
|
||||
{{ STATUS_LABEL[s] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="tk-field">
|
||||
<span class="tk-label">投稿日期</span>
|
||||
<input v-model="form.date" class="tk-input" type="date" />
|
||||
</label>
|
||||
<label class="tk-field tk-field-wide">
|
||||
<span class="tk-label">备注(可空)</span>
|
||||
<textarea
|
||||
v-model="form.notes"
|
||||
class="tk-input tk-textarea"
|
||||
rows="3"
|
||||
placeholder="如:审稿 2 人已返回,等待编辑决定…"
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="formError" class="tk-form-error">{{ formError }}</p>
|
||||
<div class="tk-form-btns">
|
||||
<button class="tk-save" type="button" @click="save">
|
||||
{{ mode === 'create' ? '创建' : '保存' }}
|
||||
</button>
|
||||
<button class="tk-cancel" type="button" @click="closeForm">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== 空态 ========== -->
|
||||
<p v-if="!edu.submissionsSorted.length" class="tk-empty">
|
||||
暂无投稿记录,点击「新增投稿」开始追踪
|
||||
</p>
|
||||
|
||||
<!-- ========== 列表(store 已按 updatedAt 倒序) ========== -->
|
||||
<div
|
||||
v-for="row in edu.submissionsSorted"
|
||||
:key="row.id"
|
||||
class="tk-row"
|
||||
:class="{ confirming: pendingDeleteId === row.id }"
|
||||
>
|
||||
<div class="tk-main">
|
||||
<span class="tk-journal" :title="row.journal">{{ row.journal }}</span>
|
||||
<span class="tk-badge" :class="row.status">{{ STATUS_LABEL[row.status] || row.status }}</span>
|
||||
<span class="tk-date">投稿 {{ dateOf(row.submittedAt) || '—' }}</span>
|
||||
<span class="tk-updated">更新 {{ dtOf(row.updatedAt) || '—' }}</span>
|
||||
</div>
|
||||
<p class="tk-notes" :class="{ empty: !row.notes }">{{ row.notes || '—' }}</p>
|
||||
<div class="tk-actions">
|
||||
<template v-if="pendingDeleteId === row.id">
|
||||
<span class="tk-confirm-q">确认删除该记录?</span>
|
||||
<button class="tk-danger" type="button" @click="confirmDelete">确认删除</button>
|
||||
<button class="tk-cancel" type="button" @click="pendingDeleteId = null">取消</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="tk-act" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button class="tk-act tk-danger" type="button" @click="pendingDeleteId = row.id">
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tk {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.tk-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.tk-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.tk-sub {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
margin-right: auto;
|
||||
}
|
||||
.tk-add {
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-gradient);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tk-add:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* ---------- 抽屉表单 ---------- */
|
||||
.tk-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline-strong);
|
||||
border-radius: var(--pa-r-lg);
|
||||
}
|
||||
.tk-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.tk-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.tk-field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.tk-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.tk-req {
|
||||
color: var(--pa-error);
|
||||
font-style: normal;
|
||||
margin-left: 3px;
|
||||
}
|
||||
.tk-input {
|
||||
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;
|
||||
}
|
||||
.tk-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--pa-g-from);
|
||||
}
|
||||
.tk-textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
.tk-form-error {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.tk-form-btns {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.tk-save {
|
||||
padding: 6px 20px;
|
||||
border: none;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-gradient);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tk-cancel,
|
||||
.tk-act {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
background: transparent;
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tk-cancel:hover,
|
||||
.tk-act:hover {
|
||||
color: var(--pa-ink);
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.tk-danger {
|
||||
color: var(--pa-error);
|
||||
border-color: currentColor;
|
||||
}
|
||||
.tk-danger:hover {
|
||||
color: var(--pa-error);
|
||||
background: transparent;
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
/* ---------- 空态 ---------- */
|
||||
.tk-empty {
|
||||
margin: 0;
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--pa-ink-subtle);
|
||||
border: 1px dashed var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
}
|
||||
|
||||
/* ---------- 列表行 ---------- */
|
||||
.tk-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 12px 16px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
transition: border-color .15s var(--pa-ease);
|
||||
}
|
||||
.tk-row:hover {
|
||||
border-color: var(--pa-hairline-strong);
|
||||
}
|
||||
.tk-row.confirming {
|
||||
border-color: var(--pa-error);
|
||||
}
|
||||
.tk-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.tk-journal {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--pa-ink);
|
||||
max-width: 44%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 状态徽章 6 态配色:灰/蓝/青/橙/绿/红。
|
||||
青(under_review)无专用 --pa-cyan 变量 → 用现成渐变终点色 --pa-g-to(#00D4FF),不硬编码 */
|
||||
.tk-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.tk-badge.draft { color: var(--pa-ink-subtle); }
|
||||
.tk-badge.submitted { color: var(--pa-info); }
|
||||
.tk-badge.under_review { color: var(--pa-g-to); }
|
||||
.tk-badge.revision { color: var(--pa-warning); }
|
||||
.tk-badge.accepted { color: var(--pa-success); }
|
||||
.tk-badge.rejected { color: var(--pa-error); }
|
||||
.tk-date,
|
||||
.tk-updated {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.tk-notes {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--pa-ink-muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tk-notes.empty {
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.tk-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.tk-confirm-q {
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-error);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<!-- TrainingPanel.vue —— ⑦ 审稿训练与教育(步骤4 占位)。 -->
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="edu-panel-inner">
|
||||
<h1 class="edu-title">审稿训练与教育</h1>
|
||||
<p class="edu-placeholder">功能开发中,敬请期待</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.edu-panel-inner { display: flex; flex-direction: column; gap: 8px; }
|
||||
.edu-title { margin: 0; font-size: 16px; font-weight: 600; color: var(--pa-ink); }
|
||||
.edu-placeholder { margin: 0; font-size: 13px; color: var(--pa-ink-muted); }
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ import { APP_NAME, STATUS_LABEL, T } from '../../i18n/labels.js';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { useThemeStore } from '../../stores/theme.js';
|
||||
import { useBioflowStore } from '../../stores/bioflow.js';
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
|
||||
const props = defineProps({
|
||||
/** 当前会话标题(无会话为 null → 显示「就绪」徽章) */
|
||||
@@ -40,6 +41,9 @@ const bioflowStore = useBioflowStore();
|
||||
const bioflowOnline = computed(() => bioflowStore.availability === 'online');
|
||||
const bioflowStatus = computed(() => bioflowStore.availability);
|
||||
|
||||
/* ---------- 教育科研入口(步骤5;覆盖层视图切换 D1,同 themeStore 静态导入) ---------- */
|
||||
const appStore = useAppStore();
|
||||
|
||||
/* ---------- B2/B3 store 数据(动态导入 + 空值守卫) ---------- */
|
||||
const chatStore = ref(null);
|
||||
const benchStore = ref(null);
|
||||
@@ -160,6 +164,8 @@ const iconClose = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
const iconRailExt = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 7.7l5.4-.8z"/><path d="M20 14v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-5"/></svg>';
|
||||
const iconRailSettings = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1-1.55 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.55-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h0a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.55h0a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v0a1.7 1.7 0 0 0 1.55 1H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.55 1z"/></svg>';
|
||||
const iconRailBio = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3c0 4-1.2 8.6-5 11"/><path d="M7 3c0 4 1.2 8.6 5 11"/><path d="M17 3c0 4 1.2 8.6 5 11"/><path d="M17 3c0 4-1.2 8.6-5 11"/><path d="M8.5 8h7"/><path d="M8 13.5h8"/><path d="M10.5 18h3"/></svg>';
|
||||
// 教育科研(步骤5:毕业帽,与 rail 同款内联 SVG)
|
||||
const iconRailEdu = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 10L12 5 2 10l10 5 10-5z"/><path d="M6 12.5V17c0 1.7 2.7 3 6 3s6-1.3 6-3v-4.5"/><path d="M22 10v6"/></svg>';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -223,6 +229,12 @@ const iconRailBio = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
<span v-html="iconRailBio"></span>
|
||||
<span class="rail-label">生信</span>
|
||||
</button>
|
||||
<!-- 教育科研入口(步骤5:覆盖层切 edu 视图;on 高亮在 edu 态由 EduSideNav 返回后可见) -->
|
||||
<button class="icon-btn top-rail-btn" :class="{ on: appStore.isEdu }" type="button" title="教育科研" aria-label="教育科研"
|
||||
@click="appStore.setView('edu')">
|
||||
<span v-html="iconRailEdu"></span>
|
||||
<span class="rail-label">教育科研</span>
|
||||
</button>
|
||||
<button class="icon-btn top-rail-btn" type="button" :title="T.railSettings" :aria-label="T.railSettings"
|
||||
@click="$emit('goto-settings')">
|
||||
<span v-html="iconRailSettings"></span>
|
||||
@@ -245,3 +257,11 @@ const iconRailBio = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 教育科研入口高亮(步骤5;仅现成 --pa-* 变量,同 bench-tab.on 思路) */
|
||||
.top-rail-btn.on {
|
||||
color: var(--pa-ink);
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// stores/app.js —— 全局视图状态(chat ⇄ edu,无 vue-router;覆盖层语义 D1)
|
||||
// 切到 'edu' 时 App.vue 用 v-show 隐藏 AppShell 而不卸载:RAG 会话、
|
||||
// 上传文件、滚动位置零丢失(严禁改用 v-if)。
|
||||
// 仅内存态:刷新页面必回 'chat',避免用户被卡在 edu 视图。
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
const VIEWS = ['chat', 'edu'];
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
currentView: 'chat', // 'chat' | 'edu',默认 chat
|
||||
}),
|
||||
getters: {
|
||||
isChat: (s) => s.currentView === 'chat',
|
||||
isEdu: (s) => s.currentView === 'edu',
|
||||
},
|
||||
actions: {
|
||||
setView(v) {
|
||||
if (VIEWS.includes(v)) this.currentView = v; // 非法值静默忽略
|
||||
},
|
||||
toggleView() {
|
||||
this.currentView = this.currentView === 'edu' ? 'chat' : 'edu';
|
||||
},
|
||||
gotoEdu() {
|
||||
this.setView('edu');
|
||||
},
|
||||
gotoChat() {
|
||||
this.setView('chat');
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
// stores/edu.js —— 教育科研工作台状态(Pinia options API,风格同 settings.js)
|
||||
// 覆盖左侧 ①~⑦ 菜单、面板共享数据、③→④ 报告衔接、D3 投稿台账(localStorage)。
|
||||
// 面板拟用 <component :is> 切换(切菜单卸载旧面板):
|
||||
// - 进行中的请求由 store action 持有 → 面板卸载不中断;
|
||||
// - 表单草稿存 drafts → 切菜单不丢。
|
||||
import { defineStore } from 'pinia';
|
||||
import { formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, presubmit, recommend, reviewErrorMessage } from '../api/review.js';
|
||||
|
||||
const STORAGE_KEY = 'plantai.edu.submissions'; // D3 台账持久化
|
||||
const SECTIONS = ['journals', 'recommend', 'presubmit', 'report', 'format', 'tracker', 'calendar'];
|
||||
|
||||
// ⑥ 台账状态枚举(labels 由 step-7 UI 层给;store 只存字符串)
|
||||
export const SUBMISSION_STATUS = ['draft', 'submitted', 'under_review', 'revision', 'accepted', 'rejected'];
|
||||
|
||||
function safeId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function readStorage() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((r) => r && typeof r === 'object' && typeof r.id === 'string');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const useEduStore = defineStore('edu', {
|
||||
state: () => ({
|
||||
/* —— 菜单(①~⑦,默认①)—— */
|
||||
section: 'journals',
|
||||
|
||||
/* —— ⑥ 投稿台账(D3;构造即加载 localStorage)—— */
|
||||
submissions: readStorage(), // {id, journal, status, submittedAt, updatedAt, notes}
|
||||
|
||||
/* —— 面板数据三态(统一 idle | loading | ready | error)—— */
|
||||
journals: [], // ① items
|
||||
journalsMeta: null, // {count, last_updated, data_source_note}
|
||||
journalsStatus: 'idle',
|
||||
journalsError: null,
|
||||
/* —— ①' 期刊目录(journal_directory 1056 本;与 journals 5 本精选三态隔离)—— */
|
||||
journalsTab: 'en', // 'en' | 'zh' 视图态(切 Tab 全清筛选,见 resetDirectoryQuery)
|
||||
directory: [], // 当前页 items(后端已过滤/排序/分页)
|
||||
directoryMeta: null, // {total,page,size,lang,domain,cas_partition,q,domains[],data_source_note,data_imported}
|
||||
directoryStatus: 'idle', // idle | loading | ready | error
|
||||
directoryError: null,
|
||||
// 请求参数与 directory 同生命周期 → 切菜单(面板卸载)不丢筛选
|
||||
directoryQuery: {
|
||||
lang: 'en', q: '', domain: '', cas_partition: '',
|
||||
page: 1, size: 50, field: '', order: '',
|
||||
},
|
||||
// 竞态令牌:防抖/连点/切 Tab 时旧响应覆盖新数据(fetchDirectory 内比对)
|
||||
directorySeq: 0,
|
||||
conferences: [], // ⑦' 会议时间线 items(后端已过滤/排序;首版全 placeholder)
|
||||
conferencesStatus: 'idle',
|
||||
conferencesError: null,
|
||||
report: null, // ③→④ presubmit 响应 {ok, report, meta}
|
||||
reportCreatedAt: null, // ④报告头生成时间(ISO;payload 无时间字段,提交成功时盖章,clearReport 重置)
|
||||
reportStatus: 'idle',
|
||||
reportError: null,
|
||||
recommend: null, // ② 推荐响应 {ok, tiers, meta}
|
||||
recommendStatus: 'idle',
|
||||
recommendError: null,
|
||||
formatReport: null, // ⑤ 版式检查响应 {ok, checks, summary, meta}(跨菜单保留)
|
||||
formatStatus: 'idle',
|
||||
formatError: null,
|
||||
|
||||
/* —— 表单草稿(切菜单不丢;四字段并存:recommend 用 abstract+prefs、
|
||||
presubmit 用 targetJournal、format 用 formatJournalId(期刊 id 下拉))—— */
|
||||
drafts: {
|
||||
abstract: '',
|
||||
targetJournal: '',
|
||||
formatJournalId: '',
|
||||
// 与 POST /api/review/recommend 的 preferences 同构:
|
||||
// {max_review_weeks, min_impact_factor, tier_focus} 子字段逐一同名同型,
|
||||
// 提交时整体映射为 payload.preferences(null 数值剔除)。
|
||||
// 首版 chips 为纯前端筛选 → tier_focus 恒为默认 'all',不改 payload 语义。
|
||||
prefs: { max_review_weeks: null, min_impact_factor: null, tier_focus: 'all' },
|
||||
// 结果区档位筛选(纯视图状态,不参与 payload;随 store 保留跨菜单)
|
||||
tierFilter: 'all',
|
||||
},
|
||||
}),
|
||||
|
||||
getters: {
|
||||
hasReport: (s) => !!(s.report && s.report.ok && s.report.report),
|
||||
submissionsSorted: (s) =>
|
||||
[...s.submissions].sort((a, b) => String(b.updatedAt || '').localeCompare(a.updatedAt || '')),
|
||||
},
|
||||
|
||||
actions: {
|
||||
setSection(k) {
|
||||
if (SECTIONS.includes(k)) this.section = k;
|
||||
},
|
||||
|
||||
/* —— D3 台账 CRUD(每步 _persist 同步 localStorage)—— */
|
||||
loadFromStorage() {
|
||||
this.submissions = readStorage();
|
||||
},
|
||||
addSubmission({ journal, status = 'draft', notes = '' }) {
|
||||
const now = new Date().toISOString();
|
||||
const rec = { id: safeId(), journal, status, submittedAt: now, updatedAt: now, notes };
|
||||
this.submissions.unshift(rec);
|
||||
this._persist();
|
||||
return rec;
|
||||
},
|
||||
updateSubmission(id, patch = {}) {
|
||||
const i = this.submissions.findIndex((s) => s.id === id);
|
||||
if (i < 0) return null;
|
||||
const merged = { ...this.submissions[i], ...patch, id, updatedAt: new Date().toISOString() };
|
||||
this.submissions[i] = merged;
|
||||
this._persist();
|
||||
return merged;
|
||||
},
|
||||
removeSubmission(id) {
|
||||
this.submissions = this.submissions.filter((s) => s.id !== id);
|
||||
this._persist();
|
||||
},
|
||||
_persist() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.submissions));
|
||||
} catch {
|
||||
/* 隐私模式/配额满:内存态可用,静默降级 */
|
||||
}
|
||||
},
|
||||
|
||||
/* —— API actions(错误吞入 *Status/*Error 状态,不 rethrow;成功返回 payload,失败 null)—— */
|
||||
async fetchJournals(params = {}) {
|
||||
this.journalsStatus = 'loading';
|
||||
this.journalsError = null;
|
||||
try {
|
||||
const payload = await getJournals(params);
|
||||
this.journals = (payload && payload.items) || [];
|
||||
this.journalsMeta = (payload && payload.meta) || null;
|
||||
this.journalsStatus = 'ready';
|
||||
return payload;
|
||||
} catch (err) {
|
||||
this.journalsStatus = 'error';
|
||||
this.journalsError = reviewErrorMessage(err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/* —— ①' 目录查询(请求参数合并 → 只读 GET;seq 防乱序覆盖)—— */
|
||||
async fetchDirectory(partial = {}) {
|
||||
Object.assign(this.directoryQuery, partial);
|
||||
const seq = ++this.directorySeq;
|
||||
this.directoryStatus = 'loading';
|
||||
this.directoryError = null;
|
||||
try {
|
||||
const payload = await getDirectory({ ...this.directoryQuery });
|
||||
if (seq !== this.directorySeq) return null; // 过期响应:丢弃
|
||||
this.directory = (payload && payload.items) || [];
|
||||
this.directoryMeta = (payload && payload.meta) || null;
|
||||
this.directoryStatus = 'ready';
|
||||
return payload;
|
||||
} catch (err) {
|
||||
if (seq !== this.directorySeq) return null;
|
||||
this.directoryStatus = 'error';
|
||||
this.directoryError = reviewErrorMessage(err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/* 复位筛选(保留 size):切 Tab / 清除筛选共用 */
|
||||
resetDirectoryQuery(newLang = this.directoryQuery.lang) {
|
||||
this.directoryQuery = {
|
||||
lang: newLang,
|
||||
q: '', domain: '', cas_partition: '',
|
||||
page: 1,
|
||||
size: this.directoryQuery.size,
|
||||
field: '', order: '',
|
||||
};
|
||||
},
|
||||
/* 切 Tab:全清 q/domain/cas_partition/page(en/zh 筛选语义不对称,保留会困惑) */
|
||||
async switchDirectoryTab(tab) {
|
||||
if (tab !== 'en' && tab !== 'zh' || tab === this.journalsTab) return null;
|
||||
this.journalsTab = tab;
|
||||
this.resetDirectoryQuery(tab);
|
||||
return this.fetchDirectory();
|
||||
},
|
||||
|
||||
async fetchConferences(query = {}) {
|
||||
this.conferencesStatus = 'loading';
|
||||
this.conferencesError = null;
|
||||
try {
|
||||
const payload = await getConferences(query);
|
||||
this.conferences = (payload && payload.items) || [];
|
||||
this.conferencesStatus = 'ready';
|
||||
return payload;
|
||||
} catch (err) {
|
||||
this.conferencesStatus = 'error';
|
||||
this.conferencesError = reviewErrorMessage(err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async submitPresubmit(formData, opts = {}) {
|
||||
this.reportStatus = 'loading';
|
||||
this.reportError = null;
|
||||
try {
|
||||
const payload = await presubmit(formData, opts); // opts 透传 onUploadProgress(http.js XHR 分支)
|
||||
this.report = payload;
|
||||
this.reportCreatedAt = new Date().toISOString();
|
||||
this.reportStatus = 'ready';
|
||||
return payload;
|
||||
} catch (err) {
|
||||
this.reportStatus = 'error';
|
||||
this.reportError = reviewErrorMessage(err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
clearReport() {
|
||||
this.report = null;
|
||||
this.reportCreatedAt = null;
|
||||
this.reportStatus = 'idle';
|
||||
this.reportError = null;
|
||||
},
|
||||
async submitRecommend(payload) {
|
||||
this.recommendStatus = 'loading';
|
||||
this.recommendError = null;
|
||||
try {
|
||||
const res = await recommend(payload);
|
||||
this.recommend = res;
|
||||
this.recommendStatus = 'ready';
|
||||
return res;
|
||||
} catch (err) {
|
||||
this.recommendStatus = 'error';
|
||||
this.recommendError = reviewErrorMessage(err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async submitFormatCheck(formData) {
|
||||
this.formatStatus = 'loading';
|
||||
this.formatError = null;
|
||||
try {
|
||||
const payload = await formatCheck(formData); // 纯规则引擎,同步秒回
|
||||
this.formatReport = payload;
|
||||
this.formatStatus = 'ready';
|
||||
return payload;
|
||||
} catch (err) {
|
||||
this.formatStatus = 'error';
|
||||
this.formatError = formatErrorMessage(err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
clearFormatReport() {
|
||||
this.formatReport = null;
|
||||
this.formatStatus = 'idle';
|
||||
this.formatError = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
// vite.config.js —— B1 归属,后续由 B1 扩展。此处为 M0 最小可用配置。
|
||||
// 默认代理指向 mock server (8099);集成阶段由 PM 改为真实 BFF (8090)。
|
||||
// 默认代理指向真实 BFF (8090);需要 mock 时显式 VITE_BFF_TARGET=http://127.0.0.1:8099。
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
const bffTarget = process.env.VITE_BFF_TARGET || 'http://127.0.0.1:8099';
|
||||
const bffTarget = process.env.VITE_BFF_TARGET || 'http://127.0.0.1:8090';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
|
||||
Reference in New Issue
Block a user