feat(docs-center): CMAKnowledge reads md single source via new /api/cma/docs
ci/woodpecker/push/main Pipeline failed

New router app/api/doc_center.py: GET /api/cma/docs (list, ?with_content) and GET /api/cma/docs/{slug}; runtime disk read, role-gated (ceo/finance/business/it), no existing API semantics changed.

Content dir /srv/hermes/shared/cma-content (docs/*.md + categories.json). Placed outside the repo on purpose: the deploy tree equals the committed tree (deploy.sh checkout -f + clean -fdx), so in-repo content would be reset on every deploy and content edits would fail the deploy-tree dirty check.

CMAKnowledge.vue: 20 hardcoded items migrated to md, page now fetches at runtime and renders md with the existing marked dep (no new deps); table borders restored via :deep() since scoped styles never reach v-html content.

Tests: backend/tests/test_doc_center_20260924.py (20 cases). Full suite 1448 passed (baseline 1428 + 20).
This commit is contained in:
Hermes CI Fix
2026-09-24 01:07:29 +08:00
parent 72d4d97c83
commit ede0524c22
4 changed files with 512 additions and 286 deletions
+233
View File
@@ -0,0 +1,233 @@
"""CMA 文档中心 API —— md 单一来源(任务 yanxue-20260924-cma-docs-center-poc)
口径(任总 2026-09-24 00:54 拍板):**指标进 UI、结论出 md、机器走 API/MCP。**
设计要点
--------
1. 内容单一来源 = 运行期目录(默认 `/srv/hermes/shared/cma-content`,可用 `CMA_DOCS_DIR` 覆盖),
一篇一个 `docs/<slug>.md`,front-matter 带 title/category/category_name/icon/summary/order。
2. **故意不放在仓库内**:CMA 部署链是「部署树 = origin/main 已提交树」(deploy.sh `[0]/[1]`
`git checkout -f` + `git clean -fdx`):仓内内容目录会被每次部署重置,且内容改动会让
部署树出现「未提交修改」而被 `[1.1]` 判失败、**阻断整个部署**。放仓外 ⇒ 改内容不需要
git/部署/重启,dev 树与部署树天然共读同一份。
3. **本模块每次请求实时读盘,无缓存、不做 build-time 打包** ⇒ 新增/修改一个 md 后,
页面刷新即变,前端无需改码、无需 rebuild。
4. 只**新增**端点,不改任何既有接口语义;内容为系统级共享知识(与 `knowledge_articles`
`entity_id IS NULL` 的全局共享同族),不做账套分区,故不接 `entity_id`。
5. 写口 = 文件系统(Bot/Agent 直接写 md),**不提供写 API**,避免出现"第二来源"。
"""
from __future__ import annotations
import logging
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from app.auth_middleware import require_role
logger = logging.getLogger("cma.doc_center_api")
DEFAULT_DOCS_DIR = "/srv/hermes/shared/cma-content"
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
router = APIRouter(
prefix="/api/cma/docs",
tags=["文档中心"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# ────────────────────────── 内容目录(运行期读盘)──────────────────────────
def content_root() -> Path:
"""内容根目录(每次调用实时解析,便于测试/迁移换目录)"""
return Path(os.environ.get("CMA_DOCS_DIR", DEFAULT_DOCS_DIR))
def docs_dir() -> Path:
return content_root() / "docs"
def _parse_scalar(raw: str):
raw = raw.strip()
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'":
body = raw[1:-1]
return body.replace('\\"', '"').replace("\\\\", "\\")
if re.fullmatch(r"-?\d+", raw or ""):
return int(raw)
return raw
def parse_front_matter(text: str) -> Optional[dict]:
"""极简 YAML front-matter 解析(只支持 `key: value` 标量,避免引入新依赖)"""
if not text.startswith("---"):
return None
end = text.find("\n---", 3)
if end < 0:
return None
meta = {}
for line in text[3:end].splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
key, sep, val = line.partition(":")
if not sep:
continue
meta[key.strip()] = _parse_scalar(val)
body = text[end + 4:]
if body.startswith("\n"):
body = body[1:]
return {"meta": meta, "body": body}
def _category_registry() -> list:
"""分类注册表(categories.json):决定分类顺序与展示名/图标"""
path = content_root() / "categories.json"
cats = []
if path.is_file():
import json
try:
data = json.loads(path.read_text(encoding="utf-8"))
cats = list(data.get("categories") or [])
except Exception as exc: # 注册表损坏不阻断读内容,只在响应里暴露
logger.warning("categories.json 解析失败: %s", exc)
return []
return cats
def _read_doc(path: Path):
try:
text = path.read_text(encoding="utf-8")
except Exception as exc:
return None, f"读取失败: {exc}"
parsed = parse_front_matter(text)
if parsed is None:
return None, "缺少 front-matter(--- 包裹的 title/category/... )"
meta = parsed["meta"]
for field in ("title", "category", "summary"):
if not str(meta.get(field, "")).strip():
return None, f"front-matter 缺必填字段: {field}"
stat = path.stat()
return {
"slug": path.stem,
"title": str(meta["title"]),
"category": str(meta["category"]),
"summary": str(meta["summary"]),
"order": int(meta.get("order") or 0),
"icon": str(meta.get("icon") or ""),
"category_name": str(meta.get("category_name") or ""),
"bytes": stat.st_size,
"updated_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"content": parsed["body"],
}, None
def load_docs() -> tuple:
"""实时读盘:返回 (docs, skipped, available)。docs 已按 (category order, doc order, slug) 排序"""
d = docs_dir()
if not d.is_dir():
return [], [], False
cats = _category_registry()
cat_order = {c.get("key"): int(c.get("order") or 0) for c in cats}
docs, skipped = [], []
for path in sorted(d.glob("*.md")):
if not SLUG_RE.match(path.stem):
skipped.append({"file": path.name, "reason": "文件名不符合 slug 规则 ^[a-z0-9][a-z0-9-]{0,63}$"})
continue
doc, err = _read_doc(path)
if err:
skipped.append({"file": path.name, "reason": err})
continue
docs.append(doc)
docs.sort(key=lambda x: (cat_order.get(x["category"], 10_000), x["category"], x["order"], x["slug"]))
return docs, skipped, True
def _categories_view(docs: list) -> list:
"""分类视图:按注册表 `order` 升序(未登记分类补在末尾,不丢内容)"""
cats = _category_registry()
seen, out, seq = set(), [], 0
for c in cats:
key = c.get("key")
if not key or key in seen:
continue
seen.add(key)
seq += 1
out.append({
"key": key,
"name": c.get("name") or key,
"icon": c.get("icon") or "",
"order": int(c.get("order") or 0),
"_seq": seq,
"count": len([d for d in docs if d["category"] == key]),
})
for d in docs:
if d["category"] in seen:
continue
seen.add(d["category"])
seq += 1
out.append({
"key": d["category"],
"name": d["category_name"] or d["category"],
"icon": d["icon"] or "",
"order": 10_000,
"_seq": seq,
"count": len([x for x in docs if x["category"] == d["category"]]),
})
out.sort(key=lambda c: (c["order"], c["_seq"]))
for c in out:
c.pop("_seq")
return out
def _meta_only(doc: dict) -> dict:
return {k: v for k, v in doc.items() if k != "content"}
# ────────────────────────── 端点(只新增)──────────────────────────
@router.get("")
def list_docs(
with_content: bool = Query(False, description="是否带上 md 正文(默认只返回元数据)"),
category: Optional[str] = Query(None, description="按分类 key 过滤"),
):
"""文档列表(实时读盘)。
- `data`:按「分类顺序 → 篇内 order → slug」排序的文档元数据(`with_content=true` 时含正文)
- `categories`:分类视图(name/icon/count),前端按它分组渲染
- `source`:内容目录绝对路径(机器侧取内容用同一来源,满足「机器走 API」)
- `skipped`:被跳过的 md 及原因(缺 front-matter/文件名非法),便于 Bot 自诊断
"""
docs, skipped, available = load_docs()
all_docs = docs
if category:
docs = [d for d in docs if d["category"] == category]
return {
"count": len(docs),
"available": available,
"source": str(docs_dir()),
"categories": _categories_view(all_docs), # 分类视图按全量内容统计(不受 category 过滤影响)
"data": docs if with_content else [_meta_only(d) for d in docs],
"skipped": skipped,
"format": "markdown",
}
@router.get("/{slug}")
def get_doc(slug: str):
"""单篇文档(含 md 正文,实时读盘)"""
if not SLUG_RE.match(slug or ""):
raise HTTPException(400, "无效的文档标识(只允许 [a-z0-9-],1-64 位,字母数字开头)")
path = docs_dir() / f"{slug}.md"
if not path.is_file():
raise HTTPException(404, "文档不存在")
doc, err = _read_doc(path)
if err:
raise HTTPException(422, f"文档格式不合法: {err}")
return doc
+2 -1
View File
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from dotenv import load_dotenv from dotenv import load_dotenv
from app.database import init_db, get_db from app.database import init_db, get_db
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center
from app.utils.cache import clear_all as clear_cache, delete as delete_cache from app.utils.cache import clear_all as clear_cache, delete as delete_cache
from scripts.erp_sync import run_sync as run_erp_sync from scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth, require_auth_401 from app.auth_middleware import require_auth, require_auth_401
@@ -100,6 +100,7 @@ app.include_router(tax_compliance.router)
app.include_router(verify.router) app.include_router(verify.router)
app.include_router(ext_input_ledgers.router) # 外部输入台账(M2 准入3 派生,2026-09-15) app.include_router(ext_input_ledgers.router) # 外部输入台账(M2 准入3 派生,2026-09-15)
app.include_router(treasury.router) # 司库(资金)阶段一:账户池/头寸/内控闸门(2026-09-15) app.include_router(treasury.router) # 司库(资金)阶段一:账户池/头寸/内控闸门(2026-09-15)
app.include_router(doc_center.router) # 文档中心:md 单一来源(运行期读盘,2026-09-24)
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception): async def global_exception_handler(request: Request, exc: Exception):
+215
View File
@@ -0,0 +1,215 @@
"""文档中心(md 单一来源)端点测试 —— 任务 yanxue-20260924-cma-docs-center-poc
覆盖:
1. 列表:元数据/分类视图/排序/默认不带正文、`with_content=true` 带正文
2. 单篇:200 / 未知 slug 404 / 非法 slug 400(含路径穿越)
3. 鉴权:无 token → 401(不得成为 B 类缺口);持 token → 200
4. 健壮性:缺 front-matter / 缺必填字段 → skipped 且不影响其它文档;目录缺失 → available=false 不 500
5. **核心判据**:改 md 后同一进程再次请求即返回新内容(运行期读盘、无缓存、无 build-time 打包)
6. 生产内容目录金丝雀(目录不存在时跳过)
"""
import json
import os
import pytest
from fastapi.testclient import TestClient
from tests.conftest import create_test_user, get_token_for_user, auth_header
DOCS_ENV = "CMA_DOCS_DIR"
PROD_CONTENT = "/srv/hermes/shared/cma-content"
def write_doc(root, slug, body="#### 标题\n\n正文段落。\n", title="标题", category="term",
summary="摘要", category_name="术语解释", icon="📖", order=10, extra=""):
path = root / "docs" / f"{slug}.md"
path.write_text(
"---\n"
f'title: "{title}"\n'
f"category: {category}\n"
f"category_name: {category_name}\n"
f'icon: "{icon}"\n'
f'summary: "{summary}"\n'
f"order: {order}\n"
f"{extra}"
"---\n" + body,
encoding="utf-8",
)
return path
@pytest.fixture
def content_dir(tmp_path, monkeypatch):
"""隔离的临时内容目录(不动生产目录)"""
root = tmp_path / "cma-content"
(root / "docs").mkdir(parents=True)
(root / "categories.json").write_text(json.dumps({"categories": [
{"key": "faq", "name": "常见问题", "icon": "❓", "order": 40},
{"key": "term", "name": "术语解释", "icon": "📖", "order": 10},
]}, ensure_ascii=False), encoding="utf-8")
monkeypatch.setenv(DOCS_ENV, str(root))
return root
@pytest.fixture
def token(client: TestClient, db) -> str:
create_test_user(db)
return get_token_for_user(client)
class TestListDocs:
def test_meta_only_and_category_order(self, client, content_dir, token):
write_doc(content_dir, "term-b", title="B术语", order=20)
write_doc(content_dir, "term-a", title="A术语", order=10)
write_doc(content_dir, "faq-x", title="常见问题X", category="faq",
category_name="常见问题", icon="❓", order=10)
r = client.get("/api/cma/docs", headers=auth_header(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["count"] == 3 and body["available"] is True and body["format"] == "markdown"
assert body["source"].endswith("/docs")
# 分类顺序 = categories.json 的 order(term 10 在 faq 40 之前,与文件写入顺序无关)
assert [c["key"] for c in body["categories"]] == ["term", "faq"]
assert [c["count"] for c in body["categories"]] == [2, 1]
assert body["categories"][0]["name"] == "术语解释" and body["categories"][0]["icon"] == "📖"
# 文档顺序 = (分类 order, 篇内 order, slug)
assert [d["slug"] for d in body["data"]] == ["term-a", "term-b", "faq-x"]
assert "content" not in body["data"][0], "默认不得带正文"
assert set(body["data"][0]) >= {"slug", "title", "summary", "category",
"category_name", "icon", "order", "bytes", "updated_at"}
assert body["skipped"] == []
def test_with_content(self, client, content_dir, token):
write_doc(content_dir, "term-a", body="#### H\n\n**加粗** 正文\n")
r = client.get("/api/cma/docs?with_content=true", headers=auth_header(token))
assert r.status_code == 200
assert r.json()["data"][0]["content"] == "#### H\n\n**加粗** 正文\n"
def test_filter_by_category(self, client, content_dir, token):
write_doc(content_dir, "term-a")
write_doc(content_dir, "faq-x", category="faq", category_name="常见问题")
r = client.get("/api/cma/docs?category=faq", headers=auth_header(token))
body = r.json()
assert [d["slug"] for d in body["data"]] == ["faq-x"]
# 分类视图按全量统计,不受过滤影响
assert [c["count"] for c in body["categories"]] == [1, 1]
def test_unregistered_category_still_listed(self, client, content_dir, token):
write_doc(content_dir, "other-a", category="其他类", category_name="其他类")
r = client.get("/api/cma/docs", headers=auth_header(token))
body = r.json()
assert body["count"] == 1
assert [c["key"] for c in body["categories"]] == ["term", "faq", "其他类"]
class TestGetDoc:
def test_detail(self, client, content_dir, token):
write_doc(content_dir, "term-a", body="#### H\n\n正文x\n")
r = client.get("/api/cma/docs/term-a", headers=auth_header(token))
assert r.status_code == 200
d = r.json()
assert d["slug"] == "term-a" and d["title"] == "标题" and d["content"] == "#### H\n\n正文x\n"
def test_unknown_slug_404(self, client, content_dir, token):
r = client.get("/api/cma/docs/no-such-doc", headers=auth_header(token))
assert r.status_code == 404
@pytest.mark.parametrize("slug", ["Upper", "with space", "a@b", "-lead", "x" * 65])
def test_invalid_slug_400(self, client, content_dir, token, slug):
r = client.get(f"/api/cma/docs/{slug}", headers=auth_header(token))
assert r.status_code in (400, 404), f"非法 slug 不得进入文件系统查找: {slug} → {r.status_code}"
if r.status_code == 400:
assert "无效的文档标识" in r.text
def test_path_traversal_blocked(self, content_dir):
"""越权取文件:`../`、绝对路径、URL 编码一律被 slug 白名单拦下(无 HTTP 层绕行)"""
from app.api import doc_center
from fastapi import HTTPException
for bad in ["../categories", "..%2F..%2Fetc%2Fpasswd", "/etc/passwd", "%2e%2e%2fsecret",
"....//x", "a/b"]:
with pytest.raises(HTTPException) as ei:
doc_center.get_doc(bad)
assert ei.value.status_code == 400
# 正对照:合法 slug 走查文件路径(不存在 → 404,不是 400)
with pytest.raises(HTTPException) as ei:
doc_center.get_doc("term-a")
assert ei.value.status_code == 404
class TestAuth:
def test_no_token_401(self, client, content_dir):
assert client.get("/api/cma/docs").status_code == 401
assert client.get("/api/cma/docs/term-a").status_code == 401
def test_with_token_200(self, client, content_dir, token):
write_doc(content_dir, "term-a")
assert client.get("/api/cma/docs", headers=auth_header(token)).status_code == 200
class TestRobustness:
def test_bad_md_skipped_not_fatal(self, client, content_dir, token):
(content_dir / "docs" / "no-front.md").write_text("没有 front-matter\n", encoding="utf-8")
write_doc(content_dir, "missing-title", title="", summary="s")
(content_dir / "docs" / "BadSlug.md").write_text(
'---\ntitle: "T"\ncategory: term\nsummary: "S"\n---\nX\n', encoding="utf-8")
write_doc(content_dir, "term-good")
r = client.get("/api/cma/docs", headers=auth_header(token))
assert r.status_code == 200
body = r.json()
assert [d["slug"] for d in body["data"]] == ["term-good"]
files = {s["file"]: s["reason"] for s in body["skipped"]}
assert "no-front.md" in files and "front-matter" in files["no-front.md"]
assert "missing-title.md" in files and "title" in files["missing-title.md"]
assert "BadSlug.md" in files and "slug" in files["BadSlug.md"]
def test_missing_dir_not_500(self, client, tmp_path, monkeypatch, token):
monkeypatch.setenv(DOCS_ENV, str(tmp_path / "not-exist"))
r = client.get("/api/cma/docs", headers=auth_header(token))
assert r.status_code == 200
body = r.json()
assert body["count"] == 0 and body["available"] is False and body["data"] == []
def test_front_matter_multi_colon_and_quotes(self, content_dir):
from app.api import doc_center
p = write_doc(content_dir, "term-q", title='带:冒号"引号"的标题', summary="摘要: 含冒号")
doc, err = doc_center._read_doc(p)
assert err is None
assert doc["title"] == '带:冒号"引号"的标题' and doc["summary"] == "摘要: 含冒号"
class TestRuntimeSource:
"""核心判据:内容运行期读盘 —— 改 md 即生效,无需改前端 / rebuild / 重启"""
def test_edit_md_visible_on_next_request(self, client, content_dir, token):
p = write_doc(content_dir, "term-a", body="#### 旧内容\n")
r1 = client.get("/api/cma/docs/term-a", headers=auth_header(token)).json()
assert r1["content"] == "#### 旧内容\n"
# 只改 md 文件(不改码、不重启、不构建)
p.write_text(p.read_text(encoding="utf-8").replace("旧内容", "新内容"), encoding="utf-8")
r2 = client.get("/api/cma/docs/term-a", headers=auth_header(token)).json()
assert r2["content"] == "#### 新内容\n"
def test_add_md_visible_without_restart(self, client, content_dir, token):
assert client.get("/api/cma/docs", headers=auth_header(token)).json()["count"] == 0
write_doc(content_dir, "term-new", title="新增篇目")
body = client.get("/api/cma/docs?with_content=true", headers=auth_header(token)).json()
assert body["count"] == 1 and body["data"][0]["title"] == "新增篇目"
@pytest.mark.skipif(not os.path.isdir(PROD_CONTENT), reason="生产内容目录不存在(非本机环境)")
class TestProdContentCanary:
"""生产内容目录金丝雀:真实目录必须可读、无 skipped、分类齐备(防内容被写坏)"""
def test_prod_content_readable(self, client, token, monkeypatch):
monkeypatch.setenv(DOCS_ENV, PROD_CONTENT)
r = client.get("/api/cma/docs", headers=auth_header(token))
assert r.status_code == 200
body = r.json()
assert body["available"] is True and body["skipped"] == [], body["skipped"]
assert body["count"] >= 20, f"迁移应有 20 篇,实际 {body['count']}"
assert [c["key"] for c in body["categories"]] == ["term", "formula", "practice", "faq"]
for slug in ("term-kpi", "formula-achievement-rate", "practice-threshold-tips", "faq-data-sync"):
d = client.get(f"/api/cma/docs/{slug}", headers=auth_header(token)).json()
assert d["title"] and d["content"].strip()
+62 -285
View File
@@ -9,6 +9,10 @@
<el-input v-model="search" placeholder="搜索术语、计算公式、最佳实践..." prefix-icon="Search" clearable style="width:360px;" /> <el-input v-model="search" placeholder="搜索术语、计算公式、最佳实践..." prefix-icon="Search" clearable style="width:360px;" />
</div> </div>
<div v-if="docsLoading" class="kb-hint">文档加载中...</div>
<div v-else-if="docsError" class="kb-hint kb-hint-error">{{ docsError }}</div>
<div v-else-if="categories.length === 0" class="kb-hint">暂无文档内容</div>
<!-- 搜索结果 --> <!-- 搜索结果 -->
<div v-if="search" class="search-results"> <div v-if="search" class="search-results">
<h4>搜索结果({{ searchResults.length }}条)</h4> <h4>搜索结果({{ searchResults.length }}条)</h4>
@@ -39,7 +43,7 @@
<!-- 详情弹窗 --> <!-- 详情弹窗 -->
<MyDialog v-model="showDetail" :title="detailItem?.title || '文章详情'" :width="700"> <MyDialog v-model="showDetail" :title="detailItem?.title || '文章详情'" :width="700">
<div v-if="detailItem" class="detail-content" v-html="detailItem.content"></div> <div v-if="detailItem" class="detail-content" v-html="detailHtml"></div>
<template #footer> <template #footer>
<el-button @click="showDetail = false">关闭</el-button> <el-button @click="showDetail = false">关闭</el-button>
</template> </template>
@@ -166,6 +170,7 @@ import { ref, computed } from "vue"
import { ElMessage } from "element-plus" import { ElMessage } from "element-plus"
import { ethicsQuizApi } from "../api/index" import { ethicsQuizApi } from "../api/index"
import api from "../api/index" import api from "../api/index"
import { marked } from "marked"
// ── Tab ── // ── Tab ──
const activeTab = ref("knowledge") const activeTab = ref("knowledge")
@@ -175,288 +180,48 @@ const search = ref("")
const showDetail = ref(false) const showDetail = ref(false)
const detailItem = ref<any>(null) const detailItem = ref<any>(null)
const categories = ref([ // ── 知识库内容:md 单一来源 ─────────────────────────────────────────────
{ // 口径(任总 2026-09-24 00:54):指标进 UI、结论出 md、机器走 API。
name: "术语解释", icon: "📖", key: "term", // 内容源 = 后端 /api/cma/docs(运行期实时读 md 目录)⇒ 加/改一篇内容只改一个 md 文件,
items: [ // 前端无需改码、无需 rebuild,刷新页面即生效。
{ const categories = ref<any[]>([])
title: "KPI(关键绩效指标)", const docsLoading = ref(false)
summary: "衡量战略目标达成情况的量化指标", const docsError = ref("")
content: `<h4>KPI(Key Performance Indicator)</h4> const detailHtml = ref("")
<p>关键绩效指标,是衡量企业战略目标达成情况的量化指标。在管理会计OS中,KPI贯穿战略地图、经营看板、预警中心等所有模块。</p>
<h5>特征</h5> async function loadDocs() {
<ul> docsLoading.value = true
<li><b>可量化</b>:每个KPI有明确的计算公式和数值</li> try {
<li><b>有时效</b>:按日/周/月/季/年采集和考核</li> const body: any = (await api.get("/docs", { params: { with_content: true } })) || {}
<li><b>有标准</b>:每个KPI设定了目标值和红黄绿灯阈值</li> const list: any[] = Array.isArray(body.data) ? body.data : (Array.isArray(body) ? body : [])
</ul> const cats: any[] = Array.isArray(body.categories) ? body.categories : []
<h5>编码规则</h5> const grouped: Record<string, any[]> = {}
<p>KPI编码按 BSC维度_类别_序号 命名,如 <code>F_REVENUE_001</code>:</p> for (const d of list) {
<ul> const k = d.category || "其他"
<li>F = 财务维度 (Finance)</li> if (!grouped[k]) grouped[k] = []
<li>C = 客户维度 (Customer)</li> grouped[k].push({
<li>P = 内部流程 (Process)</li> title: d.title,
<li>L = 学习成长 (Learning)</li> summary: d.summary || "",
</ul>`, content: d.content || "",
}, slug: d.slug,
{ kind: "md",
title: "BSC(平衡计分卡)", })
summary: "从财务、客户、内部流程、学习成长四个维度衡量企业绩效", }
content: `<h4>BSC(Balanced Scorecard)</h4> const view: any[] = cats
<p>平衡计分卡,由Kaplan和Norton提出,从四个维度衡量企业绩效:</p> .map((c: any) => ({ name: c.name || c.key, icon: c.icon || "", key: c.key, items: grouped[c.key] || [] }))
<ul> .filter((c: any) => c.items.length > 0)
<li><b>财务维度</b>:最终结果指标,如收入、利润、成本</li> // 兜底:categories.json 未登记的分类也照常展示,不丢内容
<li><b>客户维度</b>:市场反馈指标,如客户数量、满意度</li> for (const k of Object.keys(grouped)) {
<li><b>内部流程</b>:运营效率指标,如交付及时率、库存周转</li> if (!view.find((c: any) => c.key === k)) view.push({ name: k, icon: "", key: k, items: grouped[k] })
<li><b>学习成长</b>:驱动因素指标,如培训完成率、人才梯队</li> }
</ul> categories.value = view
<p>四个维度形成因果链:<b>学习成长 → 内部流程 → 客户 → 财务</b></p>`, docsError.value = ""
}, } catch (e) {
{ docsError.value = "文档加载失败,请稍后重试"
title: "红黄绿灯预警", console.error("加载文档中心失败", e)
summary: "KPI达成情况的颜色标识:绿=正常 黄=预警 红=异常", }
content: `<h4>红黄绿灯预警机制</h4> docsLoading.value = false
<p>系统自动根据KPI实际值与目标值的比率,生成三种预警等级:</p> }
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;width:100%;margin:12px 0;">
<tr style="background:#f5f5f5;"><th>颜色</th><th>等级</th><th>判定标准</th><th>建议动作</th></tr>
<tr><td style="color:#67c23a;font-weight:bold;">🟢 绿色</td><td>正常</td><td>达成率 ≥ 90%</td><td>保持现状</td></tr>
<tr><td style="color:#e6a23c;font-weight:bold;">🟡 黄色</td><td>预警</td><td>70% ≤ 达成率 < 90%</td><td>关注趋势,制定改善计划</td></tr>
<tr><td style="color:#f56c6c;font-weight:bold;">🔴 红色</td><td>异常</td><td>达成率 < 70%</td><td>立即分析原因,启动改善行动</td></tr>
</table>`,
},
{
title: "战略地图",
summary: "用BSC四维框架可视化展示战略目标与因果关系的工具",
content: `<h4>战略地图</h4>
<p>战略地图是平衡计分卡的图形化表达,将战略目标按BSC四个维度排列,并用箭头展示因果驱动关系。</p>
<h5>使用方法</h5>
<ol>
<li>在「战略地图」页面创建一张新地图</li>
<li>在每个维度下添加战略目标</li>
<li>给目标关联KPI,用于量化衡量</li>
<li>从低维度向高维度画因果连线(如 人才培养→流程优化→客户满意→收入增长)</li>
<li>发布后可在「战略回顾会」中查看执行情况</li>
</ol>`,
},
{
title: "改善行动计划(CAP)",
summary: "针对预警问题制定的改进措施,跟踪执行和效果",
content: `<h4>改善行动计划</h4>
<p>当KPI出现黄色或红色预警时,需要制定改善行动计划来解决问题。</p>
<h5>流程</h5>
<ol>
<li>在预警中心识别异常KPI</li>
<li>创建改善行动计划:指定标题、负责人、截止日期、优先级</li>
<li>执行过程中更新进度(0-100%)</li>
<li>完成后填写改善结果</li>
<li>系统会持续跟踪逾期和进度</li>
</ol>`,
},
{
title: "PDCA双循环",
summary: "计划(Plan)→执行(Do)→检查(Check)→改进(Act)的持续改进循环",
content: `<h4>PDCA双循环</h4>
<p>PDCA是计划→执行→检查→改进的持续改进循环,由戴明提出,是CMA管理控制体系的核心框架。系统菜单已按PDCA四阶段分组。</p>
<h5>双循环模型</h5>
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%;margin:8px 0;">
<tr style="background:#f5f5f5;"><th>循环</th><th>频率</th><th>内容</th><th>系统对应</th></tr>
<tr><td><b>战略循环</b></td><td>年度/季度</td><td>战略地图更新·O调整</td><td>战略回顾会</td></tr>
<tr><td><b>执行循环</b></td><td>月度/周度</td><td>KR追踪·KPI考核·改善</td><td>经营看板+差异分析</td></tr>
</table>`,
},
{
title: "SMART原则",
summary: "设定KPI的五项原则:具体的、可衡量的、可实现的、相关的、有时限的",
content: `<h4>SMART原则</h4>
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%;margin:8px 0;">
<tr style="background:#f5f5f5;"><th>字母</th><th>含义</th><th>好KPI</th><th>差KPI</th></tr>
<tr><td><b>S</b></td><td>具体的</td><td>库存周转从45天降到30天</td><td>提升效率</td></tr>
<tr><td><b>M</b></td><td>可衡量的</td><td>渠补率从82.8%降到75%</td><td>改善渠道关系</td></tr>
<tr><td><b>A</b></td><td>可实现的</td><td>管理费从91万降到85万</td><td>降到0(不现实)</td></tr>
<tr><td><b>R</b></td><td>相关的</td><td>仓库KPI→库存周转率</td><td>仓库KPI→客户满意度</td></tr>
<tr><td><b>T</b></td><td>有时限的</td><td>Q3前完成TOP5谈判</td><td>尽快完成</td></tr>
</table>`,
},
{
title: "OKR",
summary: "目标与关键结果:O是定性方向,KR是定量衡量",
content: `<h4>OKR——目标与关键结果</h4>
<p><b>O</b>(目标):我们要去哪里?——定性、有方向感</p>
<p><b>KR</b>(关键结果):怎么知道到了?——定量、可衡量</p>
<h5>OKR vs KPI</h5>
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%;margin:8px 0;">
<tr style="background:#f5f5f5;"><th>维度</th><th>OKR</th><th>KPI</th></tr>
<tr><td>本质</td><td>设定目标+衡量结果</td><td>持续监控健康状况</td></tr>
<tr><td>频率</td><td>季度设定</td><td>持续追踪</td></tr>
<tr><td>场景</td><td>战略落地、创新突破</td><td>日常运营、绩效评估</td></tr>
</table>
<p>CMA系统已内置OKR模板库(<a href="/okr-templates">/okr-templates</a>),每个O可预设3-5个KR。</p>`,
},
],
},
{
name: "计算公式", icon: "📐", key: "formula",
items: [
{
title: "达成率",
summary: "实际值 ÷ 目标值 × 100%",
content: `<h4>达成率</h4>
<p><b>公式</b>:达成率 = 实际值 / 目标值 × 100%</p>
<h5>示例</h5>
<p>销售总额目标2000万,实际1640万:1640/2000 = <b>82%</b></p>
<h5>说明</h5>
<ul>
<li>达成率 ≥ 90% 为绿灯</li>
<li>70% ≤ 达成率 < 90% 为黄灯</li>
<li>达成率 < 70% 为红灯</li>
<li>部分反向指标(如成本控制率)需取相反判断</li>
</ul>`,
},
{
title: "偏差率",
summary: "(实际值 - 目标值)÷ 目标值 × 100%",
content: `<h4>偏差率</h4>
<p><b>公式</b>:偏差率 = (实际值 - 目标值) / 目标值 × 100%</p>
<h5>示例</h5>
<p>预算100万,实际支出120万:(120-100)/100 = <b>+20%</b>(超支)</p>
<h5>说明</h5>
<ul>
<li>正偏差 = 超过目标(可能好也可能坏,取决于指标性质)</li>
<li>负偏差 = 低于目标</li>
</ul>`,
},
{
title: "销售毛利率",
summary: "(销售收入 - 销售成本)÷ 销售收入 × 100%",
content: `<h4>销售毛利率</h4>
<p><b>公式</b>:毛利率 = (销售收入 - 销售成本) / 销售收入 × 100%</p>
<h5>示例</h5>
<p>收入1000万,成本700万:(1000-700)/1000 = <b>30%</b></p>
<h5>说明</h5>
<p>毛利率是衡量企业盈利能力的核心指标,越高代表产品附加值越大。</p>`,
},
{
title: "库存周转率",
summary: "销售成本 ÷ 平均库存金额",
content: `<h4>库存周转率</h4>
<p><b>公式</b>:库存周转率 = 销售成本 / 平均库存金额</p>
<h5>示例</h5>
<p>年销售成本1.2亿,平均库存1500万:1.2亿/1500万 = <b>8次/年</b></p>
<h5>说明</h5>
<p>库存周转率反映企业存货管理效率,越高说明资金占用越少。</p>`,
},
{
title: "达成率 vs 偏差率 对照",
summary: "两个指标的区别与使用场景",
content: `<h4>达成率 vs 偏差率</h4>
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;width:100%;margin:12px 0;">
<tr style="background:#f5f5f5;"><th></th><th>达成率</th><th>偏差率</th></tr>
<tr><td><b>公式</b></td><td>实际/目标 × 100%</td><td>(实际-目标)/目标 × 100%</td></tr>
<tr><td><b>理想值</b></td><td>≥ 100%</td><td>接近0%</td></tr>
<tr><td><b>适用场景</b></td><td>正向指标(收入、满意度)</td><td>预算控制、成本差异</td></tr>
<tr><td><b>红黄绿灯</b></td><td>≥90%绿,≥70%黄,<70%红</td><td>按偏差幅度自定义</td></tr>
</table>`,
},
],
},
{
name: "最佳实践", icon: "⭐", key: "practice",
items: [
{
title: "如何绘制一张好的战略地图",
summary: "从设定目标到画因果链的实操指南",
content: `<h4>绘制战略地图的5个步骤</h4>
<ol>
<li><b>明确战略主题</b>:确定本年度最重要的3-5个战略方向</li>
<li><b>从财务开始</b>:先在财务维度设定最终目标(如"提升销售总额30%")</li>
<li><b>追溯驱动因素</b>:思考"要达成财务目标,客户需要什么?流程需要怎么优化?员工需要什么能力?"</li>
<li><b>关联KPI</b>:每个目标绑定1-2个可量化的KPI</li>
<li><b>画因果连线</b>:从下到上连接各维度目标,形成因果链</li>
</ol>
<h5>常见错误</h5>
<ul>
<li>❌ 每个维度设太多目标(建议不超过4个)</li>
<li>❌ 目标不关联KPI(无法量化跟踪)</li>
<li>❌ 因果链混乱(学习→流程→客户→财务,不要跳层)</li>
</ul>`,
},
{
title: "预算编制的10个原则",
summary: "预算管理模块使用的最佳实践",
content: `<h4>预算编制最佳实践</h4>
<ol>
<li><b>自上而下 vs 自下而上</b>:先定总盘子,再分解到各部门</li>
<li><b>零基预算</b>:每年从零开始编制,不以上年基数为准</li>
<li><b>弹性预算</b>:按业务量设定不同层级的预算标准</li>
<li><b>滚动预算</b>:每季度更新一次全年预算</li>
<li><b>差异分析</b>:每月对比实际与预算,找出偏差原因</li>
</ol>`,
},
{
title: "预警阈值设置的技巧",
summary: "如何设置合理的红黄绿灯阈值",
content: `<h4>阈值设置建议</h4>
<p>合理的红黄绿灯阈值应该:</p>
<ul>
<li><b>不要过严</b>:绿灯设90%而不是95%,否则大部分KPI都是黄色</li>
<li><b>区分指标性质</b>:正向指标(收入)和反向指标(成本)的绿/红判断逻辑不同</li>
<li><b>结合历史数据</b>:参考过去6个月的实际值来设定合理目标</li>
<li><b>动态调整</b>:每季度review一次阈值,随业务发展调整</li>
</ul>`,
},
{
title: "每日工作台的使用方法",
summary: "如何高效使用我的工作台进行日常管理",
content: `<h4>工作台使用三部曲</h4>
<ol>
<li><b>早上看KPI</b>:登录先看一眼我的KPI红黄绿灯,了解整体状态</li>
<li><b>处理待办</b>:查看逾期改善行动和待审批事项</li>
<li><b>跟踪异常</b>:红色预警的KPI立即查看详情,安排改善行动</li>
</ol>`,
},
],
},
{
name: "常见问题", icon: "❓", key: "faq",
items: [
{
title: "数据不同步怎么办?",
summary: "ERP数据同步失败的排查步骤",
content: `<h4>数据同步问题排查</h4>
<ol>
<li>检查ERP API网关是否正常运行(端口8300)</li>
<li>在「数据管理」页面查看最近一次同步时间</li>
<li>手动触发同步:数据管理 → 点击「手动同步」</li>
<li>如仍失败,联系IT管理员查看后端日志</li>
</ol>`,
},
{
title: "为什么看不到某些KPI?",
summary: "角色权限和KPI可见性的说明",
content: `<h4>KPI可见性规则</h4>
<ul>
<li><b>CEO</b>:全量KPI可见</li>
<li><b>财务</b>:财务维度KPI + 通用KPI</li>
<li><b>业务</b>:客户、流程、学习维度KPI</li>
<li><b>IT</b>:全量KPI可见(运维用途)</li>
</ul>
<p>如需调整可见范围,请联系管理员修改权限配置。</p>`,
},
{
title: "如何删除一个战略目标?",
summary: "画布上目标的编辑和删除操作",
content: `<h4>删除战略目标</h4>
<ol>
<li>进入战略地图画布页面</li>
<li>点击要删除的目标卡片(会弹出编辑弹窗)</li>
<li>点击弹窗底部的「删除」按钮</li>
<li>确认删除</li>
<li>点击「保存」使改动生效</li>
</ol>`,
},
],
},
])
const searchResults = computed(() => { const searchResults = computed(() => {
if (!search.value.trim()) return [] if (!search.value.trim()) return []
@@ -533,9 +298,12 @@ async function loadDynamicArticles() {
} }
} }
// 页面加载时拉取动态知识库 // 页面加载:先取 md 单一来源文档,再追加 DB 动态知识库(合并语义与改动前一致)
import { onMounted } from "vue" import { onMounted } from "vue"
onMounted(() => { loadDynamicArticles() }) onMounted(async () => {
await loadDocs()
loadDynamicArticles()
})
function catLabel(key: string) { function catLabel(key: string) {
const map: Record<string, string> = { term: "术语", formula: "公式", practice: "实践", faq: "FAQ" } const map: Record<string, string> = { term: "术语", formula: "公式", practice: "实践", faq: "FAQ" }
@@ -544,6 +312,8 @@ function catLabel(key: string) {
function openItem(item: any) { function openItem(item: any) {
detailItem.value = item detailItem.value = item
// md 单一来源的内容用页面同一渲染器渲染;DB 动态知识库文章保持既有 HTML 直出
detailHtml.value = item?.kind === "md" ? (marked(item.content || "") as string) : (item?.content || "")
showDetail.value = true showDetail.value = true
} }
@@ -676,6 +446,8 @@ function backToIntro() {
.search-title { font-weight: 500; font-size: 14px; flex-shrink: 0; } .search-title { font-weight: 500; font-size: 14px; flex-shrink: 0; }
.search-summary { font-size: 12px; color: #999; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .search-summary { font-size: 12px; color: #999; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty-search { text-align: center; color: #999; padding: 40px; } .empty-search { text-align: center; color: #999; padding: 40px; }
.kb-hint { text-align: center; color: #999; padding: 24px; font-size: 14px; }
.kb-hint-error { color: #f56c6c; }
.detail-content { line-height: 1.8; font-size: 14px; } .detail-content { line-height: 1.8; font-size: 14px; }
.detail-content h4 { margin: 16px 0 8px; } .detail-content h4 { margin: 16px 0 8px; }
.detail-content h5 { margin: 12px 0 6px; color: #666; } .detail-content h5 { margin: 12px 0 6px; color: #666; }
@@ -685,6 +457,11 @@ function backToIntro() {
.detail-content table { width: 100%; border-collapse: collapse; margin: 12px 0; } .detail-content table { width: 100%; border-collapse: collapse; margin: 12px 0; }
.detail-content th, .detail-content td { border: 1px solid #e0e0e0; padding: 8px; text-align: left; } .detail-content th, .detail-content td { border: 1px solid #e0e0e0; padding: 8px; text-align: left; }
.detail-content th { background: #f5f5f5; font-weight: 600; } .detail-content th { background: #f5f5f5; font-weight: 600; }
/* v-html 内容不带 scoped 属性(旧版靠 HTML 内联 border/cellpadding 出边框);
md 单一来源的表格是纯 markdown,改用 :deep 让既有表格样式真正生效(2026-09-24) */
.detail-content :deep(table) { width: 100%; border-collapse: collapse; margin: 12px 0; }
.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; }
/* ── Quiz Intro ── */ /* ── Quiz Intro ── */
.quiz-intro { display: flex; justify-content: center; padding: 40px 0; } .quiz-intro { display: flex; justify-content: center; padding: 40px 0; }