feat: add knowledge content search

This commit is contained in:
2026-07-30 11:02:04 +08:00
parent c2655361b5
commit d1f573108e
7 changed files with 629 additions and 8 deletions

View File

@@ -6,6 +6,8 @@ import type { KnowledgeVersionContent } from "../types/api";
const props = defineProps<{
versionId: number | null;
targetSectionId?: number | null;
highlightKeyword?: string;
}>();
const loading = ref(false);
@@ -25,6 +27,7 @@ const characterCount = computed(() => (
));
watch(() => props.versionId, loadVersion, { immediate: true });
watch(() => props.targetSectionId, selectTargetSection);
async function loadVersion(versionId: number | null) {
const sequence = ++requestSequence;
@@ -38,7 +41,9 @@ async function loadVersion(versionId: number | null) {
const result = await api.knowledgeVersionContent(versionId);
if (sequence !== requestSequence) return;
content.value = result;
activeSectionId.value = result.sections[0]?.id ?? null;
activeSectionId.value = props.targetSectionId && result.sections.some((section) => section.id === props.targetSectionId)
? props.targetSectionId
: result.sections[0]?.id ?? null;
} catch (loadError) {
if (sequence !== requestSequence) return;
error.value = loadError instanceof Error ? loadError.message : "知识库内容加载失败";
@@ -47,6 +52,32 @@ async function loadVersion(versionId: number | null) {
}
}
function selectTargetSection(sectionId?: number | null) {
if (!sectionId || !content.value?.sections.some((section) => section.id === sectionId)) return;
activeSectionId.value = sectionId;
}
function highlightedParts(text: string) {
const keyword = (props.highlightKeyword || "").trim();
if (!keyword) return [{ text, match: false }];
const parts: Array<{ text: string; match: boolean }> = [];
const source = text || "";
const lowerSource = source.toLowerCase();
const lowerKeyword = keyword.toLowerCase();
let cursor = 0;
while (cursor < source.length) {
const index = lowerSource.indexOf(lowerKeyword, cursor);
if (index < 0) {
parts.push({ text: source.slice(cursor), match: false });
break;
}
if (index > cursor) parts.push({ text: source.slice(cursor, index), match: false });
parts.push({ text: source.slice(index, index + keyword.length), match: true });
cursor = index + keyword.length;
}
return parts.length ? parts : [{ text: source, match: false }];
}
</script>
<template>
@@ -94,7 +125,7 @@ async function loadVersion(versionId: number | null) {
<div><small>{{ activeSection.sectionKey }}</small><h3>{{ activeSection.title }}</h3></div>
<span>源内容位置 {{ activeSection.sourceStart }}{{ activeSection.sourceEnd }}</span>
</div>
<pre>{{ activeSection.content }}</pre>
<pre><template v-for="(part, index) in highlightedParts(activeSection.content)" :key="index"><mark v-if="part.match">{{ part.text }}</mark><span v-else>{{ part.text }}</span></template></pre>
</article>
</div>
@@ -133,6 +164,7 @@ async function loadVersion(versionId: number | null) {
.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-article mark { padding: 0 2px; border-radius: 3px; background: #fff1a8; color: inherit; }
.content-card-panel { margin-top: 16px; }
@media (max-width: 900px) {
.content-preview-toolbar { align-items: stretch; flex-direction: column; }

View File

@@ -8,6 +8,7 @@ import TableRowActions from "./TableRowActions.vue";
import AdminPagination from "./AdminPagination.vue";
import type {
KnowledgeDetail,
KnowledgeContentSearchItem,
KnowledgeBatchSyncResult,
KnowledgeItem,
KnowledgeSyncJob,
@@ -24,6 +25,8 @@ const detailOpen = ref(false);
const detail = ref<KnowledgeDetail | null>(null);
const jobs = ref<KnowledgeSyncJob[]>([]);
const previewVersionId = ref<number | null>(null);
const previewTargetSectionId = ref<number | null>(null);
const previewHighlightKeyword = ref("");
const activeTab = ref("overview");
const selectedRows = ref<KnowledgeItem[]>([]);
const editOpen = ref(false);
@@ -38,6 +41,10 @@ const nodeResolved = ref(false);
const sourceTitle = ref("");
const filter = reactive({ keyword: "", type: "", open: "", source: "" });
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const contentSearch = reactive({ keyword: "", includeClosed: true });
const contentSearchRows = ref<KnowledgeContentSearchItem[]>([]);
const contentSearchLoading = ref(false);
const contentSearchPager = reactive({ page: 1, pageSize: 10, total: 0 });
const form = reactive({
name: "",
feishuSpaceId: "",
@@ -178,6 +185,36 @@ async function handleMore(command: string, item: KnowledgeItem) {
}
}
async function searchContent(page = contentSearchPager.page, pageSize = contentSearchPager.pageSize) {
const keyword = contentSearch.keyword.trim();
if (!keyword) {
contentSearchRows.value = [];
Object.assign(contentSearchPager, { page: 1, pageSize, total: 0 });
return ElMessage.warning("请输入要搜索的知识库内容关键字");
}
contentSearchLoading.value = true;
try {
const result = await api.knowledgeContentSearch({
keyword,
includeClosed: contentSearch.includeClosed,
page,
pageSize,
});
contentSearchRows.value = result.items;
Object.assign(contentSearchPager, { page: result.page, pageSize: result.pageSize, total: result.total });
} catch (error) {
ElMessage.error(errorMessage(error, "知识库内容搜索失败"));
} finally {
contentSearchLoading.value = false;
}
}
function clearContentSearch() {
contentSearch.keyword = "";
contentSearchRows.value = [];
Object.assign(contentSearchPager, { page: 1, pageSize: contentSearchPager.pageSize, total: 0 });
}
async function restore(item: KnowledgeItem) {
if (!await confirmAction("恢复后知识库仍保持关闭,需要单独确认开放。", "恢复知识库", {
confirmButtonText: "确认恢复", cancelButtonText: "取消", type: "warning",
@@ -218,11 +255,16 @@ async function resolveNode() {
} finally { resolvingNode.value = false; }
}
async function openDetail(item: KnowledgeItem) {
async function openDetail(
item: KnowledgeItem | { id: number },
options: { sectionId?: number | null; keyword?: string; tab?: string } = {},
) {
detailOpen.value = true;
detail.value = null;
activeTab.value = "overview";
activeTab.value = options.tab || "overview";
previewVersionId.value = null;
previewTargetSectionId.value = options.sectionId ?? null;
previewHighlightKeyword.value = options.keyword || "";
try {
const refreshedDetail = await refreshDetail(item.id);
previewVersionId.value = refreshedDetail.currentVersion?.id ?? null;
@@ -232,6 +274,13 @@ async function openDetail(item: KnowledgeItem) {
}
}
async function openSearchHit(item: KnowledgeContentSearchItem) {
await openDetail(
{ id: item.knowledgeId },
{ sectionId: item.sectionId, keyword: contentSearch.keyword.trim(), tab: "content" },
);
}
async function refreshDetail(id: number) {
const [nextDetail, nextJobs] = await Promise.all([
api.knowledgeDetail(id), api.knowledgeSyncJobs(id),
@@ -330,6 +379,36 @@ function typeLabel(value: string) {
return knowledgeTypeOptions.find((item) => item.value === value)?.label || value;
}
function contentItemTypeLabel(value: string) {
return ({ section: "章节", card: "知识卡", chunk: "切片" } as Record<string, string>)[value] || value;
}
function matchedLabel(values: string[]) {
const labels = values.map((value) => ({ title: "标题", content: "正文" } as Record<string, string>)[value] || value);
return labels.join("、");
}
function highlightSnippet(text: string) {
const keyword = contentSearch.keyword.trim();
if (!keyword) return [{ text, match: false }];
const parts: Array<{ text: string; match: boolean }> = [];
const source = text || "";
const lowerSource = source.toLowerCase();
const lowerKeyword = keyword.toLowerCase();
let cursor = 0;
while (cursor < source.length) {
const index = lowerSource.indexOf(lowerKeyword, cursor);
if (index < 0) {
parts.push({ text: source.slice(cursor), match: false });
break;
}
if (index > cursor) parts.push({ text: source.slice(cursor, index), match: false });
parts.push({ text: source.slice(index, index + keyword.length), match: true });
cursor = index + keyword.length;
}
return parts.length ? parts : [{ text: source, match: false }];
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
@@ -380,6 +459,58 @@ async function confirmAction(
</div>
</section>
<section class="knowledge-content-search-panel">
<div class="content-search-head">
<div>
<h3>搜索知识库内容</h3>
<p>跨全部当前最新版搜索章节知识卡和切片用于确认内容是否已同步进检索链路</p>
</div>
<el-switch v-model="contentSearch.includeClosed" active-text="包含已关闭" inactive-text="仅开放" />
</div>
<div class="content-search-row">
<el-input
v-model="contentSearch.keyword"
placeholder="输入内容关键字,例如:静水流深静心、合一作业、会议链接"
clearable
@clear="clearContentSearch"
@keydown.enter.exact.prevent="searchContent(1)"
/>
<el-button type="primary" :loading="contentSearchLoading" @click="searchContent(1)">搜索内容</el-button>
</div>
<div v-if="contentSearchPager.total || contentSearchRows.length" v-loading="contentSearchLoading" class="content-search-results">
<div class="content-search-result-summary">
找到 {{ contentSearchPager.total }} 条命中
<span>结果只来自当前最新版已关闭知识库会标记为不可被用户端默认召回</span>
</div>
<article v-for="item in contentSearchRows" :key="`${item.itemType}-${item.itemId}`" class="content-search-hit">
<div class="content-search-hit-main">
<div class="content-search-hit-title">
<strong>{{ item.title }}</strong>
<el-tag size="small" effect="plain">{{ contentItemTypeLabel(item.itemType) }}</el-tag>
<el-tag size="small" :type="item.canAgentUse ? 'success' : 'warning'">{{ item.canAgentUse ? '可参与召回' : '不可默认召回' }}</el-tag>
</div>
<p class="content-search-hit-meta">
{{ item.knowledgeName }} · V{{ item.versionNo }} · {{ item.sectionKey }} · 命中{{ matchedLabel(item.matchedIn) }}
</p>
<p class="content-search-hit-snippet">
<template v-for="(part, index) in highlightSnippet(item.snippet)" :key="index">
<mark v-if="part.match">{{ part.text }}</mark><span v-else>{{ part.text }}</span>
</template>
</p>
<div class="content-search-diagnostics">
<span>{{ item.knowledgeStatus === 1 ? '已开放' : '已关闭' }}</span>
<span>{{ item.sourceStatus === 'normal' ? '源正常' : '源异常' }}</span>
<span>{{ item.hasChunks ? '已生成切片' : '未生成切片' }}</span>
<span>{{ item.isCurrentVersion ? '当前版本' : '非当前版本' }}</span>
</div>
</div>
<el-button type="primary" plain @click="openSearchHit(item)">查看内容</el-button>
</article>
<AdminPagination :page="contentSearchPager.page" :page-size="contentSearchPager.pageSize" :total="contentSearchPager.total" @change="searchContent" />
</div>
<el-empty v-else-if="contentSearch.keyword && !contentSearchLoading" description="暂无内容搜索结果" :image-size="64" />
</section>
<div class="knowledge-filters">
<el-input v-model="filter.keyword" placeholder="搜索后台名称或飞书标题" clearable @change="load(1)" />
<el-select v-model="filter.type" placeholder="知识库类型" clearable @change="load(1)"><el-option v-for="item in knowledgeTypeOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select>
@@ -420,7 +551,11 @@ async function confirmAction(
<section v-if="detail.manifest" class="manifest-panel"><h4>Knowledge Manifest</h4><p><strong>用途</strong>{{ detail.manifest.purpose }}</p><p><strong>适用</strong>{{ detail.manifest.applicableQuestions }}</p><p><strong>不适用</strong>{{ detail.manifest.inapplicableQuestions }}</p><p><strong>核心主题</strong>{{ detail.manifest.coreTopics }}</p><p><strong>边界</strong>{{ detail.manifest.boundaries }}</p></section>
</el-tab-pane>
<el-tab-pane label="内容预览" name="content" lazy>
<KnowledgeContentPreview :version-id="previewVersionId" />
<KnowledgeContentPreview
:version-id="previewVersionId"
:target-section-id="previewTargetSectionId"
:highlight-keyword="previewHighlightKeyword"
/>
</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="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-tabs>
@@ -511,4 +646,134 @@ async function confirmAction(
.batch-sync-result-table {
margin-top: 16px;
}
.knowledge-content-search-panel {
margin: 18px 0;
padding: 18px;
border: 1px solid #dfe8e5;
border-radius: 10px;
background: #fbfdfc;
}
.content-search-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 14px;
}
.content-search-head h3 {
margin: 0;
color: #203b32;
}
.content-search-head p {
margin: 6px 0 0;
color: #71817b;
font-size: 13px;
}
.content-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
}
.content-search-results {
display: grid;
gap: 10px;
margin-top: 14px;
}
.content-search-result-summary {
display: flex;
justify-content: space-between;
gap: 12px;
color: #53645e;
font-size: 13px;
}
.content-search-result-summary span {
color: #81918b;
}
.content-search-hit {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
padding: 14px;
border: 1px solid #e1ebe7;
border-radius: 8px;
background: #ffffff;
}
.content-search-hit-main {
min-width: 0;
}
.content-search-hit-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.content-search-hit-title strong {
overflow: hidden;
color: #203b32;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.content-search-hit-meta {
margin: 6px 0 0;
color: #708179;
font-size: 12px;
}
.content-search-hit-snippet {
margin: 8px 0 0;
color: #354840;
font-size: 13px;
line-height: 1.7;
}
.content-search-hit-snippet mark {
padding: 0 2px;
border-radius: 3px;
background: #fff1a8;
color: inherit;
}
.content-search-diagnostics {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.content-search-diagnostics span {
padding: 3px 7px;
border-radius: 999px;
background: #eef5f2;
color: #536b61;
font-size: 11px;
}
@media (max-width: 760px) {
.content-search-head,
.content-search-result-summary,
.content-search-hit {
align-items: stretch;
grid-template-columns: 1fr;
flex-direction: column;
}
.content-search-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -12,6 +12,7 @@ import type {
ChatRecordQuery,
DashboardStats,
KnowledgeItem,
KnowledgeContentSearchItem,
KnowledgeDetail,
KnowledgeSyncJob,
KnowledgeVersionContent,
@@ -156,6 +157,8 @@ export const api = {
{ method: "POST", body: JSON.stringify(body) },
),
knowledgeDetail: (id: number) => request<KnowledgeDetail>(`/admin/knowledge/${id}/detail`),
knowledgeContentSearch: (query: { keyword: string; includeClosed?: boolean; page?: number; pageSize?: number }) =>
request<PageResult<KnowledgeContentSearchItem>>(`/admin/knowledge/content-search${queryString(query)}`),
knowledgeVersionContent: (versionId: number) => request<KnowledgeVersionContent>(`/admin/knowledge/version/${versionId}/content`),
knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`),
syncKnowledge: (id: number) =>

View File

@@ -209,6 +209,29 @@ export interface KnowledgeVersionContent {
cards: Array<Record<string, unknown>>;
}
export interface KnowledgeContentSearchItem {
knowledgeId: number;
knowledgeName: string;
sourceTitle?: string | null;
knowledgeType: "course" | "qa" | "general" | "fixed";
knowledgeStatus: number;
lifecycleStatus: "active" | "archived";
sourceStatus: "normal" | "error";
versionId: number;
versionNo: number;
publishedAt?: string | null;
itemType: "section" | "card" | "chunk";
itemId: number;
sectionId: number;
sectionKey: string;
title: string;
snippet: string;
matchedIn: Array<"title" | "content">;
hasChunks: boolean;
isCurrentVersion: boolean;
canAgentUse: boolean;
}
export interface KnowledgeSyncJob {
id: number;
knowledgeId: number;