feat(admin): add knowledge content preview
This commit is contained in:
@@ -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>
|
||||||
@@ -3,6 +3,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
|
|||||||
import { onMounted, reactive, ref } from "vue";
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
|
import KnowledgeContentPreview from "./KnowledgeContentPreview.vue";
|
||||||
import TableRowActions from "./TableRowActions.vue";
|
import TableRowActions from "./TableRowActions.vue";
|
||||||
import AdminPagination from "./AdminPagination.vue";
|
import AdminPagination from "./AdminPagination.vue";
|
||||||
import type {
|
import type {
|
||||||
@@ -10,7 +11,6 @@ import type {
|
|||||||
KnowledgeItem,
|
KnowledgeItem,
|
||||||
KnowledgeSyncJob,
|
KnowledgeSyncJob,
|
||||||
KnowledgeVersion,
|
KnowledgeVersion,
|
||||||
KnowledgeVersionContent,
|
|
||||||
MigrationSummary,
|
MigrationSummary,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ const detailOpen = ref(false);
|
|||||||
const detail = ref<KnowledgeDetail | null>(null);
|
const detail = ref<KnowledgeDetail | null>(null);
|
||||||
const versions = ref<KnowledgeVersion[]>([]);
|
const versions = ref<KnowledgeVersion[]>([]);
|
||||||
const jobs = ref<KnowledgeSyncJob[]>([]);
|
const jobs = ref<KnowledgeSyncJob[]>([]);
|
||||||
const versionContent = ref<KnowledgeVersionContent | null>(null);
|
const previewVersionId = ref<number | null>(null);
|
||||||
const activeTab = ref("overview");
|
const activeTab = ref("overview");
|
||||||
const selectedRows = ref<KnowledgeItem[]>([]);
|
const selectedRows = ref<KnowledgeItem[]>([]);
|
||||||
const editOpen = ref(false);
|
const editOpen = ref(false);
|
||||||
@@ -194,10 +194,12 @@ async function resolveNode() {
|
|||||||
|
|
||||||
async function openDetail(item: KnowledgeItem) {
|
async function openDetail(item: KnowledgeItem) {
|
||||||
detailOpen.value = true;
|
detailOpen.value = true;
|
||||||
|
detail.value = null;
|
||||||
activeTab.value = "overview";
|
activeTab.value = "overview";
|
||||||
versionContent.value = null;
|
previewVersionId.value = null;
|
||||||
try {
|
try {
|
||||||
await refreshDetail(item.id);
|
const refreshedDetail = await refreshDetail(item.id);
|
||||||
|
previewVersionId.value = refreshedDetail.currentVersion?.id ?? versions.value[0]?.id ?? null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
detailOpen.value = false;
|
detailOpen.value = false;
|
||||||
ElMessage.error(errorMessage(error, "知识库详情加载失败"));
|
ElMessage.error(errorMessage(error, "知识库详情加载失败"));
|
||||||
@@ -205,9 +207,13 @@ async function openDetail(item: KnowledgeItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshDetail(id: number) {
|
async function refreshDetail(id: number) {
|
||||||
[detail.value, versions.value, jobs.value] = await Promise.all([
|
const [nextDetail, nextVersions, nextJobs] = await Promise.all([
|
||||||
api.knowledgeDetail(id), api.knowledgeVersions(id), api.knowledgeSyncJobs(id),
|
api.knowledgeDetail(id), api.knowledgeVersions(id), api.knowledgeSyncJobs(id),
|
||||||
]);
|
]);
|
||||||
|
detail.value = nextDetail;
|
||||||
|
versions.value = nextVersions;
|
||||||
|
jobs.value = nextJobs;
|
||||||
|
return nextDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sync(item: KnowledgeItem, mode: "review" | "replace") {
|
async function sync(item: KnowledgeItem, mode: "review" | "replace") {
|
||||||
@@ -358,7 +364,7 @@ async function remove(item: KnowledgeItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function showVersion(version: KnowledgeVersion) {
|
async function showVersion(version: KnowledgeVersion) {
|
||||||
versionContent.value = await api.knowledgeVersionContent(version.id);
|
previewVersionId.value = version.id;
|
||||||
activeTab.value = "content";
|
activeTab.value = "content";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +461,7 @@ async function confirmAction(
|
|||||||
<template v-if="detail">
|
<template v-if="detail">
|
||||||
<div class="knowledge-detail-head">
|
<div class="knowledge-detail-head">
|
||||||
<div><h3>{{ detail.knowledge.name }}</h3><p>{{ typeLabel(detail.knowledge.knowledgeType) }} · {{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核' : '允许自动发布' }}</p></div>
|
<div><h3>{{ detail.knowledge.name }}</h3><p>{{ typeLabel(detail.knowledge.knowledgeType) }} · {{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核' : '允许自动发布' }}</p></div>
|
||||||
<div><el-tag :type="detail.knowledge.status === 1 ? 'success' : 'info'">{{ detail.knowledge.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag :type="statusType(detail.knowledge.sourceStatus)">{{ detail.knowledge.sourceStatus === 'normal' ? '源文章正常' : '源文章异常' }}</el-tag></div>
|
<div class="knowledge-detail-head-actions"><el-button type="primary" plain @click="activeTab = 'content'">预览内容</el-button><el-tag :type="detail.knowledge.status === 1 ? 'success' : 'info'">{{ detail.knowledge.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag :type="statusType(detail.knowledge.sourceStatus)">{{ detail.knowledge.sourceStatus === 'normal' ? '源文章正常' : '源文章异常' }}</el-tag></div>
|
||||||
</div>
|
</div>
|
||||||
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
|
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
|
||||||
<el-tabs v-model="activeTab">
|
<el-tabs v-model="activeTab">
|
||||||
@@ -467,9 +473,8 @@ async function confirmAction(
|
|||||||
<div v-else-if="detail.pendingVersion?.status === 'approved'" class="version-actions"><el-button type="primary" @click="publish(true)">发布并保持开放</el-button><el-button @click="publish(false)">发布后关闭</el-button></div>
|
<div v-else-if="detail.pendingVersion?.status === 'approved'" class="version-actions"><el-button type="primary" @click="publish(true)">发布并保持开放</el-button><el-button @click="publish(false)">发布后关闭</el-button></div>
|
||||||
<el-alert v-else-if="detail.pendingVersion?.status === 'rejected'" title="该版本已被拒绝,请在飞书修改源文章后重新同步。" type="warning" :closable="false" />
|
<el-alert v-else-if="detail.pendingVersion?.status === 'rejected'" title="该版本已被拒绝,请在飞书修改源文章后重新同步。" type="warning" :closable="false" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="内容结构" name="content">
|
<el-tab-pane label="内容预览" name="content" lazy>
|
||||||
<el-empty v-if="!versionContent" description="请从版本记录中选择一个版本查看" />
|
<KnowledgeContentPreview v-model:version-id="previewVersionId" :versions="versions" />
|
||||||
<template v-else><el-collapse><el-collapse-item v-for="section in versionContent.sections" :key="section.id" :title="`${section.sectionKey} ${section.title}`"><pre class="source-content">{{ section.content }}</pre></el-collapse-item></el-collapse><h4>知识卡</h4><el-table :data="versionContent.cards"><el-table-column prop="title" label="标题" /><el-table-column prop="summary" label="摘要" min-width="300" /><el-table-column prop="riskLevel" label="风险" width="100" /><el-table-column prop="reviewStatus" label="审核" width="110" /></el-table></template>
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="版本记录" name="versions"><el-table :data="versions"><el-table-column label="版本" width="90"><template #default="{ row }">V{{ row.versionNo }}</template></el-table-column><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="qualityStatus" label="质检" width="120" /><el-table-column prop="createdAt" label="生成时间" /><el-table-column prop="publishedAt" label="发布时间" /><el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="showVersion(row)">查看内容</el-button><el-button v-if="['published','superseded'].includes(row.status) && row.id !== detail?.knowledge.currentVersionId" link @click="rollback(row)">回滚</el-button></template></el-table-column></el-table></el-tab-pane>
|
<el-tab-pane label="版本记录" name="versions"><el-table :data="versions"><el-table-column label="版本" width="90"><template #default="{ row }">V{{ row.versionNo }}</template></el-table-column><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="qualityStatus" label="质检" width="120" /><el-table-column prop="createdAt" label="生成时间" /><el-table-column prop="publishedAt" label="发布时间" /><el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="showVersion(row)">查看内容</el-button><el-button v-if="['published','superseded'].includes(row.status) && row.id !== detail?.knowledge.currentVersionId" link @click="rollback(row)">回滚</el-button></template></el-table-column></el-table></el-tab-pane>
|
||||||
<el-tab-pane label="同步记录" name="jobs"><el-table :data="jobs"><el-table-column prop="id" label="任务" width="80" /><el-table-column prop="mode" label="模式" width="110" /><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="stage" label="阶段" width="130" /><el-table-column label="进度" width="180"><template #default="{ row }"><el-progress :percentage="row.progress" /></template></el-table-column><el-table-column prop="sourceError" label="失败原因" min-width="260" /></el-table></el-tab-pane>
|
<el-tab-pane label="同步记录" name="jobs"><el-table :data="jobs"><el-table-column prop="id" label="任务" width="80" /><el-table-column prop="mode" label="模式" width="110" /><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="stage" label="阶段" width="130" /><el-table-column label="进度" width="180"><template #default="{ row }"><el-progress :percentage="row.progress" /></template></el-table-column><el-table-column prop="sourceError" label="失败原因" min-width="260" /></el-table></el-tab-pane>
|
||||||
@@ -522,6 +527,12 @@ async function confirmAction(
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.knowledge-detail-head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.batch-edit-form {
|
.batch-edit-form {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user