From ede0524c221a3e82dcc536f663dbd8293bafee57 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Thu, 24 Sep 2026 01:07:29 +0800 Subject: [PATCH] feat(docs-center): CMAKnowledge reads md single source via new /api/cma/docs 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). --- backend/app/api/doc_center.py | 233 +++++++++++++++ backend/app/main.py | 3 +- backend/tests/test_doc_center_20260924.py | 215 ++++++++++++++ frontend/src/views/CMAKnowledge.vue | 347 ++++------------------ 4 files changed, 512 insertions(+), 286 deletions(-) create mode 100644 backend/app/api/doc_center.py create mode 100644 backend/tests/test_doc_center_20260924.py diff --git a/backend/app/api/doc_center.py b/backend/app/api/doc_center.py new file mode 100644 index 00000000..d21e496e --- /dev/null +++ b/backend/app/api/doc_center.py @@ -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/.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 diff --git a/backend/app/main.py b/backend/app/main.py index 64f5a75c..53b24c0d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from dotenv import load_dotenv 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 scripts.erp_sync import run_sync as run_erp_sync 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(ext_input_ledgers.router) # 外部输入台账(M2 准入3 派生,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) async def global_exception_handler(request: Request, exc: Exception): diff --git a/backend/tests/test_doc_center_20260924.py b/backend/tests/test_doc_center_20260924.py new file mode 100644 index 00000000..6898e141 --- /dev/null +++ b/backend/tests/test_doc_center_20260924.py @@ -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() diff --git a/frontend/src/views/CMAKnowledge.vue b/frontend/src/views/CMAKnowledge.vue index dfdce000..5de1f191 100644 --- a/frontend/src/views/CMAKnowledge.vue +++ b/frontend/src/views/CMAKnowledge.vue @@ -9,6 +9,10 @@ +
文档加载中...
+
{{ docsError }}
+
暂无文档内容
+

搜索结果({{ searchResults.length }}条)

@@ -39,7 +43,7 @@ -
+
@@ -166,6 +170,7 @@ import { ref, computed } from "vue" import { ElMessage } from "element-plus" import { ethicsQuizApi } from "../api/index" import api from "../api/index" +import { marked } from "marked" // ── Tab ── const activeTab = ref("knowledge") @@ -175,288 +180,48 @@ const search = ref("") const showDetail = ref(false) const detailItem = ref(null) -const categories = ref([ - { - name: "术语解释", icon: "📖", key: "term", - items: [ - { - title: "KPI(关键绩效指标)", - summary: "衡量战略目标达成情况的量化指标", - content: `

KPI(Key Performance Indicator)

-

关键绩效指标,是衡量企业战略目标达成情况的量化指标。在管理会计OS中,KPI贯穿战略地图、经营看板、预警中心等所有模块。

-
特征
-
    -
  • 可量化:每个KPI有明确的计算公式和数值
  • -
  • 有时效:按日/周/月/季/年采集和考核
  • -
  • 有标准:每个KPI设定了目标值和红黄绿灯阈值
  • -
-
编码规则
-

KPI编码按 BSC维度_类别_序号 命名,如 F_REVENUE_001:

-
    -
  • F = 财务维度 (Finance)
  • -
  • C = 客户维度 (Customer)
  • -
  • P = 内部流程 (Process)
  • -
  • L = 学习成长 (Learning)
  • -
`, - }, - { - title: "BSC(平衡计分卡)", - summary: "从财务、客户、内部流程、学习成长四个维度衡量企业绩效", - content: `

BSC(Balanced Scorecard)

-

平衡计分卡,由Kaplan和Norton提出,从四个维度衡量企业绩效:

-
    -
  • 财务维度:最终结果指标,如收入、利润、成本
  • -
  • 客户维度:市场反馈指标,如客户数量、满意度
  • -
  • 内部流程:运营效率指标,如交付及时率、库存周转
  • -
  • 学习成长:驱动因素指标,如培训完成率、人才梯队
  • -
-

四个维度形成因果链:学习成长 → 内部流程 → 客户 → 财务

`, - }, - { - title: "红黄绿灯预警", - summary: "KPI达成情况的颜色标识:绿=正常 黄=预警 红=异常", - content: `

红黄绿灯预警机制

-

