feat(docs-center): report reading section (type=report) on /api/cma/docs
Increment #2 over PoC ede0524 (task yanxue-20260924-cma-report-source).
- backend derives reports from existing drop points (wisdom/_reports + every profile pending-tasks/_reports), read live per request, no cache, no rebuild
- gate = front-matter plus non-empty conclusion; missing either means silently out of the list (receipts/audits never enter); title/date/domain/sources missing are marked incomplete instead of rejected
- entity + role partitioning (ceo/it cross-entity; others own entity plus group/generic); kpi_refs resolve to a one-hop metric link
- new GET /api/cma/docs/report/{rid} detail endpoint; knowledge branch unchanged (type defaults to knowledge)
- frontend: CMAKnowledge adds a report reading pane (filters + detail dialog), reuses the existing markdown renderer, no new deps
(cherry picked from commit 7c6e0857e68133f5ba2a539fd3569aeb82547911)
This commit is contained in:
@@ -25,9 +25,14 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from app.auth_middleware import require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
|
from app.database import get_db
|
||||||
|
from app.deps import get_session_entity
|
||||||
|
from app.models import Entity, User, UserEntity
|
||||||
|
|
||||||
logger = logging.getLogger("cma.doc_center_api")
|
logger = logging.getLogger("cma.doc_center_api")
|
||||||
|
|
||||||
@@ -194,8 +199,18 @@ def _meta_only(doc: dict) -> dict:
|
|||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_docs(
|
def list_docs(
|
||||||
|
request: Request,
|
||||||
with_content: bool = Query(False, description="是否带上 md 正文(默认只返回元数据)"),
|
with_content: bool = Query(False, description="是否带上 md 正文(默认只返回元数据)"),
|
||||||
category: Optional[str] = Query(None, description="按分类 key 过滤"),
|
category: Optional[str] = Query(None, description="按分类 key 过滤"),
|
||||||
|
type: str = Query("knowledge", description="内容类型:knowledge(缺省,知识库)/ report(报告解读)"),
|
||||||
|
domain: Optional[str] = Query(None, description="type=report:按域过滤"),
|
||||||
|
entity: Optional[str] = Query(None, description="type=report:按账套归属过滤"),
|
||||||
|
kind: Optional[str] = Query(None, description="type=report:按报告类型过滤"),
|
||||||
|
date_from: Optional[str] = Query(None, description="type=report:起始日(含)YYYY-MM-DD"),
|
||||||
|
date_to: Optional[str] = Query(None, description="type=report:结束日(含)YYYY-MM-DD"),
|
||||||
|
diagnose: bool = Query(False, description="type=report:附带被排除件明细(自诊断)"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""文档列表(实时读盘)。
|
"""文档列表(实时读盘)。
|
||||||
|
|
||||||
@@ -204,6 +219,13 @@ def list_docs(
|
|||||||
- `source`:内容目录绝对路径(机器侧取内容用同一来源,满足「机器走 API」)
|
- `source`:内容目录绝对路径(机器侧取内容用同一来源,满足「机器走 API」)
|
||||||
- `skipped`:被跳过的 md 及原因(缺 front-matter/文件名非法),便于 Bot 自诊断
|
- `skipped`:被跳过的 md 及原因(缺 front-matter/文件名非法),便于 Bot 自诊断
|
||||||
"""
|
"""
|
||||||
|
if str(type or "").strip().lower() == "report":
|
||||||
|
return _list_reports(
|
||||||
|
request=request, db=db, user=current_user,
|
||||||
|
domain=domain, entity=entity, kind=kind,
|
||||||
|
date_from=date_from, date_to=date_to, diagnose=bool(diagnose),
|
||||||
|
)
|
||||||
|
|
||||||
docs, skipped, available = load_docs()
|
docs, skipped, available = load_docs()
|
||||||
all_docs = docs
|
all_docs = docs
|
||||||
if category:
|
if category:
|
||||||
@@ -231,3 +253,303 @@ def get_doc(slug: str):
|
|||||||
if err:
|
if err:
|
||||||
raise HTTPException(422, f"文档格式不合法: {err}")
|
raise HTTPException(422, f"文档格式不合法: {err}")
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════ 报告解读(type=report)══════════════════════════
|
||||||
|
# 口径(规范 /srv/hermes/wisdom/_decisions/report-frontmatter-spec-20260924.md,本单按其实现):
|
||||||
|
# ① 进门标记 = front-matter + `conclusion` 非空(§二「front-matter 本身就是进门标记」
|
||||||
|
# + §三「conclusion 是唯一硬字段」):缺任一 ⇒ **不进列表、不报错**。
|
||||||
|
# 回执/审计/施工/工作件天然不带 front-matter ⇒ 天然不进;**不搬家、不新建内容目录**。
|
||||||
|
# ② 规范 §四 另列 title/date/domain/sources 为必填:缺失**不拒收**,标 `incomplete` 并按
|
||||||
|
# 兜底值展示(若按字面拒收,页面只能是空壳,与 §一「无体之用」的立意相悖)。
|
||||||
|
# ③ 只**新增** type=report 分支与新端点 /report/{rid};type 缺省走知识库分支,语义逐字不变。
|
||||||
|
# ④ 权限:按 entity + CMA 既有 roles 分区 —— ceo/it 可跨账套读结论,其余角色只看本账套 + 集团/通用。
|
||||||
|
# ⑤ 读口 = 文件系统(实时读盘,改一篇 md 刷新即生效),无写 API(与知识库同口径,避免第二来源)。
|
||||||
|
|
||||||
|
WISDOM_REPORTS_DIR = "/srv/hermes/wisdom/_reports"
|
||||||
|
PROFILES_ROOT = "/root/.hermes/profiles"
|
||||||
|
REPORT_SPEC_PATH = "/srv/hermes/wisdom/_decisions/report-frontmatter-spec-20260924.md"
|
||||||
|
REPORT_HEAD_BYTES = 8192
|
||||||
|
REPORT_DIRS_ENV = "CMA_REPORTS_DIRS"
|
||||||
|
|
||||||
|
DOMAIN_LABELS = {
|
||||||
|
"finance": "财务", "customer": "客户", "process": "流程", "learning": "学习成长",
|
||||||
|
"tech": "技术", "ops": "运维", "market": "市场", "gov": "政务",
|
||||||
|
}
|
||||||
|
KIND_LABELS = {"report": "报告", "analysis": "分析", "brief": "摘要", "warning": "预警"}
|
||||||
|
GLOBAL_ENTITIES = ("集团", "通用")
|
||||||
|
CROSS_ENTITY_ROLES = ("ceo", "it")
|
||||||
|
ENTITY_NAME_FALLBACK = {1: "酣客", 2: "博海"}
|
||||||
|
_SAFE_STEM_RE = re.compile(r"^[^/\\\x00]{1,160}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(val) -> list:
|
||||||
|
"""front-matter 值 → 列表(支持行内数组 [a, b] / 逗号分隔 / 单值)"""
|
||||||
|
if val is None:
|
||||||
|
return []
|
||||||
|
if isinstance(val, list):
|
||||||
|
return [str(v).strip() for v in val if str(v).strip()]
|
||||||
|
s = str(val).strip()
|
||||||
|
if not s:
|
||||||
|
return []
|
||||||
|
if s.startswith("[") and s.endswith("]"):
|
||||||
|
s = s[1:-1]
|
||||||
|
return [p.strip().strip("\"'") for p in s.split(",") if p.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _kpi_link(raw: str) -> dict:
|
||||||
|
"""kpi_refs 项 → 一跳链(数字 / kpi:<id> → 指标详情页;其余 → 指标字典页关键词)"""
|
||||||
|
v = str(raw).strip()
|
||||||
|
m = re.fullmatch(r"(?:kpi[:/]?)?(\d{1,9})", v, flags=re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
return {"raw": v, "url": f"/kpis/{m.group(1)}", "resolved": "detail"}
|
||||||
|
return {"raw": v, "url": "/kpis?keyword=" + quote(v), "resolved": "list"}
|
||||||
|
|
||||||
|
|
||||||
|
def reports_dirs() -> list:
|
||||||
|
"""[(source_key, Path)] —— 既有报告落点的发现结果。
|
||||||
|
|
||||||
|
env `CMA_REPORTS_DIRS` 覆盖优先(`:`/`;` 分隔,条目可写 `key=path`,供测试与隔离实测);
|
||||||
|
缺省 = 自动发现:`/srv/hermes/wisdom/_reports` + 各 profile 的 `pending-tasks/_reports`
|
||||||
|
(按 resolve 后路径去重,避免 pending-tasks 为符号链接时重复收录)。
|
||||||
|
"""
|
||||||
|
raw = (os.environ.get(REPORT_DIRS_ENV) or "").strip()
|
||||||
|
out, seen = [], set()
|
||||||
|
if raw:
|
||||||
|
for i, item in enumerate(x for x in re.split(r"[:;]", raw) if x.strip()):
|
||||||
|
item = item.strip()
|
||||||
|
key, sep, path = item.partition("=")
|
||||||
|
if not sep:
|
||||||
|
key, path = f"src{i}", item
|
||||||
|
out.append(((key or f"src{i}").strip(), Path(path)))
|
||||||
|
return out
|
||||||
|
cands = []
|
||||||
|
wisdom = Path(WISDOM_REPORTS_DIR)
|
||||||
|
if wisdom.is_dir():
|
||||||
|
cands.append(("wisdom", wisdom))
|
||||||
|
for d in sorted(Path(PROFILES_ROOT).glob("*/pending-tasks/_reports")):
|
||||||
|
if not d.is_dir():
|
||||||
|
continue
|
||||||
|
prof = d.parent.parent.name
|
||||||
|
cands.append((prof[6:] if prof.startswith("wecom-") else prof, d))
|
||||||
|
for key, path in cands:
|
||||||
|
try:
|
||||||
|
rp = path.resolve()
|
||||||
|
except OSError:
|
||||||
|
rp = path
|
||||||
|
if rp in seen:
|
||||||
|
continue
|
||||||
|
seen.add(rp)
|
||||||
|
out.append((key, path))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_report_text(text: str, path: Path, source_key: str):
|
||||||
|
"""报告件 → (record, None) 或 (None, 排除码)。
|
||||||
|
|
||||||
|
排除码:`no_frontmatter`(无 --- 进门标记)/ `missing_conclusion`(缺唯一硬字段)/
|
||||||
|
`unreadable:<err>`。**排除即静默**(不进列表、不报错),这正是负对照的判据。
|
||||||
|
"""
|
||||||
|
parsed = parse_front_matter(text) if text.startswith("---") else None
|
||||||
|
if parsed is None:
|
||||||
|
return None, "no_frontmatter"
|
||||||
|
meta = parsed["meta"]
|
||||||
|
if not str(meta.get("conclusion") or "").strip():
|
||||||
|
return None, "missing_conclusion"
|
||||||
|
try:
|
||||||
|
stat = path.stat()
|
||||||
|
except OSError as exc:
|
||||||
|
return None, f"unreadable:{exc}"
|
||||||
|
refs = _as_list(meta.get("kpi_refs"))
|
||||||
|
rec = {
|
||||||
|
"id": f"{source_key}:{path.stem}",
|
||||||
|
"title": str(meta.get("title") or "").strip() or path.stem,
|
||||||
|
"date": (str(meta.get("date") or "").strip()
|
||||||
|
or datetime.fromtimestamp(stat.st_mtime).date().isoformat()),
|
||||||
|
"domain": str(meta.get("domain") or "").strip() or "未分类",
|
||||||
|
"entity": str(meta.get("entity") or "").strip(),
|
||||||
|
"kind": str(meta.get("kind") or "").strip() or "report",
|
||||||
|
"conclusion": str(meta["conclusion"]).strip(),
|
||||||
|
"sources": _as_list(meta.get("sources")),
|
||||||
|
"impact": str(meta.get("impact") or "").strip(),
|
||||||
|
"decisions": _as_list(meta.get("decisions")),
|
||||||
|
"kpi_refs": refs,
|
||||||
|
"kpi_links": [_kpi_link(r) for r in refs],
|
||||||
|
"file": path.name,
|
||||||
|
"source": source_key,
|
||||||
|
"path": str(path),
|
||||||
|
"bytes": stat.st_size,
|
||||||
|
"updated_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||||
|
"incomplete": [f for f in ("title", "date", "domain", "sources")
|
||||||
|
if not str(meta.get(f) or "").strip()],
|
||||||
|
"content": parsed["body"],
|
||||||
|
}
|
||||||
|
return rec, None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_report(path: Path, source_key: str, full: bool = False):
|
||||||
|
try:
|
||||||
|
if full:
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
else:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
text = fh.read(REPORT_HEAD_BYTES).decode("utf-8", errors="replace")
|
||||||
|
if text.startswith("---") and text.find("\n---", 3) < 0:
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError as exc:
|
||||||
|
return None, f"unreadable:{exc}"
|
||||||
|
return _parse_report_text(text, path, source_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_scope(db: Session, user: User, request: Request) -> dict:
|
||||||
|
"""当前会话的可见范围:账套 id / 账套名称集合 / 是否跨账套角色"""
|
||||||
|
entity_id = get_session_entity(request)
|
||||||
|
if entity_id is None:
|
||||||
|
rel = db.query(UserEntity).filter(UserEntity.user_id == user.id).first()
|
||||||
|
entity_id = rel.entity_id if rel else None
|
||||||
|
names = set()
|
||||||
|
if entity_id is not None:
|
||||||
|
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||||
|
if ent:
|
||||||
|
names |= {str(ent.name or "").strip(), str(ent.short_name or "").strip()}
|
||||||
|
fb = ENTITY_NAME_FALLBACK.get(int(entity_id))
|
||||||
|
if fb:
|
||||||
|
names.add(fb)
|
||||||
|
names.discard("")
|
||||||
|
return {
|
||||||
|
"role": user.role,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"entity_names": sorted(names),
|
||||||
|
"cross_entity": user.role in CROSS_ENTITY_ROLES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _report_visible(rec: dict, scope: dict) -> bool:
|
||||||
|
"""按 entity 分区:跨账套角色(ceo/it)→ 全可见;其余 → 本账套名 + 集团/通用。
|
||||||
|
|
||||||
|
未标 entity 的件按「通用」处理(entity 属选填字段,不设门槛);
|
||||||
|
显式 entity 数字与会话账套一致也算命中(兼容 `entity: 2` 写法)。
|
||||||
|
"""
|
||||||
|
if scope["cross_entity"]:
|
||||||
|
return True
|
||||||
|
e = str(rec.get("entity") or "").strip()
|
||||||
|
if not e or e in GLOBAL_ENTITIES:
|
||||||
|
return True
|
||||||
|
if e in set(scope["entity_names"]):
|
||||||
|
return True
|
||||||
|
if e.isdigit() and scope["entity_id"] is not None and int(e) == int(scope["entity_id"]):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _facet(items: list, field: str, labels: dict) -> list:
|
||||||
|
cnt = {}
|
||||||
|
for r in items:
|
||||||
|
k = str(r.get(field) or "").strip() or "(未标)"
|
||||||
|
cnt[k] = cnt.get(k, 0) + 1
|
||||||
|
return [{"key": k, "name": labels.get(k, k), "count": v}
|
||||||
|
for k, v in sorted(cnt.items(), key=lambda kv: (-kv[1], kv[0]))]
|
||||||
|
|
||||||
|
|
||||||
|
def _list_reports(request: Request, db: Session, user: User,
|
||||||
|
domain=None, entity=None, kind=None,
|
||||||
|
date_from=None, date_to=None, diagnose: bool = False) -> dict:
|
||||||
|
"""报告解读列表:扫描既有报告落点 → 只收带 front-matter + conclusion 的件 → 按 entity 分区 → 筛选"""
|
||||||
|
scope = _entity_scope(db, user, request)
|
||||||
|
included, sources = [], []
|
||||||
|
excluded = {"no_frontmatter": 0, "missing_conclusion": 0, "other": 0}
|
||||||
|
detail = []
|
||||||
|
for key, base in reports_dirs():
|
||||||
|
exists = base.is_dir()
|
||||||
|
found = 0
|
||||||
|
if exists:
|
||||||
|
for path in sorted(base.glob("*.md")):
|
||||||
|
found += 1
|
||||||
|
rec, reason = _read_report(path, key)
|
||||||
|
if reason:
|
||||||
|
if reason == "no_frontmatter":
|
||||||
|
excluded["no_frontmatter"] += 1
|
||||||
|
elif reason == "missing_conclusion":
|
||||||
|
excluded["missing_conclusion"] += 1
|
||||||
|
else:
|
||||||
|
excluded["other"] += 1
|
||||||
|
if diagnose and len(detail) < 200:
|
||||||
|
detail.append({"file": path.name, "source": key, "reason": reason})
|
||||||
|
continue
|
||||||
|
if not _report_visible(rec, scope):
|
||||||
|
continue
|
||||||
|
included.append(rec)
|
||||||
|
sources.append({"key": key, "path": str(base),
|
||||||
|
"exists": exists, "md_count": found})
|
||||||
|
visible_total = len(included)
|
||||||
|
|
||||||
|
def _keep(r: dict) -> bool:
|
||||||
|
if domain and r["domain"] != domain:
|
||||||
|
return False
|
||||||
|
if entity and r["entity"] != entity:
|
||||||
|
return False
|
||||||
|
if kind and r["kind"] != kind:
|
||||||
|
return False
|
||||||
|
if date_from and (r["date"] or "") < date_from:
|
||||||
|
return False
|
||||||
|
if date_to and (r["date"] or "") > date_to:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
rows = [r for r in included if _keep(r)]
|
||||||
|
rows.sort(key=lambda r: (r["date"] or "", r["title"]), reverse=True)
|
||||||
|
dates = sorted({r["date"] for r in included if r["date"]})
|
||||||
|
data = []
|
||||||
|
for r in rows:
|
||||||
|
r = dict(r)
|
||||||
|
r.pop("content", None)
|
||||||
|
data.append(r)
|
||||||
|
payload = {
|
||||||
|
"type": "report",
|
||||||
|
"count": len(data),
|
||||||
|
"available": any(s["exists"] for s in sources),
|
||||||
|
"scope": scope,
|
||||||
|
"sources": sources,
|
||||||
|
"excluded": excluded,
|
||||||
|
"facets": {
|
||||||
|
"domain": _facet(included, "domain", DOMAIN_LABELS),
|
||||||
|
"entity": _facet(included, "entity", {}),
|
||||||
|
"kind": _facet(included, "kind", KIND_LABELS),
|
||||||
|
"date": {"min": dates[0] if dates else "", "max": dates[-1] if dates else ""},
|
||||||
|
},
|
||||||
|
"totals": {"visible": visible_total, "filtered_out": visible_total - len(data)},
|
||||||
|
"data": data,
|
||||||
|
"format": "markdown",
|
||||||
|
"spec": REPORT_SPEC_PATH,
|
||||||
|
}
|
||||||
|
if diagnose:
|
||||||
|
payload["excluded_detail"] = detail
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/report/{rid}")
|
||||||
|
def get_report(rid: str, request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_auth)):
|
||||||
|
"""单篇报告(含 md 正文,实时读盘)。`rid` = `<source_key>:<文件名主干>`"""
|
||||||
|
key, sep, stem = (rid or "").partition(":")
|
||||||
|
if not sep or not key:
|
||||||
|
raise HTTPException(400, "无效的报告标识(应为 <source>:<文件名>)")
|
||||||
|
if not _SAFE_STEM_RE.match(stem) or stem.startswith(".") or ".." in stem:
|
||||||
|
raise HTTPException(400, "无效的报告标识(文件名非法)")
|
||||||
|
base = {k: p for k, p in reports_dirs()}.get(key)
|
||||||
|
if base is None:
|
||||||
|
raise HTTPException(404, "报告不存在")
|
||||||
|
path = base / f"{stem}.md"
|
||||||
|
if path.parent != base or not path.is_file():
|
||||||
|
raise HTTPException(404, "报告不存在")
|
||||||
|
rec, reason = _read_report(path, key, full=True)
|
||||||
|
if reason:
|
||||||
|
raise HTTPException(422, f"报告格式不合法: {reason}")
|
||||||
|
scope = _entity_scope(db, current_user, request)
|
||||||
|
if not _report_visible(rec, scope):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
f"无权访问企业 {rec.get('entity') or '(未标注)'} 的报告(当前账套: {scope.get('entity_names') or '未绑定'})",
|
||||||
|
)
|
||||||
|
return rec
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""报告解读(type=report)端点测试 —— 任务 yanxue-20260924-cma-report-source
|
||||||
|
|
||||||
|
规范:/srv/hermes/wisdom/_decisions/report-frontmatter-spec-20260924.md
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
1. 正样本:带 front-matter + `conclusion` ⇒ 进列表(字段完整 / kpi_links 一跳链)
|
||||||
|
2. 负对照 a:无 front-matter 的回执件 ⇒ **不进列表、不报错**(excluded.no_frontmatter 计数)
|
||||||
|
3. 负对照 b:有 front-matter 但缺 `conclusion` ⇒ 不进列表(excluded.missing_conclusion)
|
||||||
|
4. 软必填:title/date/domain/sources 缺失 ⇒ 仍收录 + 标 incomplete + 兜底值
|
||||||
|
5. 筛选:domain / entity / kind / date_from+date_to
|
||||||
|
6. 权限:非本账套 entity 的报告不可见(列表不含 + 详情 403);ceo/it 跨账套可见
|
||||||
|
7. 详情:正文 200 / 未知 rid 404 / 非法 rid 400(路径穿越)
|
||||||
|
8. 核心判据:改 md 的 conclusion ⇒ 下一请求即变(运行期读盘,不改前端、不 rebuild)
|
||||||
|
9. 回归:`type` 缺省 = 知识库分支(既有结构 categories/skipped/format 逐字不变)
|
||||||
|
10. 落点缺失 ⇒ available=false 且 count=0,不 500
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.conftest import (
|
||||||
|
TEST_USER_PASSWORD, auth_header, create_test_user, get_token_for_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
REPORTS_ENV = "CMA_REPORTS_DIRS"
|
||||||
|
DOCS_ENV = "CMA_DOCS_DIR"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def token(client: TestClient, db) -> str:
|
||||||
|
"""默认会话 = ceo 角色 + entity_id=1(酣客)"""
|
||||||
|
create_test_user(db)
|
||||||
|
return get_token_for_user(client)
|
||||||
|
|
||||||
|
|
||||||
|
def write_report(root, name, *, title="样例报告", date="2026-09-24", domain="tech",
|
||||||
|
entity="通用", kind="report", conclusion="一句话结论(≤60字)",
|
||||||
|
sources="srv/hermes/wisdom/_reports/x.md", extra="", body="正文段落。\n"):
|
||||||
|
path = root / f"{name}.md"
|
||||||
|
lines = ["---"]
|
||||||
|
if title is not None:
|
||||||
|
lines.append(f"title: {title}")
|
||||||
|
if date is not None:
|
||||||
|
lines.append(f"date: {date}")
|
||||||
|
if domain is not None:
|
||||||
|
lines.append(f"domain: {domain}")
|
||||||
|
if entity is not None:
|
||||||
|
lines.append(f"entity: {entity}")
|
||||||
|
if kind is not None:
|
||||||
|
lines.append(f"kind: {kind}")
|
||||||
|
if conclusion is not None:
|
||||||
|
lines.append(f"conclusion: {conclusion}")
|
||||||
|
if sources is not None:
|
||||||
|
lines.append(f"sources: {sources}")
|
||||||
|
if extra:
|
||||||
|
lines.append(extra)
|
||||||
|
lines.append("---")
|
||||||
|
path.write_text("\n".join(lines) + "\n" + body, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def reports_dir(tmp_path, monkeypatch):
|
||||||
|
"""隔离的临时报告落点(不动任何真实落点)"""
|
||||||
|
root = tmp_path / "wisdom-reports"
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
monkeypatch.setenv(REPORTS_ENV, f"wisdom={root}")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def docs_dir(tmp_path, monkeypatch):
|
||||||
|
root = tmp_path / "cma-content"
|
||||||
|
(root / "docs").mkdir(parents=True)
|
||||||
|
(root / "categories.json").write_text(json.dumps({"categories": [
|
||||||
|
{"key": "term", "name": "术语解释", "icon": "📖", "order": 10},
|
||||||
|
]}, ensure_ascii=False), encoding="utf-8")
|
||||||
|
monkeypatch.setenv(DOCS_ENV, str(root))
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def two_entities(client, db):
|
||||||
|
"""额外造一个 active 账套(博海,id=2)—— 权限分区用例用"""
|
||||||
|
from app.models import Entity
|
||||||
|
|
||||||
|
if not db.query(Entity).filter(Entity.id == 2).first():
|
||||||
|
db.add(Entity(id=2, name="博海测试企业", short_name="博海", status="active"))
|
||||||
|
db.commit()
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def login_for(client, db, username, entity_id, role="finance"):
|
||||||
|
"""建用户 + 只授权指定账套 + 登录,返回 token(显式 UserEntity ⇒ 不触发全网自动授权)"""
|
||||||
|
from app.models import UserEntity
|
||||||
|
|
||||||
|
user = create_test_user(db, username=username, name=username, role=role)
|
||||||
|
db.add(UserEntity(user_id=user.id, entity_id=entity_id))
|
||||||
|
db.commit()
|
||||||
|
r = client.post("/api/cma/auth/login", json={
|
||||||
|
"username": username, "password": TEST_USER_PASSWORD, "entity_id": entity_id,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()["token"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestReportListPositive:
|
||||||
|
def test_frontmatter_report_listed(self, client, reports_dir, token):
|
||||||
|
write_report(reports_dir, "r-ok", extra="kpi_refs: kpi:7, 应收账款周转率")
|
||||||
|
r = client.get("/api/cma/docs?type=report", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["type"] == "report" and body["count"] == 1 and body["available"] is True
|
||||||
|
rec = body["data"][0]
|
||||||
|
assert rec["id"] == "wisdom:r-ok" and rec["title"] == "样例报告"
|
||||||
|
assert rec["conclusion"] == "一句话结论(≤60字)"
|
||||||
|
assert rec["domain"] == "tech" and rec["entity"] == "通用" and rec["kind"] == "report"
|
||||||
|
assert rec["sources"] == ["srv/hermes/wisdom/_reports/x.md"]
|
||||||
|
assert rec["incomplete"] == []
|
||||||
|
# kpi_refs 一跳链:数字/kpi:<id> → 指标详情页;文本 → 指标字典页关键词
|
||||||
|
links = {x["raw"]: x["url"] for x in rec["kpi_links"]}
|
||||||
|
assert links["kpi:7"] == "/kpis/7"
|
||||||
|
assert links["应收账款周转率"].startswith("/kpis?keyword=")
|
||||||
|
# 列表不带正文(与知识库同口径)
|
||||||
|
assert "content" not in rec
|
||||||
|
assert body["sources"][0]["key"] == "wisdom" and body["sources"][0]["md_count"] == 1
|
||||||
|
|
||||||
|
def test_facets_and_sort(self, client, reports_dir, token):
|
||||||
|
write_report(reports_dir, "r-a", title="旧", date="2026-09-20", domain="finance")
|
||||||
|
write_report(reports_dir, "r-b", title="新", date="2026-09-24", domain="tech")
|
||||||
|
body = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json()
|
||||||
|
assert [d["title"] for d in body["data"]] == ["新", "旧"] # date desc
|
||||||
|
assert {f["key"] for f in body["facets"]["domain"]} == {"finance", "tech"}
|
||||||
|
assert body["facets"]["date"] == {"min": "2026-09-20", "max": "2026-09-24"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestNegativeControls:
|
||||||
|
"""★ 本单负对照判据:不进列表、不报错"""
|
||||||
|
|
||||||
|
def test_no_frontmatter_excluded_silently(self, client, reports_dir, token):
|
||||||
|
(reports_dir / "receipt-no-front.md").write_text(
|
||||||
|
"# 回执\n\n任务已完成,无 front-matter。\n", encoding="utf-8")
|
||||||
|
write_report(reports_dir, "r-ok")
|
||||||
|
r = client.get("/api/cma/docs?type=report", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200, r.text # 不报错
|
||||||
|
body = r.json()
|
||||||
|
assert [d["id"] for d in body["data"]] == ["wisdom:r-ok"]
|
||||||
|
assert body["excluded"]["no_frontmatter"] == 1
|
||||||
|
assert not any("receipt-no-front" in d["id"] for d in body["data"])
|
||||||
|
|
||||||
|
def test_missing_conclusion_excluded_silently(self, client, reports_dir, token):
|
||||||
|
write_report(reports_dir, "r-no-conclusion", conclusion=None)
|
||||||
|
write_report(reports_dir, "r-empty-conclusion", conclusion=" ")
|
||||||
|
write_report(reports_dir, "r-ok")
|
||||||
|
r = client.get("/api/cma/docs?type=report", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert [d["id"] for d in body["data"]] == ["wisdom:r-ok"]
|
||||||
|
assert body["excluded"]["missing_conclusion"] == 2
|
||||||
|
# 自诊断(diagnose=1)能看到被排除原因,但默认响应不含任何报错
|
||||||
|
d = client.get("/api/cma/docs?type=report&diagnose=1", headers=auth_header(token)).json()
|
||||||
|
reasons = {x["file"]: x["reason"] for x in d["excluded_detail"]}
|
||||||
|
assert reasons["r-no-conclusion.md"] == "missing_conclusion"
|
||||||
|
|
||||||
|
def test_excluded_report_detail_404_not_500(self, client, reports_dir, token):
|
||||||
|
(reports_dir / "receipt-no-front.md").write_text("无 front-matter\n", encoding="utf-8")
|
||||||
|
r = client.get("/api/cma/docs/report/wisdom:receipt-no-front", headers=auth_header(token))
|
||||||
|
assert r.status_code == 422 and "no_frontmatter" in r.text
|
||||||
|
|
||||||
|
def test_soft_required_fields_marked_incomplete(self, client, reports_dir, token):
|
||||||
|
write_report(reports_dir, "r-partial", title=None, date=None, domain=None, sources=None)
|
||||||
|
body = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json()
|
||||||
|
rec = body["data"][0]
|
||||||
|
assert rec["incomplete"] == ["title", "date", "domain", "sources"]
|
||||||
|
assert rec["title"] == "r-partial" # 兜底:文件名
|
||||||
|
assert rec["domain"] == "未分类" # 兜底:未分类
|
||||||
|
assert rec["date"] # 兜底:文件 mtime 日
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilters:
|
||||||
|
def test_domain_entity_kind_date(self, client, reports_dir, token):
|
||||||
|
write_report(reports_dir, "r-fin", date="2026-09-20", domain="finance", entity="通用", kind="analysis")
|
||||||
|
write_report(reports_dir, "r-tech", date="2026-09-24", domain="tech", entity="通用", kind="warning")
|
||||||
|
base = "/api/cma/docs?type=report"
|
||||||
|
|
||||||
|
def ids(q):
|
||||||
|
return [d["id"] for d in client.get(base + q, headers=auth_header(token)).json()["data"]]
|
||||||
|
|
||||||
|
assert ids("&domain=finance") == ["wisdom:r-fin"]
|
||||||
|
assert ids("&kind=warning") == ["wisdom:r-tech"]
|
||||||
|
assert ids("&date_from=2026-09-22") == ["wisdom:r-tech"]
|
||||||
|
assert ids("&date_to=2026-09-22") == ["wisdom:r-fin"]
|
||||||
|
assert ids("&domain=market") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermissionByEntity:
|
||||||
|
"""权限:按 entity + CMA 既有 roles 分区(客户只看自己实体)"""
|
||||||
|
|
||||||
|
def _seed(self, reports_dir):
|
||||||
|
write_report(reports_dir, "r-hanke", date="2026-09-24", entity="酣客")
|
||||||
|
write_report(reports_dir, "r-bohai", date="2026-09-24", entity="博海")
|
||||||
|
write_report(reports_dir, "r-common", date="2026-09-24", entity="通用")
|
||||||
|
write_report(reports_dir, "r-group", date="2026-09-24", entity="集团")
|
||||||
|
write_report(reports_dir, "r-nomark", date="2026-09-24", entity=None)
|
||||||
|
|
||||||
|
def test_entity1_finance_sees_only_own(self, client, db, reports_dir, two_entities):
|
||||||
|
self._seed(reports_dir)
|
||||||
|
tok = login_for(client, db, "fin1", 1)
|
||||||
|
body = client.get("/api/cma/docs?type=report", headers=auth_header(tok)).json()
|
||||||
|
ids = {d["id"] for d in body["data"]}
|
||||||
|
assert ids == {"wisdom:r-hanke", "wisdom:r-common", "wisdom:r-group", "wisdom:r-nomark"}
|
||||||
|
assert body["scope"]["cross_entity"] is False
|
||||||
|
assert body["scope"]["entity_id"] == 1
|
||||||
|
# 详情:跨账套报告不可见
|
||||||
|
r = client.get("/api/cma/docs/report/wisdom:r-bohai", headers=auth_header(tok))
|
||||||
|
assert r.status_code == 403 and "无权访问企业" in r.text
|
||||||
|
# 本账套报告可取
|
||||||
|
assert client.get("/api/cma/docs/report/wisdom:r-hanke",
|
||||||
|
headers=auth_header(tok)).status_code == 200
|
||||||
|
|
||||||
|
def test_entity2_finance_sees_own_only(self, client, db, reports_dir, two_entities):
|
||||||
|
self._seed(reports_dir)
|
||||||
|
tok = login_for(client, db, "fin2", 2)
|
||||||
|
body = client.get("/api/cma/docs?type=report", headers=auth_header(tok)).json()
|
||||||
|
ids = {d["id"] for d in body["data"]}
|
||||||
|
assert ids == {"wisdom:r-bohai", "wisdom:r-common", "wisdom:r-group", "wisdom:r-nomark"}
|
||||||
|
assert client.get("/api/cma/docs/report/wisdom:r-hanke",
|
||||||
|
headers=auth_header(tok)).status_code == 403
|
||||||
|
|
||||||
|
def test_ceo_cross_entity(self, client, db, reports_dir, two_entities):
|
||||||
|
self._seed(reports_dir)
|
||||||
|
tok = login_for(client, db, "ceo1", 1, role="ceo")
|
||||||
|
body = client.get("/api/cma/docs?type=report", headers=auth_header(tok)).json()
|
||||||
|
assert len(body["data"]) == 5 and body["scope"]["cross_entity"] is True
|
||||||
|
|
||||||
|
def test_it_cross_entity(self, client, db, reports_dir, two_entities):
|
||||||
|
self._seed(reports_dir)
|
||||||
|
tok = login_for(client, db, "it1", 1, role="it")
|
||||||
|
body = client.get("/api/cma/docs?type=report", headers=auth_header(tok)).json()
|
||||||
|
assert len(body["data"]) == 5 and body["scope"]["cross_entity"] is True
|
||||||
|
|
||||||
|
def test_no_token_401(self, client, reports_dir):
|
||||||
|
assert client.get("/api/cma/docs?type=report").status_code == 401
|
||||||
|
assert client.get("/api/cma/docs/report/wisdom:r-ok").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestReportDetail:
|
||||||
|
def test_detail_body_and_unknown_and_traversal(self, client, reports_dir, token):
|
||||||
|
write_report(reports_dir, "r-ok", body="## 小标题\n\n结论正文。\n")
|
||||||
|
d = client.get("/api/cma/docs/report/wisdom:r-ok", headers=auth_header(token))
|
||||||
|
assert d.status_code == 200
|
||||||
|
rec = d.json()
|
||||||
|
assert rec["id"] == "wisdom:r-ok" and rec["content"] == "## 小标题\n\n结论正文。\n"
|
||||||
|
|
||||||
|
assert client.get("/api/cma/docs/report/wisdom:nope",
|
||||||
|
headers=auth_header(token)).status_code == 404
|
||||||
|
assert client.get("/api/cma/docs/report/nosuchsource:r-ok",
|
||||||
|
headers=auth_header(token)).status_code == 404
|
||||||
|
from app.api import doc_center
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
for bad in ["r-ok", "wisdom:../escape", "wisdom:a/b", "wisdom:", "wisdom:..",
|
||||||
|
"wisdom:.hidden"]:
|
||||||
|
with pytest.raises(HTTPException) as ei:
|
||||||
|
doc_center.get_report(bad, request=None, db=None, current_user=None)
|
||||||
|
assert ei.value.status_code in (400, 404), bad
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuntimeSource:
|
||||||
|
"""核心判据:运行期读盘 —— 改一篇报告的结构字段即生效,不改前端、不 rebuild"""
|
||||||
|
|
||||||
|
def test_edit_conclusion_visible_on_next_request(self, client, reports_dir, token):
|
||||||
|
p = write_report(reports_dir, "r-ok", conclusion="旧结论")
|
||||||
|
r1 = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json()
|
||||||
|
assert r1["data"][0]["conclusion"] == "旧结论"
|
||||||
|
p.write_text(p.read_text(encoding="utf-8").replace("旧结论", "新结论"), encoding="utf-8")
|
||||||
|
r2 = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json()
|
||||||
|
assert r2["data"][0]["conclusion"] == "新结论"
|
||||||
|
|
||||||
|
def test_new_report_visible_without_restart(self, client, reports_dir, token):
|
||||||
|
assert client.get("/api/cma/docs?type=report", headers=auth_header(token)).json()["count"] == 0
|
||||||
|
write_report(reports_dir, "r-new")
|
||||||
|
body = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json()
|
||||||
|
assert body["count"] == 1 and body["data"][0]["id"] == "wisdom:r-new"
|
||||||
|
|
||||||
|
|
||||||
|
class TestKnowledgeRegression:
|
||||||
|
"""回归:type 缺省 = 知识库分支(既有结构逐字不变)+ 报告来源不污染知识库列表"""
|
||||||
|
|
||||||
|
def test_knowledge_branch_intact(self, client, docs_dir, reports_dir, token):
|
||||||
|
d = docs_dir / "docs" / "term-a.md"
|
||||||
|
d.write_text('---\ntitle: "A术语"\ncategory: term\ncategory_name: 术语解释\n'
|
||||||
|
'icon: "📖"\nsummary: "摘要"\norder: 10\n---\n正文\n', encoding="utf-8")
|
||||||
|
write_report(reports_dir, "r-ok")
|
||||||
|
r = client.get("/api/cma/docs", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "categories" in body and "skipped" in body and body["format"] == "markdown"
|
||||||
|
assert [x["slug"] for x in body["data"]] == ["term-a"]
|
||||||
|
assert "type" not in body
|
||||||
|
# 报告落点的件不得混进知识库列表
|
||||||
|
assert all(x["slug"] != "r-ok" for x in body["data"])
|
||||||
|
|
||||||
|
def test_missing_reports_dir_not_500(self, client, tmp_path, monkeypatch, token):
|
||||||
|
monkeypatch.setenv(REPORTS_ENV, f"wisdom={tmp_path / 'not-exist'}")
|
||||||
|
r = client.get("/api/cma/docs?type=report", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["count"] == 0 and body["available"] is False and body["data"] == []
|
||||||
@@ -50,7 +50,81 @@
|
|||||||
</MyDialog>
|
</MyDialog>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- Tab 2: 职业道德自测 -->
|
<!-- Tab 2: 报告解读(读结论)—— 与知识库并列的分区,不是第二个报表中心(看数在 管理报表/BI报表) -->
|
||||||
|
<el-tab-pane label="📄 报告解读" name="reports">
|
||||||
|
<div class="kb-header">
|
||||||
|
<h3>报告解读</h3>
|
||||||
|
<div class="rp-filters">
|
||||||
|
<el-date-picker v-model="reportRange" type="daterange" value-format="YYYY-MM-DD"
|
||||||
|
start-placeholder="开始日" end-placeholder="结束日" style="width:250px;" @change="loadReports" />
|
||||||
|
<el-select v-model="reportDomain" placeholder="域" clearable style="width:110px" @change="loadReports">
|
||||||
|
<el-option v-for="f in reportFacets.domain" :key="f.key" :label="f.name" :value="f.key" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="reportEntity" placeholder="账套" clearable style="width:110px" @change="loadReports">
|
||||||
|
<el-option v-for="f in reportFacets.entity" :key="f.key" :label="f.key" :value="f.key" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="reportKind" placeholder="类型" clearable style="width:110px" @change="loadReports">
|
||||||
|
<el-option v-for="f in reportFacets.kind" :key="f.key" :label="f.name" :value="f.key" />
|
||||||
|
</el-select>
|
||||||
|
<el-button @click="loadReports">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="reportLoading" class="kb-hint">报告加载中...</div>
|
||||||
|
<div v-else-if="reportError" class="kb-hint kb-hint-error">{{ reportError }}</div>
|
||||||
|
<div v-else-if="reports.length === 0" class="kb-hint">
|
||||||
|
暂无报告解读内容(只收录带 front-matter 且含 conclusion 的报告件;回执/审计/施工件按规范不进此页)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rp-list">
|
||||||
|
<div v-for="r in reports" :key="r.id" class="rp-item" @click="openReport(r)">
|
||||||
|
<div class="rp-item-head">
|
||||||
|
<span class="rp-date">{{ r.date }}</span>
|
||||||
|
<span class="rp-title">{{ r.title }}</span>
|
||||||
|
<span class="rp-tag">{{ domainLabel(r.domain) }}</span>
|
||||||
|
<span v-if="r.entity" class="rp-tag rp-tag-entity">{{ r.entity }}</span>
|
||||||
|
<span class="rp-tag rp-tag-kind">{{ kindLabel(r.kind) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rp-conclusion">{{ r.conclusion }}</div>
|
||||||
|
<div class="rp-meta">
|
||||||
|
<span v-if="r.incomplete && r.incomplete.length" class="rp-warn">缺 {{ r.incomplete.join('/') }}</span>
|
||||||
|
<span>{{ r.source }}:{{ r.file }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 报告详情弹窗:结论 + md 正文 + kpi_refs 一跳回链(读结论 → 看数) -->
|
||||||
|
<MyDialog v-model="showReport" :title="reportDetail?.title || '报告详情'" :width="760">
|
||||||
|
<div v-if="reportDetail" class="detail-content">
|
||||||
|
<div class="rp-detail-meta">
|
||||||
|
<span class="rp-date">{{ reportDetail.date }}</span>
|
||||||
|
<span class="rp-tag">{{ domainLabel(reportDetail.domain) }}</span>
|
||||||
|
<span v-if="reportDetail.entity" class="rp-tag rp-tag-entity">{{ reportDetail.entity }}</span>
|
||||||
|
<span class="rp-tag rp-tag-kind">{{ kindLabel(reportDetail.kind) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rp-detail-conclusion"><strong>结论:</strong>{{ reportDetail.conclusion }}</div>
|
||||||
|
<div v-if="reportDetail.impact" class="rp-detail-row"><strong>影响:</strong>{{ reportDetail.impact }}</div>
|
||||||
|
<div v-if="reportDetail.sources && reportDetail.sources.length" class="rp-detail-row">
|
||||||
|
<strong>出处:</strong>{{ reportDetail.sources.join(' · ') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="reportDetail.decisions && reportDetail.decisions.length" class="rp-detail-row">
|
||||||
|
<strong>待拍板:</strong>{{ reportDetail.decisions.join(' · ') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="reportDetail.kpi_links && reportDetail.kpi_links.length" class="rp-detail-row">
|
||||||
|
<strong>关联指标:</strong>
|
||||||
|
<el-button v-for="k in reportDetail.kpi_links" :key="k.raw" link type="primary"
|
||||||
|
class="rp-kpi-link" @click="gotoKpi(k)">{{ k.raw }} ↗</el-button>
|
||||||
|
</div>
|
||||||
|
<hr class="rp-hr" />
|
||||||
|
<div v-html="reportHtml"></div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showReport = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</MyDialog>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- Tab 3: 职业道德自测 -->
|
||||||
<el-tab-pane label="⚖️ 职业道德自测" name="ethics-quiz">
|
<el-tab-pane label="⚖️ 职业道德自测" name="ethics-quiz">
|
||||||
<div v-if="!quizStarted" class="quiz-intro">
|
<div v-if="!quizStarted" class="quiz-intro">
|
||||||
<div class="quiz-intro-card">
|
<div class="quiz-intro-card">
|
||||||
@@ -298,6 +372,84 @@ async function loadDynamicArticles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 报告解读(type=report):读结论 → 看数(kpi_refs 一跳回链指标页) ──────────────
|
||||||
|
// 口径(规范 report-frontmatter-spec-20260924):只收录带 front-matter 且含 conclusion 的报告件;
|
||||||
|
// 回执/审计/施工/工作件天然不带 front-matter ⇒ 不进本页(不搬家、不新建内容目录)。
|
||||||
|
// 看数在 管理报表/BI报表 —— 本分区只做「读结论」,不做第二个报表中心。
|
||||||
|
import { watch } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const reports = ref<any[]>([])
|
||||||
|
const reportLoading = ref(false)
|
||||||
|
const reportError = ref("")
|
||||||
|
const reportRange = ref<any>(null)
|
||||||
|
const reportDomain = ref("")
|
||||||
|
const reportEntity = ref("")
|
||||||
|
const reportKind = ref("")
|
||||||
|
const reportFacets = ref<any>({ domain: [], entity: [], kind: [], date: { min: "", max: "" } })
|
||||||
|
const showReport = ref(false)
|
||||||
|
const reportDetail = ref<any>(null)
|
||||||
|
const reportHtml = ref("")
|
||||||
|
|
||||||
|
const REPORT_DOMAIN_LABELS: Record<string, string> = {
|
||||||
|
finance: "财务", customer: "客户", process: "流程", learning: "学习成长",
|
||||||
|
tech: "技术", ops: "运维", market: "市场", gov: "政务",
|
||||||
|
}
|
||||||
|
const REPORT_KIND_LABELS: Record<string, string> = {
|
||||||
|
report: "报告", analysis: "分析", brief: "摘要", warning: "预警",
|
||||||
|
}
|
||||||
|
function domainLabel(k: string) { return REPORT_DOMAIN_LABELS[k] || k || "未分类" }
|
||||||
|
function kindLabel(k: string) { return REPORT_KIND_LABELS[k] || k || "报告" }
|
||||||
|
|
||||||
|
async function loadReports() {
|
||||||
|
reportLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = { type: "report" }
|
||||||
|
if (reportRange.value && reportRange.value[0]) {
|
||||||
|
params.date_from = reportRange.value[0]
|
||||||
|
params.date_to = reportRange.value[1]
|
||||||
|
}
|
||||||
|
if (reportDomain.value) params.domain = reportDomain.value
|
||||||
|
if (reportEntity.value) params.entity = reportEntity.value
|
||||||
|
if (reportKind.value) params.kind = reportKind.value
|
||||||
|
const body: any = (await api.get("/docs", { params })) || {}
|
||||||
|
reports.value = Array.isArray(body.data) ? body.data : []
|
||||||
|
reportFacets.value = body.facets || { domain: [], entity: [], kind: [], date: { min: "", max: "" } }
|
||||||
|
reportError.value = ""
|
||||||
|
} catch (e) {
|
||||||
|
reportError.value = "报告加载失败,请稍后重试"
|
||||||
|
console.error("加载报告解读失败", e)
|
||||||
|
}
|
||||||
|
reportLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openReport(r: any) {
|
||||||
|
reportDetail.value = r
|
||||||
|
reportHtml.value = ""
|
||||||
|
showReport.value = true
|
||||||
|
try {
|
||||||
|
const d: any = await api.get(`/docs/report/${r.id}`)
|
||||||
|
if (d) {
|
||||||
|
reportDetail.value = d
|
||||||
|
reportHtml.value = marked(d.content || "") as string
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error("报告正文加载失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一跳回链:`kpi:<id>` / 数字 → 指标详情页;其余 → 指标字典页(关键词)
|
||||||
|
function gotoKpi(k: any) {
|
||||||
|
const url = (k && k.url) || "/kpis"
|
||||||
|
router.push(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切到「报告解读」时才拉取(不拖慢知识库首屏)
|
||||||
|
watch(activeTab, (v) => {
|
||||||
|
if (v === "reports" && reports.value.length === 0) loadReports()
|
||||||
|
})
|
||||||
|
|
||||||
// 页面加载:先取 md 单一来源文档,再追加 DB 动态知识库(合并语义与改动前一致)
|
// 页面加载:先取 md 单一来源文档,再追加 DB 动态知识库(合并语义与改动前一致)
|
||||||
import { onMounted } from "vue"
|
import { onMounted } from "vue"
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -474,6 +626,26 @@ function backToIntro() {
|
|||||||
.detail-content :deep(th), .detail-content :deep(td) { border: 1px solid #e0e0e0; padding: 8px; text-align: left; }
|
.detail-content :deep(th), .detail-content :deep(td) { border: 1px solid #e0e0e0; padding: 8px; text-align: left; }
|
||||||
.detail-content :deep(th) { background: #f5f5f5; font-weight: 600; }
|
.detail-content :deep(th) { background: #f5f5f5; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── 报告解读(type=report) ── */
|
||||||
|
.rp-filters { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.rp-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.rp-item { background: #fff; border-radius: 10px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; transition: background 0.2s; }
|
||||||
|
.rp-item:hover { background: #f5f7fa; }
|
||||||
|
.rp-item-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||||
|
.rp-date { font-size: 12px; color: #909399; }
|
||||||
|
.rp-title { font-size: 15px; font-weight: 600; color: #303133; }
|
||||||
|
.rp-tag { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: #ecf5ff; color: #409eff; }
|
||||||
|
.rp-tag-entity { background: #f0f9eb; color: #67c23a; }
|
||||||
|
.rp-tag-kind { background: #fdf6ec; color: #e6a23c; }
|
||||||
|
.rp-conclusion { font-size: 13px; color: #303133; line-height: 1.65; }
|
||||||
|
.rp-meta { display: flex; gap: 10px; margin-top: 6px; font-size: 11px; color: #c0c4cc; flex-wrap: wrap; }
|
||||||
|
.rp-warn { color: #e6a23c; }
|
||||||
|
.rp-detail-meta { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
|
||||||
|
.rp-detail-conclusion { background: #f5f7fa; border-left: 3px solid #409eff; padding: 10px 12px; border-radius: 4px; font-size: 14px; line-height: 1.7; margin-bottom: 8px; }
|
||||||
|
.rp-detail-row { font-size: 13px; color: #606266; margin-bottom: 6px; line-height: 1.7; }
|
||||||
|
.rp-kpi-link { margin-right: 12px; padding: 0; }
|
||||||
|
.rp-hr { border: none; border-top: 1px solid #ebeef5; margin: 14px 0; }
|
||||||
|
|
||||||
/* ── Quiz Intro ── */
|
/* ── Quiz Intro ── */
|
||||||
.quiz-intro { display: flex; justify-content: center; padding: 40px 0; }
|
.quiz-intro { display: flex; justify-content: center; padding: 40px 0; }
|
||||||
.quiz-intro-card {
|
.quiz-intro-card {
|
||||||
|
|||||||
Reference in New Issue
Block a user