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

795 lines
33 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 { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import { formatDateTime } from "../utils/dateTime";
import KnowledgeContentPreview from "./KnowledgeContentPreview.vue";
import TableRowActions from "./TableRowActions.vue";
import AdminPagination from "./AdminPagination.vue";
import type {
KnowledgeDetail,
KnowledgeContentSearchItem,
KnowledgeBatchSyncResult,
KnowledgeItem,
KnowledgeSyncJob,
MigrationSummary,
} from "../types/api";
const emit = defineEmits<{ preview: [knowledgeId: number] }>();
const rows = ref<KnowledgeItem[]>([]);
const migration = ref<MigrationSummary | null>(null);
const loading = ref(false);
const operatingId = ref<number | null>(null);
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);
const editSaving = ref(false);
const batchOpen = ref(false);
const batchSaving = ref(false);
const batchSyncing = ref(false);
const batchSyncResultOpen = ref(false);
const batchSyncResult = ref<KnowledgeBatchSyncResult | null>(null);
const resolvingNode = ref(false);
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: "",
feishuNodeId: "",
knowledgeType: "general",
status: 0,
remark: "",
});
const editForm = reactive({ id: 0, name: "", knowledgeType: "general" });
const batchForm = reactive({ knowledgeType: "" });
const knowledgeTypeOptions = [
{ label: "课程知识库", value: "course" },
{ label: "答疑知识库", value: "qa" },
{ label: "固定信息类知识库", value: "fixed" },
{ label: "通用知识库", value: "general" },
];
onMounted(load);
async function load(page = pager.page, pageSize = pager.pageSize) {
loading.value = true;
try {
const result = await api.knowledge({
keyword: filter.keyword, knowledgeType: filter.type, openStatus: filter.open,
sourceStatus: filter.source,
page, pageSize,
});
rows.value = result.items;
Object.assign(pager, { page: result.page, pageSize: result.pageSize, total: result.total });
if (!migration.value) migration.value = await api.migrationSummary();
} catch (error) {
ElMessage.error(errorMessage(error, "知识库列表加载失败"));
} finally {
loading.value = false;
}
}
function openEdit() {
if (!detail.value) return;
Object.assign(editForm, {
id: detail.value.knowledge.id,
name: detail.value.knowledge.name,
knowledgeType: detail.value.knowledge.knowledgeType,
});
editOpen.value = true;
}
async function saveMetadata() {
if (!editForm.name.trim()) {
ElMessage.warning("请输入后台知识库名称");
return;
}
editSaving.value = true;
try {
await api.updateKnowledgeMetadata(editForm.id, {
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType,
});
ElMessage.success("知识库基本信息已更新");
editOpen.value = false;
await Promise.all([load(), refreshDetail(editForm.id)]);
} catch (error) {
ElMessage.error(errorMessage(error, "基本信息保存失败"));
} finally {
editSaving.value = false;
}
}
function openBatchEdit() {
if (!selectedRows.value.length) {
ElMessage.warning("请先勾选需要修改的知识库");
return;
}
Object.assign(batchForm, { knowledgeType: "" });
batchOpen.value = true;
}
async function saveBatchMetadata() {
if (!batchForm.knowledgeType) {
ElMessage.warning("请选择知识库类型");
return;
}
if (!await confirmAction(
`将修改已选择的 ${selectedRows.value.length} 个知识库。该操作不影响正式版本和开放状态。`,
"确认批量修改",
{ confirmButtonText: "确认修改", cancelButtonText: "取消", type: "warning" },
)) return;
batchSaving.value = true;
try {
const result = await api.batchUpdateKnowledgeMetadata({
knowledgeIds: selectedRows.value.map((item) => item.id),
knowledgeType: batchForm.knowledgeType || undefined,
});
ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed}`);
batchOpen.value = false;
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "批量修改失败"));
} finally {
batchSaving.value = false;
}
}
async function syncSelected() {
if (!selectedRows.value.length) {
ElMessage.warning("请先勾选需要同步的知识库");
return;
}
if (!await confirmAction(
`将依次同步已选择的 ${selectedRows.value.length} 个知识库。同步成功后最新内容立即生效,单个失败不会中断其他知识库。`,
"确认批量同步",
{ confirmButtonText: "开始同步", cancelButtonText: "取消", type: "warning" },
)) return;
batchSyncing.value = true;
try {
const result = await api.batchSyncKnowledge(selectedRows.value.map((item) => item.id));
batchSyncResult.value = result;
batchSyncResultOpen.value = true;
ElMessage[result.failed ? "warning" : "success"](
`批量同步完成:成功 ${result.success} 个,失败 ${result.failed}`,
);
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "批量同步失败"));
} finally {
batchSyncing.value = false;
}
}
async function handleMore(command: string, item: KnowledgeItem) {
try {
if (command === "detail") return await openDetail(item);
if (command === "preview") return emit("preview", item.id);
if (command === "archive") return await archive(item);
if (command === "restore") return await restore(item);
if (command === "delete") return await remove(item);
} catch (error) {
if (error !== "cancel" && error !== "close") ElMessage.error(errorMessage(error, "操作失败"));
}
}
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",
})) return;
await api.setKnowledgeLifecycle(item.id, "active");
ElMessage.success("知识库已恢复,当前仍为关闭状态");
await load();
}
async function createKnowledge() {
if (!nodeResolved.value || !form.name || !form.feishuSpaceId || !form.feishuNodeId) {
ElMessage.warning("请先通过 Node ID 获取并确认知识库信息");
return;
}
try {
await api.createKnowledge({ ...form });
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", status: 0, remark: "" });
nodeResolved.value = false; sourceTitle.value = "";
ElMessage.success("知识库已新增,首次同步成功后将自动生效");
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "知识库新增失败"));
}
}
async function resolveNode() {
const nodeId = form.feishuNodeId.trim();
if (!nodeId) return ElMessage.warning("请输入 Node ID");
resolvingNode.value = true;
try {
const info = await api.resolveKnowledgeNode(nodeId);
Object.assign(form, { feishuNodeId: info.nodeId, feishuSpaceId: info.spaceId, name: info.name, remark: info.remark || "" });
sourceTitle.value = info.sourceTitle; nodeResolved.value = true;
ElMessage.success("已获取飞书知识库信息");
} catch (error) {
nodeResolved.value = false; sourceTitle.value = "";
ElMessage.error(errorMessage(error, "Node ID 信息获取失败"));
} finally { resolvingNode.value = false; }
}
async function openDetail(
item: KnowledgeItem | { id: number },
options: { sectionId?: number | null; keyword?: string; tab?: string } = {},
) {
detailOpen.value = true;
detail.value = null;
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;
} catch (error) {
detailOpen.value = false;
ElMessage.error(errorMessage(error, "知识库详情加载失败"));
}
}
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),
]);
detail.value = nextDetail;
jobs.value = nextJobs;
return nextDetail;
}
async function sync(item: KnowledgeItem) {
operatingId.value = item.id;
try {
const result = await api.syncKnowledge(item.id);
const message = result.status === "unchanged"
? "飞书内容未变化,未生成新版本"
: result.status === "published"
? "同步完成,最新版本已自动生效"
: "同步完成";
ElMessage.success(message);
await load();
if (detailOpen.value) await refreshDetail(item.id);
} catch (error) {
ElMessage.error(errorMessage(error, "知识库同步失败"));
} finally {
operatingId.value = null;
}
}
async function runMigration() {
if (!await confirmAction(
"将依次重新读取全部飞书文章。同步成功后仅保留最新版,旧版本和旧内容会被清理。",
"开始知识迁移",
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
)) return;
loading.value = true;
try {
const result = await api.runKnowledgeMigration();
ElMessage.success(`迁移处理完成:${result.success}/${result.total} 成功`);
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "知识迁移失败"));
} finally {
loading.value = false;
}
}
async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
const next = item.status !== 1;
if (!await confirmAction(
next ? "开放后,该知识库将从用户下一条消息开始进入 Agent 动态知识目录。" : "关闭后,当前生成不受影响,从用户下一条消息开始无法检索该知识库。",
next ? "开放知识库" : "关闭知识库",
{ confirmButtonText: next ? "确认开放" : "确认关闭", cancelButtonText: "取消", type: "warning" },
)) return false;
operatingId.value = item.id;
try {
await api.setKnowledgeOpen(item.id, next);
ElMessage.success(next ? "知识库已开放" : "知识库已关闭");
if (detailOpen.value && detail.value?.knowledge.id === item.id) await refreshDetail(item.id);
return true;
} catch (error) {
ElMessage.error(errorMessage(error, next ? "知识库开放失败" : "知识库关闭失败"));
return false;
} finally {
operatingId.value = null;
}
}
async function archive(item: KnowledgeItem) {
if (!await confirmAction("归档后知识库会立即关闭,当前最新版和检索记录仍会保留。", "归档知识库", {
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
})) return;
await api.setKnowledgeLifecycle(item.id, "archived");
ElMessage.success("知识库已归档");
await load();
}
async function remove(item: KnowledgeItem) {
if (!await confirmAction(
`将永久删除“${item.name}”的知识正文、版本、章节、切片和同步记录,并立即停止 Agent 使用。审计日志仍会保留,该操作不可恢复。`,
"永久删除知识库",
{ confirmButtonText: "永久删除", cancelButtonText: "取消", type: "error" },
)) return;
await api.deleteKnowledge(item.id);
ElMessage.success("知识库已删除");
await load();
}
function statusType(status: string) {
if (["published", "passed", "normal", "completed"].includes(status)) return "success";
if (["failed", "blocked", "error", "rejected"].includes(status)) return "danger";
if (["pending_review", "processing", "pending"].includes(status)) return "warning";
return "info";
}
function typeLabel(value: string) {
return knowledgeTypeOptions.find((item) => item.value === value)?.label || value;
}
function typeDescription(value: string) {
return ({
fixed: "固定信息类用于上课时间、带练安排、回放音频、课程作业、会议链接、服务权益、助理联系方式等当前安排;与其他库冲突时优先采用固定信息类。",
course: "课程知识库用于课程概念、功课步骤、老师观点和课程原文解释;优先级高于答疑和通用知识。",
qa: "答疑知识库用于参考过往问答和常见问题,但不能覆盖课程原文或固定信息类的当前安排。",
general: "通用知识库只做背景补充,不能覆盖课程知识、答疑知识或固定信息类。",
} as Record<string, string>)[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, terms: string[] = []) {
const keywords = Array.from(new Set([...terms, contentSearch.keyword.trim()].filter(Boolean)))
.sort((left, right) => right.length - left.length);
if (!keywords.length) return [{ text, match: false }];
const parts: Array<{ text: string; match: boolean }> = [];
const source = text || "";
const lowerSource = source.toLowerCase();
let cursor = 0;
while (cursor < source.length) {
const next = keywords
.map((keyword) => ({ keyword, index: lowerSource.indexOf(keyword.toLowerCase(), cursor) }))
.filter((item) => item.index >= 0)
.sort((left, right) => left.index - right.index || right.keyword.length - left.keyword.length)[0];
if (!next) {
parts.push({ text: source.slice(cursor), match: false });
break;
}
const { index, keyword } = next;
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;
}
async function confirmAction(
message: string,
title: string,
options: Parameters<typeof ElMessageBox.confirm>[2],
) {
try {
await ElMessageBox.confirm(message, title, options);
return true;
} catch {
return false;
}
}
</script>
<template>
<div class="page-head inline">
<div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库同步成功后自动生效并且只保留最新版</p></div>
<el-button type="primary" :loading="loading" @click="runMigration">迁移现有知识库</el-button>
</div>
<section class="migration-overview">
<div><span>知识库总数</span><strong>{{ migration?.total ?? 0 }}</strong></div>
<div><span>待迁移</span><strong>{{ migration?.counts.pending ?? 0 }}</strong></div>
<div><span>迁移完成</span><strong>{{ migration?.counts.completed ?? 0 }}</strong></div>
<div><span>失败</span><strong>{{ migration?.counts.failed ?? 0 }}</strong></div>
</section>
<section class="knowledge-work-panel">
<h3>通过 Node ID 快速新增</h3>
<div class="node-resolve-row">
<el-input v-model="form.feishuNodeId" placeholder="请输入飞书 Node ID" :disabled="resolvingNode" @input="nodeResolved = false" />
<el-button type="primary" :loading="resolvingNode" @click="resolveNode">获取知识库信息</el-button>
</div>
<el-alert v-if="nodeResolved" :title="`已获取:${sourceTitle}`" type="success" :closable="false" show-icon />
<div v-if="nodeResolved" class="knowledge-create-grid">
<el-input v-model="form.feishuSpaceId" placeholder="Space ID" disabled />
<el-input :model-value="sourceTitle" placeholder="飞书原标题" disabled />
<el-input v-model="form.name" placeholder="后台显示名称" />
<el-select v-model="form.knowledgeType">
<el-option v-for="item in knowledgeTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-input v-model="form.remark" placeholder="备注(可修改)" />
<el-button type="primary" @click="createKnowledge">保存知识库</el-button>
</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 v-for="type in item.itemTypes || [item.itemType]" :key="type" size="small" effect="plain">{{ contentItemTypeLabel(type) }}</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, item.highlightTerms)" :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>
<el-select v-model="filter.open" placeholder="开放状态" clearable @change="load(1)"><el-option label="已开放" value="1" /><el-option label="已关闭" value="0" /></el-select>
<el-select v-model="filter.source" placeholder="源状态" clearable @change="load(1)"><el-option label="正常" value="normal" /><el-option label="源异常" value="error" /></el-select>
</div>
<div class="knowledge-batch-bar">
<span>已选择 {{ selectedRows.length }} 个知识库</span>
<div class="knowledge-batch-actions">
<el-button type="primary" plain :disabled="selectedRows.length === 0 || batchSaving" :loading="batchSyncing" @click="syncSelected">批量同步</el-button>
<el-button :disabled="selectedRows.length === 0 || batchSyncing" @click="openBatchEdit">批量修改类型</el-button>
</div>
</div>
<el-table v-loading="loading" :data="rows" stripe @selection-change="selectedRows = $event">
<el-table-column type="selection" width="48" />
<el-table-column label="知识库" min-width="240"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="table-subline">{{ row.sourceTitle || "尚未同步源标题" }}</small></template></el-table-column>
<el-table-column label="类型" width="140"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}</template></el-table-column>
<el-table-column label="当前版本" width="130"><template #default="{ row }"><span>{{ row.currentVersionId ? `#${row.currentVersionId}` : '尚未同步' }}</span></template></el-table-column>
<el-table-column label="状态" width="210"><template #default="{ row }"><div class="knowledge-status-cell"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" active-text="开放" inactive-text="关闭" inline-prompt :loading="operatingId === row.id" :disabled="row.lifecycleStatus === 'archived'" :before-change="() => toggleOpen(row)" /><el-tag :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></div></template></el-table-column>
<el-table-column label="最近同步" width="180"><template #default="{ row }">{{ formatDateTime(row.lastSyncedAt) }}</template></el-table-column>
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><TableRowActions><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :disabled="row.lifecycleStatus === 'archived'" :loading="operatingId === row.id" @click="sync(row)">同步最新版</el-button><el-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="detail">查看详情</el-dropdown-item><el-dropdown-item command="preview">进入 Agent 预览</el-dropdown-item><el-dropdown-item v-if="row.lifecycleStatus === 'active'" command="archive" divided>归档</el-dropdown-item><el-dropdown-item v-else command="restore" divided>恢复</el-dropdown-item><el-dropdown-item command="delete" divided>永久删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions></template></el-table-column>
</el-table>
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" />
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
<template v-if="detail">
<div class="knowledge-detail-head">
<div><h3>{{ detail.knowledge.name }}</h3><p>{{ typeLabel(detail.knowledge.knowledgeType) }} · 同步后自动生效</p></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>
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
<el-tabs v-model="activeTab">
<el-tab-pane label="基本信息" name="overview">
<div class="knowledge-section-head"><h4>基本信息</h4><el-button type="primary" plain @click="openEdit">编辑基本信息</el-button></div>
<dl class="knowledge-meta"><dt>后台名称</dt><dd>{{ detail.knowledge.name }}</dd><dt>知识库类型</dt><dd>{{ typeLabel(detail.knowledge.knowledgeType) }}</dd><dt>版本更新</dt><dd>同步成功后自动替换</dd><dt>SpaceID</dt><dd>{{ detail.knowledge.feishuSpaceId }}</dd><dt>NodeID</dt><dd>{{ detail.knowledge.feishuNodeId }}</dd><dt>当前版本</dt><dd>{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}</dd></dl>
<el-alert :title="typeDescription(detail.knowledge.knowledgeType)" type="info" :closable="false" show-icon />
<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"
: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>
</template>
</el-drawer>
<el-dialog v-model="editOpen" title="编辑知识库基本信息" width="min(520px, 92vw)" append-to-body destroy-on-close>
<el-form label-position="top">
<el-form-item label="后台知识库名称" required><el-input v-model="editForm.name" maxlength="100" show-word-limit /></el-form-item>
<el-form-item label="知识库类型" required><el-select v-model="editForm.knowledgeType"><el-option v-for="item in knowledgeTypeOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-alert title="本次修改不会重新同步飞书,也不会改变当前正式版本和开放状态。" type="info" :closable="false" />
</el-form>
<template #footer><el-button @click="editOpen = false">取消</el-button><el-button type="primary" :loading="editSaving" @click="saveMetadata">保存</el-button></template>
</el-dialog>
<el-dialog v-model="batchOpen" title="批量修改知识库" width="min(520px, 92vw)" append-to-body destroy-on-close>
<el-alert :title="`已选择 ${selectedRows.length} 个知识库,未选择的字段将保持原值。`" type="info" :closable="false" />
<el-form label-position="top" class="batch-edit-form">
<el-form-item label="知识库类型"><el-select v-model="batchForm.knowledgeType" clearable placeholder="不修改"><el-option v-for="item in knowledgeTypeOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="batchOpen = false">取消</el-button><el-button type="primary" :loading="batchSaving" @click="saveBatchMetadata">下一步</el-button></template>
</el-dialog>
<el-dialog v-model="batchSyncResultOpen" title="批量同步结果" width="min(760px, 94vw)" append-to-body destroy-on-close>
<el-alert
v-if="batchSyncResult"
:title="`共处理 ${batchSyncResult.total} 个:成功 ${batchSyncResult.success} 个,失败 ${batchSyncResult.failed} 个`"
:type="batchSyncResult.failed ? 'warning' : 'success'"
:closable="false"
show-icon
/>
<el-table v-if="batchSyncResult" :data="batchSyncResult.results" max-height="440" class="batch-sync-result-table">
<el-table-column label="知识库" min-width="220">
<template #default="{ row }">{{ row.name || `知识库 #${row.knowledgeId}` }}</template>
</el-table-column>
<el-table-column label="结果" width="100">
<template #default="{ row }"><el-tag :type="row.ok ? 'success' : 'danger'">{{ row.ok ? '成功' : '失败' }}</el-tag></template>
</el-table-column>
<el-table-column label="状态 / 原因" min-width="300">
<template #default="{ row }">{{ row.ok ? (row.status === 'unchanged' ? '内容无变化' : '最新内容已生效') : row.error }}</template>
</el-table-column>
</el-table>
<template #footer><el-button type="primary" @click="batchSyncResultOpen = false">完成</el-button></template>
</el-dialog>
</template>
<style scoped>
.knowledge-batch-bar,
.knowledge-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.knowledge-batch-bar {
min-height: 48px;
padding: 8px 0;
color: #66756f;
}
.knowledge-batch-actions {
display: flex;
align-items: center;
gap: 8px;
}
.knowledge-section-head h4 {
margin: 0;
}
.knowledge-status-cell {
display: flex;
align-items: center;
gap: 10px;
}
.knowledge-detail-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.batch-edit-form {
margin-top: 20px;
}
.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>