系统自动根据KPI实际值与目标值的比率,生成三种预警等级:

- - - - - -
颜色等级判定标准建议动作
🟢 绿色正常达成率 ≥ 90%保持现状
🟡 黄色预警70% ≤ 达成率 < 90%关注趋势,制定改善计划
🔴 红色异常达成率 < 70%立即分析原因,启动改善行动
`, - }, - { - title: "战略地图", - summary: "用BSC四维框架可视化展示战略目标与因果关系的工具", - content: `

战略地图

-

战略地图是平衡计分卡的图形化表达,将战略目标按BSC四个维度排列,并用箭头展示因果驱动关系。

-
使用方法
-
    -
  1. 在「战略地图」页面创建一张新地图
  2. -
  3. 在每个维度下添加战略目标
  4. -
  5. 给目标关联KPI,用于量化衡量
  6. -
  7. 从低维度向高维度画因果连线(如 人才培养→流程优化→客户满意→收入增长)
  8. -
  9. 发布后可在「战略回顾会」中查看执行情况
  10. -
`, - }, - { - title: "改善行动计划(CAP)", - summary: "针对预警问题制定的改进措施,跟踪执行和效果", - content: `

改善行动计划

-

当KPI出现黄色或红色预警时,需要制定改善行动计划来解决问题。

-
流程
-
    -
  1. 在预警中心识别异常KPI
  2. -
  3. 创建改善行动计划:指定标题、负责人、截止日期、优先级
  4. -
  5. 执行过程中更新进度(0-100%)
  6. -
  7. 完成后填写改善结果
  8. -
  9. 系统会持续跟踪逾期和进度
  10. -
`, - }, - { - title: "PDCA双循环", - summary: "计划(Plan)→执行(Do)→检查(Check)→改进(Act)的持续改进循环", - content: `

PDCA双循环

-

PDCA是计划→执行→检查→改进的持续改进循环,由戴明提出,是CMA管理控制体系的核心框架。系统菜单已按PDCA四阶段分组。

-
双循环模型
- - - - -
循环频率内容系统对应
战略循环年度/季度战略地图更新·O调整战略回顾会
执行循环月度/周度KR追踪·KPI考核·改善经营看板+差异分析
`, - }, - { - title: "SMART原则", - summary: "设定KPI的五项原则:具体的、可衡量的、可实现的、相关的、有时限的", - content: `

SMART原则

- - - - - - - -
字母含义好KPI差KPI
S具体的库存周转从45天降到30天提升效率
M可衡量的渠补率从82.8%降到75%改善渠道关系
A可实现的管理费从91万降到85万降到0(不现实)
R相关的仓库KPI→库存周转率仓库KPI→客户满意度
T有时限的Q3前完成TOP5谈判尽快完成
`, - }, - { - title: "OKR", - summary: "目标与关键结果:O是定性方向,KR是定量衡量", - content: `

OKR——目标与关键结果

-

O(目标):我们要去哪里?——定性、有方向感

-

KR(关键结果):怎么知道到了?——定量、可衡量

-
OKR vs KPI
- - - - - -
维度OKRKPI
本质设定目标+衡量结果持续监控健康状况
频率季度设定持续追踪
场景战略落地、创新突破日常运营、绩效评估
-

CMA系统已内置OKR模板库(/okr-templates),每个O可预设3-5个KR。

`, - }, - ], - }, - { - name: "计算公式", icon: "📐", key: "formula", - items: [ - { - title: "达成率", - summary: "实际值 ÷ 目标值 × 100%", - content: `

达成率

-

公式:达成率 = 实际值 / 目标值 × 100%

-
示例
-

销售总额目标2000万,实际1640万:1640/2000 = 82%

-
说明
-
    -
  • 达成率 ≥ 90% 为绿灯
  • -
  • 70% ≤ 达成率 < 90% 为黄灯
  • -
  • 达成率 < 70% 为红灯
  • -
  • 部分反向指标(如成本控制率)需取相反判断
  • -
`, - }, - { - title: "偏差率", - summary: "(实际值 - 目标值)÷ 目标值 × 100%", - content: `

偏差率

-

公式:偏差率 = (实际值 - 目标值) / 目标值 × 100%

-
示例
-

预算100万,实际支出120万:(120-100)/100 = +20%(超支)

