feat(docs-center): report reading section (type=report) on /api/cma/docs

Increment #2 over PoC ede0524 (task yanxue-20260924-cma-report-source).

- backend derives reports from existing drop points (wisdom/_reports + every profile pending-tasks/_reports), read live per request, no cache, no rebuild

- gate = front-matter plus non-empty conclusion; missing either means silently out of the list (receipts/audits never enter); title/date/domain/sources missing are marked incomplete instead of rejected

- entity + role partitioning (ceo/it cross-entity; others own entity plus group/generic); kpi_refs resolve to a one-hop metric link

- new GET /api/cma/docs/report/{rid} detail endpoint; knowledge branch unchanged (type defaults to knowledge)

- frontend: CMAKnowledge adds a report reading pane (filters + detail dialog), reuses the existing markdown renderer, no new deps

(cherry picked from commit 7c6e0857e68133f5ba2a539fd3569aeb82547911)
This commit is contained in:
Hermes CI Fix
2026-10-18 16:31:52 +08:00
parent 57f9dbae15
commit 2cff58ca6a
3 changed files with 808 additions and 3 deletions
+173 -1
View File
@@ -50,7 +50,81 @@
</MyDialog>
</el-tab-pane>
<!-- Tab 2: 职业道德自测 -->
<!-- Tab 2: 报告解读(读结论)—— 与知识库并列的分区,不是第二个报表中心(看数在 管理报表/BI报表) -->
<el-tab-pane label="📄 报告解读" name="reports">
<div class="kb-header">
<h3>报告解读</h3>
<div class="rp-filters">
<el-date-picker v-model="reportRange" type="daterange" value-format="YYYY-MM-DD"
start-placeholder="开始日" end-placeholder="结束日" style="width:250px;" @change="loadReports" />
<el-select v-model="reportDomain" placeholder="域" clearable style="width:110px" @change="loadReports">
<el-option v-for="f in reportFacets.domain" :key="f.key" :label="f.name" :value="f.key" />
</el-select>
<el-select v-model="reportEntity" placeholder="账套" clearable style="width:110px" @change="loadReports">
<el-option v-for="f in reportFacets.entity" :key="f.key" :label="f.key" :value="f.key" />
</el-select>
<el-select v-model="reportKind" placeholder="类型" clearable style="width:110px" @change="loadReports">
<el-option v-for="f in reportFacets.kind" :key="f.key" :label="f.name" :value="f.key" />
</el-select>
<el-button @click="loadReports">刷新</el-button>
</div>
</div>
<div v-if="reportLoading" class="kb-hint">报告加载中...</div>
<div v-else-if="reportError" class="kb-hint kb-hint-error">{{ reportError }}</div>
<div v-else-if="reports.length === 0" class="kb-hint">
暂无报告解读内容(只收录带 front-matter 且含 conclusion 的报告件;回执/审计/施工件按规范不进此页)
</div>
<div v-else class="rp-list">
<div v-for="r in reports" :key="r.id" class="rp-item" @click="openReport(r)">
<div class="rp-item-head">
<span class="rp-date">{{ r.date }}</span>
<span class="rp-title">{{ r.title }}</span>
<span class="rp-tag">{{ domainLabel(r.domain) }}</span>
<span v-if="r.entity" class="rp-tag rp-tag-entity">{{ r.entity }}</span>
<span class="rp-tag rp-tag-kind">{{ kindLabel(r.kind) }}</span>
</div>
<div class="rp-conclusion">{{ r.conclusion }}</div>
<div class="rp-meta">
<span v-if="r.incomplete && r.incomplete.length" class="rp-warn">缺 {{ r.incomplete.join('/') }}</span>
<span>{{ r.source }}:{{ r.file }}</span>
</div>
</div>
</div>
<!-- 报告详情弹窗:结论 + md 正文 + kpi_refs 一跳回链(读结论 → 看数) -->
<MyDialog v-model="showReport" :title="reportDetail?.title || '报告详情'" :width="760">
<div v-if="reportDetail" class="detail-content">
<div class="rp-detail-meta">
<span class="rp-date">{{ reportDetail.date }}</span>
<span class="rp-tag">{{ domainLabel(reportDetail.domain) }}</span>
<span v-if="reportDetail.entity" class="rp-tag rp-tag-entity">{{ reportDetail.entity }}</span>
<span class="rp-tag rp-tag-kind">{{ kindLabel(reportDetail.kind) }}</span>
</div>
<div class="rp-detail-conclusion"><strong>结论:</strong>{{ reportDetail.conclusion }}</div>
<div v-if="reportDetail.impact" class="rp-detail-row"><strong>影响:</strong>{{ reportDetail.impact }}</div>
<div v-if="reportDetail.sources && reportDetail.sources.length" class="rp-detail-row">
<strong>出处:</strong>{{ reportDetail.sources.join(' · ') }}
</div>
<div v-if="reportDetail.decisions && reportDetail.decisions.length" class="rp-detail-row">
<strong>待拍板:</strong>{{ reportDetail.decisions.join(' · ') }}
</div>
<div v-if="reportDetail.kpi_links && reportDetail.kpi_links.length" class="rp-detail-row">
<strong>关联指标:</strong>
<el-button v-for="k in reportDetail.kpi_links" :key="k.raw" link type="primary"
class="rp-kpi-link" @click="gotoKpi(k)">{{ k.raw }} ↗</el-button>
</div>
<hr class="rp-hr" />
<div v-html="reportHtml"></div>
</div>
<template #footer>
<el-button @click="showReport = false">关闭</el-button>
</template>
</MyDialog>
</el-tab-pane>
<!-- Tab 3: 职业道德自测 -->
<el-tab-pane label="⚖️ 职业道德自测" name="ethics-quiz">
<div v-if="!quizStarted" class="quiz-intro">
<div class="quiz-intro-card">
@@ -298,6 +372,84 @@ async function loadDynamicArticles() {
}
}
// ── 报告解读(type=report):读结论 → 看数(kpi_refs 一跳回链指标页) ──────────────
// 口径(规范 report-frontmatter-spec-20260924):只收录带 front-matter 且含 conclusion 的报告件;
// 回执/审计/施工/工作件天然不带 front-matter ⇒ 不进本页(不搬家、不新建内容目录)。
// 看数在 管理报表/BI报表 —— 本分区只做「读结论」,不做第二个报表中心。
import { watch } from "vue"
import { useRouter } from "vue-router"
const router = useRouter()
const reports = ref<any[]>([])
const reportLoading = ref(false)
const reportError = ref("")
const reportRange = ref<any>(null)
const reportDomain = ref("")
const reportEntity = ref("")
const reportKind = ref("")
const reportFacets = ref<any>({ domain: [], entity: [], kind: [], date: { min: "", max: "" } })
const showReport = ref(false)
const reportDetail = ref<any>(null)
const reportHtml = ref("")
const REPORT_DOMAIN_LABELS: Record<string, string> = {
finance: "财务", customer: "客户", process: "流程", learning: "学习成长",
tech: "技术", ops: "运维", market: "市场", gov: "政务",
}
const REPORT_KIND_LABELS: Record<string, string> = {
report: "报告", analysis: "分析", brief: "摘要", warning: "预警",
}
function domainLabel(k: string) { return REPORT_DOMAIN_LABELS[k] || k || "未分类" }
function kindLabel(k: string) { return REPORT_KIND_LABELS[k] || k || "报告" }
async function loadReports() {
reportLoading.value = true
try {
const params: any = { type: "report" }
if (reportRange.value && reportRange.value[0]) {
params.date_from = reportRange.value[0]
params.date_to = reportRange.value[1]
}
if (reportDomain.value) params.domain = reportDomain.value
if (reportEntity.value) params.entity = reportEntity.value
if (reportKind.value) params.kind = reportKind.value
const body: any = (await api.get("/docs", { params })) || {}
reports.value = Array.isArray(body.data) ? body.data : []
reportFacets.value = body.facets || { domain: [], entity: [], kind: [], date: { min: "", max: "" } }
reportError.value = ""
} catch (e) {
reportError.value = "报告加载失败,请稍后重试"
console.error("加载报告解读失败", e)
}
reportLoading.value = false
}
async function openReport(r: any) {
reportDetail.value = r
reportHtml.value = ""
showReport.value = true
try {
const d: any = await api.get(`/docs/report/${r.id}`)
if (d) {
reportDetail.value = d
reportHtml.value = marked(d.content || "") as string
}
} catch (e) {
ElMessage.error("报告正文加载失败")
}
}
// 一跳回链:`kpi:<id>` / 数字 → 指标详情页;其余 → 指标字典页(关键词)
function gotoKpi(k: any) {
const url = (k && k.url) || "/kpis"
router.push(url)
}
// 切到「报告解读」时才拉取(不拖慢知识库首屏)
watch(activeTab, (v) => {
if (v === "reports" && reports.value.length === 0) loadReports()
})
// 页面加载:先取 md 单一来源文档,再追加 DB 动态知识库(合并语义与改动前一致)
import { onMounted } from "vue"
onMounted(async () => {
@@ -474,6 +626,26 @@ function backToIntro() {
.detail-content :deep(th), .detail-content :deep(td) { border: 1px solid #e0e0e0; padding: 8px; text-align: left; }
.detail-content :deep(th) { background: #f5f5f5; font-weight: 600; }
/* ── 报告解读(type=report) ── */
.rp-filters { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.rp-list { display: flex; flex-direction: column; gap: 10px; }
.rp-item { background: #fff; border-radius: 10px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; transition: background 0.2s; }
.rp-item:hover { background: #f5f7fa; }
.rp-item-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
.rp-date { font-size: 12px; color: #909399; }
.rp-title { font-size: 15px; font-weight: 600; color: #303133; }
.rp-tag { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: #ecf5ff; color: #409eff; }
.rp-tag-entity { background: #f0f9eb; color: #67c23a; }
.rp-tag-kind { background: #fdf6ec; color: #e6a23c; }
.rp-conclusion { font-size: 13px; color: #303133; line-height: 1.65; }
.rp-meta { display: flex; gap: 10px; margin-top: 6px; font-size: 11px; color: #c0c4cc; flex-wrap: wrap; }
.rp-warn { color: #e6a23c; }
.rp-detail-meta { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
.rp-detail-conclusion { background: #f5f7fa; border-left: 3px solid #409eff; padding: 10px 12px; border-radius: 4px; font-size: 14px; line-height: 1.7; margin-bottom: 8px; }
.rp-detail-row { font-size: 13px; color: #606266; margin-bottom: 6px; line-height: 1.7; }
.rp-kpi-link { margin-right: 12px; padding: 0; }
.rp-hr { border: none; border-top: 1px solid #ebeef5; margin: 14px 0; }
/* ── Quiz Intro ── */
.quiz-intro { display: flex; justify-content: center; padding: 40px 0; }
.quiz-intro-card {