795 lines
33 KiB
Vue
795 lines
33 KiB
Vue
<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>
|