feat(admin): add knowledge content preview

This commit is contained in:
2026-07-16 10:39:46 +08:00
parent 56c9f2f359
commit c3f24d0ccd
2 changed files with 201 additions and 10 deletions

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { api } from "../services/api";
import type { KnowledgeVersion, KnowledgeVersionContent } from "../types/api";
const props = defineProps<{
versions: KnowledgeVersion[];
versionId: number | null;
}>();
const emit = defineEmits<{ "update:versionId": [versionId: number] }>();
const loading = ref(false);
const error = ref("");
const content = ref<KnowledgeVersionContent | null>(null);
const activeSectionId = ref<number | null>(null);
let requestSequence = 0;
const activeSection = computed(() => (
content.value?.sections.find((section) => section.id === activeSectionId.value)
?? content.value?.sections[0]
?? null
));
const characterCount = computed(() => (
content.value?.sections.reduce((total, section) => total + section.content.length, 0) ?? 0
));
watch(() => props.versionId, loadVersion, { immediate: true });
async function loadVersion(versionId: number | null) {
const sequence = ++requestSequence;
content.value = null;
activeSectionId.value = null;
error.value = "";
if (!versionId) return;
loading.value = true;
try {
const result = await api.knowledgeVersionContent(versionId);
if (sequence !== requestSequence) return;
content.value = result;
activeSectionId.value = result.sections[0]?.id ?? null;
} catch (loadError) {
if (sequence !== requestSequence) return;
error.value = loadError instanceof Error ? loadError.message : "知识库内容加载失败";
} finally {
if (sequence === requestSequence) loading.value = false;
}
}
function selectVersion(value: number) {
emit("update:versionId", value);
}
function versionStatus(version: KnowledgeVersion) {
const labels: Record<string, string> = {
published: "正式版本",
pending_review: "待审核",
approved: "待发布",
rejected: "已拒绝",
superseded: "历史版本",
};
return labels[version.status] ?? version.status;
}
</script>
<template>
<section class="content-preview" v-loading="loading">
<header class="content-preview-toolbar">
<div>
<h4>知识库内容预览</h4>
<p>按同步后的章节结构查看实际进入检索链路的内容</p>
</div>
<el-select
:model-value="versionId"
class="content-version-select"
placeholder="选择预览版本"
:disabled="versions.length === 0"
@update:model-value="selectVersion"
>
<el-option
v-for="version in versions"
:key="version.id"
:label="`V${version.versionNo} · ${versionStatus(version)}`"
:value="version.id"
/>
</el-select>
</header>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon>
<template #default><el-button link type="primary" @click="loadVersion(versionId)">重新加载</el-button></template>
</el-alert>
<el-empty v-else-if="!loading && !versionId" description="该知识库暂无可预览版本" />
<el-empty v-else-if="!loading && content && content.sections.length === 0" description="当前版本没有可预览的章节内容" />
<template v-else-if="content && activeSection">
<div class="content-preview-summary">
<span>V{{ content.version.versionNo }}</span>
<span>{{ content.sections.length }} 个章节</span>
<span>{{ content.cards.length }} 张知识卡</span>
<span> {{ characterCount.toLocaleString() }} </span>
</div>
<div class="content-preview-layout">
<aside class="content-outline" aria-label="章节目录">
<strong>章节目录</strong>
<button
v-for="section in content.sections"
:key="section.id"
type="button"
:class="{ active: section.id === activeSection.id }"
:title="section.title"
@click="activeSectionId = section.id"
>
<span>{{ section.sectionKey }}</span>
<em>{{ section.title }}</em>
</button>
</aside>
<article class="content-article">
<div class="content-article-head">
<div><small>{{ activeSection.sectionKey }}</small><h3>{{ activeSection.title }}</h3></div>
<span>源内容位置 {{ activeSection.sourceStart }}{{ activeSection.sourceEnd }}</span>
</div>
<pre>{{ activeSection.content }}</pre>
</article>
</div>
<el-collapse class="content-card-panel">
<el-collapse-item :title="`查看知识卡(${content.cards.length}`" name="cards">
<el-empty v-if="content.cards.length === 0" description="当前版本尚未生成知识卡" :image-size="72" />
<el-table v-else :data="content.cards" max-height="360">
<el-table-column prop="title" label="标题" min-width="180" />
<el-table-column prop="summary" label="摘要" min-width="320" show-overflow-tooltip />
<el-table-column prop="riskLevel" label="风险" width="100" />
<el-table-column prop="reviewStatus" label="审核" width="110" />
</el-table>
</el-collapse-item>
</el-collapse>
</template>
</section>
</template>
<style scoped>
.content-preview { min-height: 360px; }
.content-preview-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
.content-preview-toolbar h4 { margin: 0; font-size: 17px; color: #203b32; }
.content-preview-toolbar p { margin: 6px 0 0; color: #71817b; font-size: 13px; }
.content-version-select { width: 220px; flex: 0 0 220px; }
.content-preview-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.content-preview-summary span { padding: 5px 10px; border-radius: 999px; background: #f0f6f3; color: #526860; font-size: 12px; }
.content-preview-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 480px; max-height: 62vh; overflow: hidden; border: 1px solid #dfe8e5; border-radius: 12px; background: #fff; }
.content-outline { overflow-y: auto; padding: 14px 10px; border-right: 1px solid #e5ece9; background: #f7faf9; }
.content-outline > strong { display: block; padding: 0 10px 10px; color: #455c53; font-size: 13px; }
.content-outline button { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; width: 100%; gap: 8px; padding: 10px; border: 0; border-radius: 8px; background: transparent; color: #53645e; text-align: left; cursor: pointer; }
.content-outline button:hover { background: #eaf3ef; }
.content-outline button.active { background: #dcefe7; color: #176746; }
.content-outline button span { font-size: 11px; color: #7c8d87; }
.content-outline button em { overflow: hidden; font-style: normal; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.content-article { min-width: 0; overflow-y: auto; padding: 24px 28px; }
.content-article-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #edf1ef; }
.content-article-head small { color: #26815f; }
.content-article-head h3 { margin: 5px 0 0; color: #203b32; font-size: 20px; }
.content-article-head > span { color: #88958f; font-size: 12px; white-space: nowrap; }
.content-article pre { margin: 20px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; color: #354840; font: inherit; font-size: 14px; line-height: 1.85; }
.content-card-panel { margin-top: 16px; }
@media (max-width: 900px) {
.content-preview-toolbar { align-items: stretch; flex-direction: column; }
.content-version-select { width: 100%; flex-basis: auto; }
.content-preview-layout { grid-template-columns: 1fr; max-height: none; }
.content-outline { display: flex; gap: 8px; overflow-x: auto; border-right: 0; border-bottom: 1px solid #e5ece9; }
.content-outline > strong { display: none; }
.content-outline button { min-width: 180px; width: 180px; }
.content-article { max-height: 58vh; padding: 20px; }
.content-article-head { flex-direction: column; gap: 8px; }
}
</style>