-
说明
-
    -
  • 正偏差 = 超过目标(可能好也可能坏,取决于指标性质)
  • -
  • 负偏差 = 低于目标
  • -
`, - }, - { - title: "销售毛利率", - summary: "(销售收入 - 销售成本)÷ 销售收入 × 100%", - content: `

销售毛利率

-

公式:毛利率 = (销售收入 - 销售成本) / 销售收入 × 100%

-
示例
-

收入1000万,成本700万:(1000-700)/1000 = 30%

-
说明
-

毛利率是衡量企业盈利能力的核心指标,越高代表产品附加值越大。

`, - }, - { - title: "库存周转率", - summary: "销售成本 ÷ 平均库存金额", - content: `

库存周转率

-

公式:库存周转率 = 销售成本 / 平均库存金额

-
示例
-

年销售成本1.2亿,平均库存1500万:1.2亿/1500万 = 8次/年

-
说明
-

库存周转率反映企业存货管理效率,越高说明资金占用越少。

`, - }, - { - title: "达成率 vs 偏差率 对照", - summary: "两个指标的区别与使用场景", - content: `

达成率 vs 偏差率

- - - - - - -
达成率偏差率
公式实际/目标 × 100%(实际-目标)/目标 × 100%
理想值≥ 100%接近0%
适用场景正向指标(收入、满意度)预算控制、成本差异
红黄绿灯≥90%绿,≥70%黄,<70%红按偏差幅度自定义
`, - }, - ], - }, - { - name: "最佳实践", icon: "⭐", key: "practice", - items: [ - { - title: "如何绘制一张好的战略地图", - summary: "从设定目标到画因果链的实操指南", - content: `

绘制战略地图的5个步骤

-
    -
  1. 明确战略主题:确定本年度最重要的3-5个战略方向
  2. -
  3. 从财务开始:先在财务维度设定最终目标(如"提升销售总额30%")
  4. -
  5. 追溯驱动因素:思考"要达成财务目标,客户需要什么?流程需要怎么优化?员工需要什么能力?"
  6. -
  7. 关联KPI:每个目标绑定1-2个可量化的KPI
  8. -
  9. 画因果连线:从下到上连接各维度目标,形成因果链
  10. -
-
常见错误
-
    -
  • ❌ 每个维度设太多目标(建议不超过4个)
  • -
  • ❌ 目标不关联KPI(无法量化跟踪)
  • -
  • ❌ 因果链混乱(学习→流程→客户→财务,不要跳层)
  • -
`, - }, - { - title: "预算编制的10个原则", - summary: "预算管理模块使用的最佳实践", - content: `

预算编制最佳实践

-
    -
  1. 自上而下 vs 自下而上:先定总盘子,再分解到各部门
  2. -
  3. 零基预算:每年从零开始编制,不以上年基数为准
  4. -
  5. 弹性预算:按业务量设定不同层级的预算标准
  6. -
  7. 滚动预算:每季度更新一次全年预算
  8. -
  9. 差异分析:每月对比实际与预算,找出偏差原因
  10. -
`, - }, - { - title: "预警阈值设置的技巧", - summary: "如何设置合理的红黄绿灯阈值", - content: `

阈值设置建议

-

合理的红黄绿灯阈值应该:

-
    -
  • 不要过严:绿灯设90%而不是95%,否则大部分KPI都是黄色
  • -
  • 区分指标性质:正向指标(收入)和反向指标(成本)的绿/红判断逻辑不同
  • -
  • 结合历史数据:参考过去6个月的实际值来设定合理目标
  • -
  • 动态调整:每季度review一次阈值,随业务发展调整
  • -
`, - }, - { - title: "每日工作台的使用方法", - summary: "如何高效使用我的工作台进行日常管理", - content: `

工作台使用三部曲

-
    -
  1. 早上看KPI:登录先看一眼我的KPI红黄绿灯,了解整体状态
  2. -
  3. 处理待办:查看逾期改善行动和待审批事项
  4. -
  5. 跟踪异常:红色预警的KPI立即查看详情,安排改善行动
  6. -
`, - }, - ], - }, - { - name: "常见问题", icon: "❓", key: "faq", - items: [ - { - title: "数据不同步怎么办?", - summary: "ERP数据同步失败的排查步骤", - content: `

