feat(docs-center): CMAKnowledge reads md single source via new /api/cma/docs
ci/woodpecker/push/main Pipeline failed
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:
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user