181 lines
8.2 KiB
Vue
181 lines
8.2 KiB
Vue
<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); height: clamp(420px, 62vh, 680px); min-height: 0; overflow: hidden; border: 1px solid #dfe8e5; border-radius: 12px; background: #fff; }
|
||
.content-outline { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; 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; min-height: 0; height: 100%; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; 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; height: auto; min-height: 0; overflow: visible; }
|
||
.content-outline { display: flex; height: auto; gap: 8px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid #e5ece9; }
|
||
.content-outline > strong { display: none; }
|
||
.content-outline button { min-width: 180px; width: 180px; }
|
||
.content-article { height: auto; max-height: 58vh; padding: 20px; }
|
||
.content-article-head { flex-direction: column; gap: 8px; }
|
||
}
|
||
</style>
|