Files
QuestionProject/ai_knowledge_base_v2/apps/admin-web/src/components/KnowledgeContentPreview.vue

181 lines
8.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>