数据同步问题排查

-
    -
  1. 检查ERP API网关是否正常运行(端口8300)
  2. -
  3. 在「数据管理」页面查看最近一次同步时间
  4. -
  5. 手动触发同步:数据管理 → 点击「手动同步」
  6. -
  7. 如仍失败,联系IT管理员查看后端日志
  8. -
`, - }, - { - title: "为什么看不到某些KPI?", - summary: "角色权限和KPI可见性的说明", - content: `

KPI可见性规则

-
    -
  • CEO:全量KPI可见
  • -
  • 财务:财务维度KPI + 通用KPI
  • -
  • 业务:客户、流程、学习维度KPI
  • -
  • IT:全量KPI可见(运维用途)
  • -
-

如需调整可见范围,请联系管理员修改权限配置。

`, - }, - { - title: "如何删除一个战略目标?", - summary: "画布上目标的编辑和删除操作", - content: `

删除战略目标

-
    -
  1. 进入战略地图画布页面
  2. -
  3. 点击要删除的目标卡片(会弹出编辑弹窗)
  4. -
  5. 点击弹窗底部的「删除」按钮
  6. -
  7. 确认删除
  8. -
  9. 点击「保存」使改动生效
  10. -
`, - }, - ], - }, -]) +// ── 知识库内容:md 单一来源 ───────────────────────────────────────────── +// 口径(任总 2026-09-24 00:54):指标进 UI、结论出 md、机器走 API。 +// 内容源 = 后端 /api/cma/docs(运行期实时读 md 目录)⇒ 加/改一篇内容只改一个 md 文件, +// 前端无需改码、无需 rebuild,刷新页面即生效。 +const categories = ref([]) +const docsLoading = ref(false) +const docsError = ref("") +const detailHtml = ref("") + +async function loadDocs() { + docsLoading.value = true + try { + const body: any = (await api.get("/docs", { params: { with_content: true } })) || {} + const list: any[] = Array.isArray(body.data) ? body.data : (Array.isArray(body) ? body : []) + const cats: any[] = Array.isArray(body.categories) ? body.categories : [] + const grouped: Record = {} + for (const d of list) { + const k = d.category || "其他" + if (!grouped[k]) grouped[k] = [] + grouped[k].push({ + title: d.title, + summary: d.summary || "", + content: d.content || "", + slug: d.slug, + kind: "md", + }) + } + const view: any[] = cats + .map((c: any) => ({ name: c.name || c.key, icon: c.icon || "", key: c.key, items: grouped[c.key] || [] })) + .filter((c: any) => c.items.length > 0) + // 兜底:categories.json 未登记的分类也照常展示,不丢内容 + for (const k of Object.keys(grouped)) { + if (!view.find((c: any) => c.key === k)) view.push({ name: k, icon: "", key: k, items: grouped[k] }) + } + categories.value = view + docsError.value = "" + } catch (e) { + docsError.value = "文档加载失败,请稍后重试" + console.error("加载文档中心失败", e) + } + docsLoading.value = false +} const searchResults = computed(() => { if (!search.value.trim()) return [] @@ -533,9 +298,12 @@ async function loadDynamicArticles() { } } -// 页面加载时拉取动态知识库 +// 页面加载:先取 md 单一来源文档,再追加 DB 动态知识库(合并语义与改动前一致) import { onMounted } from "vue" -onMounted(() => { loadDynamicArticles() }) +onMounted(async () => { + await loadDocs() + loadDynamicArticles() +}) function catLabel(key: string) { const map: Record = { term: "术语", formula: "公式", practice: "实践", faq: "FAQ" } @@ -544,6 +312,8 @@ function catLabel(key: string) { function openItem(item: any) { detailItem.value = item + // md 单一来源的内容用页面同一渲染器渲染;DB 动态知识库文章保持既有 HTML 直出 + detailHtml.value = item?.kind === "md" ? (marked(item.content || "") as string) : (item?.content || "") showDetail.value = true } @@ -676,6 +446,8 @@ function backToIntro() { .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; } .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 h4 { margin: 16px 0 8px; } .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 th, .detail-content td { border: 1px solid #e0e0e0; padding: 8px; text-align: left; } .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 { display: flex; justify-content: center; padding: 40px 0; }