refactor(knowledge): keep latest version only
This commit is contained in:
@@ -2,15 +2,12 @@
|
|||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
import type { KnowledgeVersion, KnowledgeVersionContent } from "../types/api";
|
import type { KnowledgeVersionContent } from "../types/api";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
versions: KnowledgeVersion[];
|
|
||||||
versionId: number | null;
|
versionId: number | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ "update:versionId": [versionId: number] }>();
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const content = ref<KnowledgeVersionContent | null>(null);
|
const content = ref<KnowledgeVersionContent | null>(null);
|
||||||
@@ -50,22 +47,6 @@ async function loadVersion(versionId: number | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectVersion(value: number) {
|
|
||||||
emit("update:versionId", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function versionStatus(version: KnowledgeVersion) {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
published: "正式版本",
|
|
||||||
processing: "同步处理中",
|
|
||||||
pending_review: "未生效版本",
|
|
||||||
approved: "未生效版本",
|
|
||||||
rejected: "未生效版本",
|
|
||||||
discarded: "已废弃版本",
|
|
||||||
superseded: "历史版本",
|
|
||||||
};
|
|
||||||
return labels[version.status] ?? version.status;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -73,22 +54,8 @@ function versionStatus(version: KnowledgeVersion) {
|
|||||||
<header class="content-preview-toolbar">
|
<header class="content-preview-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h4>知识库内容预览</h4>
|
<h4>知识库内容预览</h4>
|
||||||
<p>按同步后的章节结构查看实际进入检索链路的内容。</p>
|
<p>查看当前最新版实际进入检索链路的章节内容。</p>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon>
|
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon>
|
||||||
@@ -150,7 +117,6 @@ function versionStatus(version: KnowledgeVersion) {
|
|||||||
.content-preview-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
|
.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 h4 { margin: 0; font-size: 17px; color: #203b32; }
|
||||||
.content-preview-toolbar p { margin: 6px 0 0; color: #71817b; font-size: 13px; }
|
.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 { 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-summary span { padding: 5px 10px; border-radius: 999px; background: #f0f6f3; color: #526860; font-size: 12px; }
|
||||||
.content-preview-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); height: clamp(420px, 62vh, 680px); min-height: 0; overflow: hidden; border: 1px solid #dfe8e5; border-radius: 12px; background: #fff; }
|
.content-preview-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); height: clamp(420px, 62vh, 680px); min-height: 0; overflow: hidden; border: 1px solid #dfe8e5; border-radius: 12px; background: #fff; }
|
||||||
@@ -170,7 +136,6 @@ function versionStatus(version: KnowledgeVersion) {
|
|||||||
.content-card-panel { margin-top: 16px; }
|
.content-card-panel { margin-top: 16px; }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.content-preview-toolbar { align-items: stretch; flex-direction: column; }
|
.content-preview-toolbar { align-items: stretch; flex-direction: column; }
|
||||||
.content-version-select { width: 100%; flex-basis: auto; }
|
|
||||||
.content-preview-layout { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
|
.content-preview-layout { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
|
||||||
.content-outline { display: flex; height: auto; gap: 8px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid #e5ece9; }
|
.content-outline { display: flex; height: auto; gap: 8px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid #e5ece9; }
|
||||||
.content-outline > strong { display: none; }
|
.content-outline > strong { display: none; }
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import type {
|
|||||||
KnowledgeDetail,
|
KnowledgeDetail,
|
||||||
KnowledgeItem,
|
KnowledgeItem,
|
||||||
KnowledgeSyncJob,
|
KnowledgeSyncJob,
|
||||||
KnowledgeVersion,
|
|
||||||
MigrationSummary,
|
MigrationSummary,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ const loading = ref(false);
|
|||||||
const operatingId = ref<number | null>(null);
|
const operatingId = ref<number | null>(null);
|
||||||
const detailOpen = ref(false);
|
const detailOpen = ref(false);
|
||||||
const detail = ref<KnowledgeDetail | null>(null);
|
const detail = ref<KnowledgeDetail | null>(null);
|
||||||
const versions = ref<KnowledgeVersion[]>([]);
|
|
||||||
const jobs = ref<KnowledgeSyncJob[]>([]);
|
const jobs = ref<KnowledgeSyncJob[]>([]);
|
||||||
const previewVersionId = ref<number | null>(null);
|
const previewVersionId = ref<number | null>(null);
|
||||||
const activeTab = ref("overview");
|
const activeTab = ref("overview");
|
||||||
@@ -136,11 +134,6 @@ async function handleMore(command: string, item: KnowledgeItem) {
|
|||||||
try {
|
try {
|
||||||
if (command === "detail") return await openDetail(item);
|
if (command === "detail") return await openDetail(item);
|
||||||
if (command === "preview") return emit("preview", item.id);
|
if (command === "preview") return emit("preview", item.id);
|
||||||
if (command === "versions") {
|
|
||||||
await openDetail(item);
|
|
||||||
if (detailOpen.value) activeTab.value = "versions";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (command === "archive") return await archive(item);
|
if (command === "archive") return await archive(item);
|
||||||
if (command === "restore") return await restore(item);
|
if (command === "restore") return await restore(item);
|
||||||
if (command === "delete") return await remove(item);
|
if (command === "delete") return await remove(item);
|
||||||
@@ -196,7 +189,7 @@ async function openDetail(item: KnowledgeItem) {
|
|||||||
previewVersionId.value = null;
|
previewVersionId.value = null;
|
||||||
try {
|
try {
|
||||||
const refreshedDetail = await refreshDetail(item.id);
|
const refreshedDetail = await refreshDetail(item.id);
|
||||||
previewVersionId.value = refreshedDetail.currentVersion?.id ?? versions.value[0]?.id ?? null;
|
previewVersionId.value = refreshedDetail.currentVersion?.id ?? null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
detailOpen.value = false;
|
detailOpen.value = false;
|
||||||
ElMessage.error(errorMessage(error, "知识库详情加载失败"));
|
ElMessage.error(errorMessage(error, "知识库详情加载失败"));
|
||||||
@@ -204,11 +197,10 @@ async function openDetail(item: KnowledgeItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshDetail(id: number) {
|
async function refreshDetail(id: number) {
|
||||||
const [nextDetail, nextVersions, nextJobs] = await Promise.all([
|
const [nextDetail, nextJobs] = await Promise.all([
|
||||||
api.knowledgeDetail(id), api.knowledgeVersions(id), api.knowledgeSyncJobs(id),
|
api.knowledgeDetail(id), api.knowledgeSyncJobs(id),
|
||||||
]);
|
]);
|
||||||
detail.value = nextDetail;
|
detail.value = nextDetail;
|
||||||
versions.value = nextVersions;
|
|
||||||
jobs.value = nextJobs;
|
jobs.value = nextJobs;
|
||||||
return nextDetail;
|
return nextDetail;
|
||||||
}
|
}
|
||||||
@@ -234,7 +226,7 @@ async function sync(item: KnowledgeItem) {
|
|||||||
|
|
||||||
async function runMigration() {
|
async function runMigration() {
|
||||||
if (!await confirmAction(
|
if (!await confirmAction(
|
||||||
"将依次重新读取全部飞书文章。每个知识库同步成功后会立即替换为最新版本,原版本仍可回滚。",
|
"将依次重新读取全部飞书文章。同步成功后仅保留最新版,旧版本和旧内容会被清理。",
|
||||||
"开始知识迁移",
|
"开始知识迁移",
|
||||||
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
|
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
|
||||||
)) return;
|
)) return;
|
||||||
@@ -250,23 +242,6 @@ async function runMigration() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rollback(version: KnowledgeVersion) {
|
|
||||||
if (!detail.value) return;
|
|
||||||
if (!await confirmAction(
|
|
||||||
`将从当前版本回滚到 V${version.versionNo},从用户下一条消息开始生效。`,
|
|
||||||
"回滚知识版本",
|
|
||||||
{ confirmButtonText: "确认回滚", cancelButtonText: "取消", type: "warning" },
|
|
||||||
)) return;
|
|
||||||
try {
|
|
||||||
await api.rollbackKnowledgeVersion(detail.value.knowledge.id, version.id, detail.value.knowledge.status === 1);
|
|
||||||
ElMessage.success("知识版本已回滚");
|
|
||||||
await load();
|
|
||||||
await refreshDetail(detail.value.knowledge.id);
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(errorMessage(error, "版本回滚失败"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
|
async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
|
||||||
const next = item.status !== 1;
|
const next = item.status !== 1;
|
||||||
if (!await confirmAction(
|
if (!await confirmAction(
|
||||||
@@ -289,7 +264,7 @@ async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function archive(item: KnowledgeItem) {
|
async function archive(item: KnowledgeItem) {
|
||||||
if (!await confirmAction("归档后知识库会立即关闭,但历史正式版本和检索记录将保留。", "归档知识库", {
|
if (!await confirmAction("归档后知识库会立即关闭,当前最新版和检索记录仍会保留。", "归档知识库", {
|
||||||
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
|
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
|
||||||
})) return;
|
})) return;
|
||||||
await api.setKnowledgeLifecycle(item.id, "archived");
|
await api.setKnowledgeLifecycle(item.id, "archived");
|
||||||
@@ -308,11 +283,6 @@ async function remove(item: KnowledgeItem) {
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showVersion(version: KnowledgeVersion) {
|
|
||||||
previewVersionId.value = version.id;
|
|
||||||
activeTab.value = "content";
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusType(status: string) {
|
function statusType(status: string) {
|
||||||
if (["published", "passed", "normal", "completed"].includes(status)) return "success";
|
if (["published", "passed", "normal", "completed"].includes(status)) return "success";
|
||||||
if (["failed", "blocked", "error", "rejected"].includes(status)) return "danger";
|
if (["failed", "blocked", "error", "rejected"].includes(status)) return "danger";
|
||||||
@@ -344,7 +314,7 @@ async function confirmAction(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-head inline">
|
<div class="page-head inline">
|
||||||
<div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库;同步成功后最新版本自动生效,历史版本可随时回滚。</p></div>
|
<div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库;同步成功后自动生效,并且只保留最新版。</p></div>
|
||||||
<el-button type="primary" :loading="loading" @click="runMigration">迁移现有知识库</el-button>
|
<el-button type="primary" :loading="loading" @click="runMigration">迁移现有知识库</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -393,7 +363,7 @@ async function confirmAction(
|
|||||||
<el-table-column label="当前版本" width="130"><template #default="{ row }"><span>{{ row.currentVersionId ? `#${row.currentVersionId}` : '尚未同步' }}</span></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="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 prop="lastSyncedAt" label="最近同步" width="180" />
|
<el-table-column prop="lastSyncedAt" label="最近同步" width="180" />
|
||||||
<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 command="versions">查看版本</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" :disabled="row.lifecycleStatus !== 'archived'" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions></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" :disabled="row.lifecycleStatus !== 'archived'" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions></template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" />
|
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" />
|
||||||
|
|
||||||
@@ -411,9 +381,8 @@ 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>
|
<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>
|
||||||
<el-tab-pane label="内容预览" name="content" lazy>
|
<el-tab-pane label="内容预览" name="content" lazy>
|
||||||
<KnowledgeContentPreview v-model:version-id="previewVersionId" :versions="versions" />
|
<KnowledgeContentPreview :version-id="previewVersionId" />
|
||||||
</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="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-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>
|
</el-tabs>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type {
|
|||||||
KnowledgeItem,
|
KnowledgeItem,
|
||||||
KnowledgeDetail,
|
KnowledgeDetail,
|
||||||
KnowledgeSyncJob,
|
KnowledgeSyncJob,
|
||||||
KnowledgeVersion,
|
|
||||||
KnowledgeVersionContent,
|
KnowledgeVersionContent,
|
||||||
MigrationSummary,
|
MigrationSummary,
|
||||||
KnowledgeBatchUpdateResult,
|
KnowledgeBatchUpdateResult,
|
||||||
@@ -150,13 +149,10 @@ export const api = {
|
|||||||
{ method: "POST", body: JSON.stringify(body) },
|
{ method: "POST", body: JSON.stringify(body) },
|
||||||
),
|
),
|
||||||
knowledgeDetail: (id: number) => request<KnowledgeDetail>(`/admin/knowledge/${id}/detail`),
|
knowledgeDetail: (id: number) => request<KnowledgeDetail>(`/admin/knowledge/${id}/detail`),
|
||||||
knowledgeVersions: (id: number) => request<KnowledgeVersion[]>(`/admin/knowledge/${id}/versions`),
|
|
||||||
knowledgeVersionContent: (versionId: number) => request<KnowledgeVersionContent>(`/admin/knowledge/version/${versionId}/content`),
|
knowledgeVersionContent: (versionId: number) => request<KnowledgeVersionContent>(`/admin/knowledge/version/${versionId}/content`),
|
||||||
knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`),
|
knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`),
|
||||||
syncKnowledge: (id: number) =>
|
syncKnowledge: (id: number) =>
|
||||||
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST" }),
|
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST" }),
|
||||||
rollbackKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
|
|
||||||
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/rollback`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
|
|
||||||
setKnowledgeOpen: (id: number, open: boolean) =>
|
setKnowledgeOpen: (id: number, open: boolean) =>
|
||||||
request<KnowledgeItem>(`/admin/knowledge/${id}/open-status`, { method: "PUT", body: JSON.stringify({ open }) }),
|
request<KnowledgeItem>(`/admin/knowledge/${id}/open-status`, { method: "PUT", body: JSON.stringify({ open }) }),
|
||||||
setKnowledgeLifecycle: (id: number, status: "active" | "archived") =>
|
setKnowledgeLifecycle: (id: number, status: "active" | "archived") =>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""keep only the latest knowledge version
|
||||||
|
|
||||||
|
Revision ID: 0012_knowledge_latest_only
|
||||||
|
Revises: 0011_knowledge_auto_publish
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0012_knowledge_latest_only"
|
||||||
|
down_revision = "0011_knowledge_auto_publish"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("UPDATE sys_knowledge SET pending_version_id = NULL")
|
||||||
|
for table in (
|
||||||
|
"sys_knowledge_review_record",
|
||||||
|
"sys_knowledge_card",
|
||||||
|
"sys_knowledge_chunk",
|
||||||
|
"sys_knowledge_section",
|
||||||
|
"sys_knowledge_manifest",
|
||||||
|
):
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
DELETE detail
|
||||||
|
FROM {table} AS detail
|
||||||
|
JOIN sys_knowledge AS knowledge ON knowledge.id = detail.knowledge_id
|
||||||
|
WHERE knowledge.current_version_id IS NULL
|
||||||
|
OR detail.version_id <> knowledge.current_version_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DELETE version
|
||||||
|
FROM sys_knowledge_version AS version
|
||||||
|
JOIN sys_knowledge AS knowledge ON knowledge.id = version.knowledge_id
|
||||||
|
WHERE knowledge.current_version_id IS NULL
|
||||||
|
OR version.id <> knowledge.current_version_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DELETE snapshot
|
||||||
|
FROM sys_knowledge_source_snapshot AS snapshot
|
||||||
|
LEFT JOIN sys_knowledge_version AS version ON version.snapshot_id = snapshot.id
|
||||||
|
WHERE version.id IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Removed historical content cannot be reconstructed from the latest version.
|
||||||
|
pass
|
||||||
@@ -23,7 +23,6 @@ from app.schemas.knowledge import (
|
|||||||
KnowledgeLifecycleRequest,
|
KnowledgeLifecycleRequest,
|
||||||
KnowledgeBatchMetadataUpdateRequest,
|
KnowledgeBatchMetadataUpdateRequest,
|
||||||
KnowledgeMetadataUpdateRequest,
|
KnowledgeMetadataUpdateRequest,
|
||||||
KnowledgeRollbackRequest,
|
|
||||||
KnowledgeStatusRequest,
|
KnowledgeStatusRequest,
|
||||||
)
|
)
|
||||||
from app.services.admin_service import OperationLogService
|
from app.services.admin_service import OperationLogService
|
||||||
@@ -113,22 +112,6 @@ def detail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/knowledge/{knowledge_id}/versions")
|
|
||||||
def versions(
|
|
||||||
knowledge_id: int,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_admin: Admin = Depends(get_current_admin),
|
|
||||||
) -> dict:
|
|
||||||
_require_super_admin(current_admin)
|
|
||||||
_knowledge(db, knowledge_id)
|
|
||||||
rows = db.scalars(
|
|
||||||
select(KnowledgeVersion)
|
|
||||||
.where(KnowledgeVersion.knowledge_id == knowledge_id)
|
|
||||||
.order_by(KnowledgeVersion.version_no.desc())
|
|
||||||
).all()
|
|
||||||
return api_success([_version_dict(item) for item in rows])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/knowledge/version/{version_id}/content")
|
@router.get("/knowledge/version/{version_id}/content")
|
||||||
def version_content(
|
def version_content(
|
||||||
version_id: int,
|
version_id: int,
|
||||||
@@ -218,29 +201,6 @@ async def synchronize(
|
|||||||
return api_success(_job_dict(job))
|
return api_success(_job_dict(job))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/knowledge/{knowledge_id}/version/{version_id}/rollback")
|
|
||||||
def rollback(
|
|
||||||
knowledge_id: int,
|
|
||||||
version_id: int,
|
|
||||||
payload: KnowledgeRollbackRequest,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_admin: Admin = Depends(get_current_admin),
|
|
||||||
) -> dict:
|
|
||||||
_require_super_admin(current_admin)
|
|
||||||
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
|
||||||
if knowledge.current_version_id == version.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="目标版本已是当前正式版本")
|
|
||||||
if payload.keepOpen and knowledge.lifecycle_status != "active":
|
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能在回滚后开放")
|
|
||||||
try:
|
|
||||||
KnowledgePipelineService.rollback(db, knowledge, version, current_admin.id, payload.keepOpen)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
|
||||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="rollback", target_id=knowledge.id)
|
|
||||||
db.commit()
|
|
||||||
return api_success(_knowledge_detail(knowledge))
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/knowledge/{knowledge_id}/open-status")
|
@router.put("/knowledge/{knowledge_id}/open-status")
|
||||||
def open_status(
|
def open_status(
|
||||||
knowledge_id: int,
|
knowledge_id: int,
|
||||||
@@ -340,14 +300,6 @@ def _version(db: Session, version_id: int) -> KnowledgeVersion:
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
def _knowledge_version(db: Session, knowledge_id: int, version_id: int) -> tuple[Knowledge, KnowledgeVersion]:
|
|
||||||
knowledge = _knowledge(db, knowledge_id)
|
|
||||||
version = _version(db, version_id)
|
|
||||||
if version.knowledge_id != knowledge.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="版本不属于该知识库")
|
|
||||||
return knowledge, version
|
|
||||||
|
|
||||||
|
|
||||||
def _knowledge_detail(item: Knowledge) -> dict:
|
def _knowledge_detail(item: Knowledge) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": item.id,
|
"id": item.id,
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ from __future__ import annotations
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeRollbackRequest(BaseModel):
|
|
||||||
keepOpen: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeStatusRequest(BaseModel):
|
class KnowledgeStatusRequest(BaseModel):
|
||||||
open: bool
|
open: bool
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import re
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.knowledge import (
|
from app.models.knowledge import (
|
||||||
@@ -15,6 +15,7 @@ from app.models.knowledge import (
|
|||||||
KnowledgeChunk,
|
KnowledgeChunk,
|
||||||
KnowledgeManifest,
|
KnowledgeManifest,
|
||||||
KnowledgePublishLog,
|
KnowledgePublishLog,
|
||||||
|
KnowledgeReviewRecord,
|
||||||
KnowledgeSection,
|
KnowledgeSection,
|
||||||
KnowledgeSourceSnapshot,
|
KnowledgeSourceSnapshot,
|
||||||
KnowledgeSyncJob,
|
KnowledgeSyncJob,
|
||||||
@@ -24,7 +25,7 @@ from app.services.external_errors import ExternalServiceError
|
|||||||
from app.services.feishu_async_service import AsyncFeishuKnowledgeService
|
from app.services.feishu_async_service import AsyncFeishuKnowledgeService
|
||||||
from app.services.feishu_service import _feishu_retrieval_config
|
from app.services.feishu_service import _feishu_retrieval_config
|
||||||
|
|
||||||
PROCESSING_RULE_VERSION = "knowledge-pipeline-v2-auto-publish"
|
PROCESSING_RULE_VERSION = "knowledge-pipeline-v3-latest-only"
|
||||||
MAX_SECTION_CHARS = 2800
|
MAX_SECTION_CHARS = 2800
|
||||||
MAX_CHUNK_CHARS = 650
|
MAX_CHUNK_CHARS = 650
|
||||||
|
|
||||||
@@ -114,6 +115,8 @@ class KnowledgePipelineService:
|
|||||||
else:
|
else:
|
||||||
job.status = "unchanged"
|
job.status = "unchanged"
|
||||||
job.result_summary = json.dumps({"changed": False}, ensure_ascii=False)
|
job.result_summary = json.dumps({"changed": False}, ensure_ascii=False)
|
||||||
|
if latest_version is not None:
|
||||||
|
cls._prune_old_versions(db, knowledge, latest_version)
|
||||||
job.stage = "completed"
|
job.stage = "completed"
|
||||||
job.progress = 100
|
job.progress = 100
|
||||||
job.finished_at = _now()
|
job.finished_at = _now()
|
||||||
@@ -305,7 +308,7 @@ class KnowledgePipelineService:
|
|||||||
keep_open: bool,
|
keep_open: bool,
|
||||||
action: str,
|
action: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Atomically activate a validated version and preserve rollback history."""
|
"""Atomically replace the active version with validated latest content."""
|
||||||
if version.quality_status == "blocked":
|
if version.quality_status == "blocked":
|
||||||
raise ValueError("质检未通过的版本不能生效")
|
raise ValueError("质检未通过的版本不能生效")
|
||||||
if keep_open and knowledge.lifecycle_status != "active":
|
if keep_open and knowledge.lifecycle_status != "active":
|
||||||
@@ -313,17 +316,6 @@ class KnowledgePipelineService:
|
|||||||
if keep_open and knowledge.source_status != "normal":
|
if keep_open and knowledge.source_status != "normal":
|
||||||
raise ValueError("飞书源文章异常,不能替换正式版本")
|
raise ValueError("飞书源文章异常,不能替换正式版本")
|
||||||
previous_version_id = knowledge.current_version_id
|
previous_version_id = knowledge.current_version_id
|
||||||
if previous_version_id:
|
|
||||||
previous = db.get(KnowledgeVersion, previous_version_id)
|
|
||||||
if previous and previous.id != version.id:
|
|
||||||
previous.status = "superseded"
|
|
||||||
db.add(previous)
|
|
||||||
previous_pending_id = knowledge.pending_version_id
|
|
||||||
if previous_pending_id and previous_pending_id not in {previous_version_id, version.id}:
|
|
||||||
previous_pending = db.get(KnowledgeVersion, previous_pending_id)
|
|
||||||
if previous_pending and previous_pending.status not in {"published", "superseded"}:
|
|
||||||
previous_pending.status = "discarded"
|
|
||||||
db.add(previous_pending)
|
|
||||||
version.status = "published"
|
version.status = "published"
|
||||||
version.published_by = admin_id
|
version.published_by = admin_id
|
||||||
version.published_at = _now()
|
version.published_at = _now()
|
||||||
@@ -347,34 +339,36 @@ class KnowledgePipelineService:
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
db.flush()
|
||||||
|
KnowledgePipelineService._prune_old_versions(db, knowledge, version)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def rollback(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int, keep_open: bool) -> None:
|
def _prune_old_versions(db: Session, knowledge: Knowledge, keep: KnowledgeVersion) -> None:
|
||||||
if version.status not in {"published", "superseded"}:
|
"""Keep only the active version and its derived retrieval data."""
|
||||||
raise ValueError("只能回滚到历史正式版本")
|
old_version_ids = list(db.scalars(
|
||||||
previous_id = knowledge.current_version_id
|
select(KnowledgeVersion.id).where(
|
||||||
if previous_id:
|
KnowledgeVersion.knowledge_id == knowledge.id,
|
||||||
previous = db.get(KnowledgeVersion, previous_id)
|
KnowledgeVersion.id != keep.id,
|
||||||
if previous:
|
)
|
||||||
previous.status = "superseded"
|
).all())
|
||||||
db.add(previous)
|
if old_version_ids:
|
||||||
version.status = "published"
|
for model in (
|
||||||
knowledge.current_version_id = version.id
|
KnowledgeReviewRecord,
|
||||||
knowledge.status = 1 if keep_open else 0
|
KnowledgeCard,
|
||||||
knowledge.last_published_at = _now()
|
KnowledgeChunk,
|
||||||
db.add_all(
|
KnowledgeSection,
|
||||||
[
|
KnowledgeManifest,
|
||||||
knowledge,
|
):
|
||||||
version,
|
db.execute(delete(model).where(model.version_id.in_(old_version_ids)))
|
||||||
KnowledgePublishLog(
|
db.execute(delete(KnowledgeVersion).where(KnowledgeVersion.id.in_(old_version_ids)))
|
||||||
knowledge_id=knowledge.id,
|
|
||||||
from_version_id=previous_id,
|
# Snapshots are source copies for a version. Once that version is gone,
|
||||||
to_version_id=version.id,
|
# retaining the old snapshot would still be retaining knowledge history.
|
||||||
action="rollback",
|
db.execute(
|
||||||
keep_open=knowledge.status,
|
delete(KnowledgeSourceSnapshot).where(
|
||||||
operated_by=admin_id,
|
KnowledgeSourceSnapshot.knowledge_id == knowledge.id,
|
||||||
),
|
KnowledgeSourceSnapshot.id != keep.snapshot_id,
|
||||||
]
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -128,29 +128,7 @@ def test_batch_metadata_update_reports_missing_items():
|
|||||||
assert db.scalar(select(Knowledge).where(Knowledge.id == item.id)).knowledge_type == "qa"
|
assert db.scalar(select(Knowledge).where(Knowledge.id == item.id)).knowledge_type == "qa"
|
||||||
|
|
||||||
|
|
||||||
def test_rollback_switches_published_version_statuses():
|
def test_sync_automatically_replaces_current_version_and_removes_history():
|
||||||
with _database() as db:
|
|
||||||
item = _knowledge(db)
|
|
||||||
old_version = db.get(KnowledgeVersion, item.current_version_id)
|
|
||||||
snapshot = db.scalar(select(KnowledgeSourceSnapshot).where(KnowledgeSourceSnapshot.knowledge_id == item.id))
|
|
||||||
target = KnowledgeVersion(
|
|
||||||
knowledge_id=item.id,
|
|
||||||
snapshot_id=snapshot.id,
|
|
||||||
version_no=0,
|
|
||||||
status="superseded",
|
|
||||||
quality_status="passed",
|
|
||||||
processing_rule_version="test-v1",
|
|
||||||
)
|
|
||||||
db.add(target)
|
|
||||||
db.flush()
|
|
||||||
KnowledgePipelineService.rollback(db, item, target, admin_id=1, keep_open=True)
|
|
||||||
db.commit()
|
|
||||||
assert item.current_version_id == target.id
|
|
||||||
assert target.status == "published"
|
|
||||||
assert old_version.status == "superseded"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_automatically_replaces_current_version_and_keeps_rollback_history():
|
|
||||||
source_content = (
|
source_content = (
|
||||||
"# 一、课程说明\n这是更新后的课程内容,同步成功后应当立即生效。\n\n"
|
"# 一、课程说明\n这是更新后的课程内容,同步成功后应当立即生效。\n\n"
|
||||||
"# 二、练习一:静水流深静心\n选择舒适安全的姿势,觉察呼吸和身体,不适时立即停止。"
|
"# 二、练习一:静水流深静心\n选择舒适安全的姿势,觉察呼吸和身体,不适时立即停止。"
|
||||||
@@ -174,7 +152,12 @@ def test_sync_automatically_replaces_current_version_and_keeps_rollback_history(
|
|||||||
|
|
||||||
db.refresh(item)
|
db.refresh(item)
|
||||||
current = db.get(KnowledgeVersion, item.current_version_id)
|
current = db.get(KnowledgeVersion, item.current_version_id)
|
||||||
previous = db.get(KnowledgeVersion, previous_version_id)
|
versions = db.scalars(
|
||||||
|
select(KnowledgeVersion).where(KnowledgeVersion.knowledge_id == item.id)
|
||||||
|
).all()
|
||||||
|
snapshots = db.scalars(
|
||||||
|
select(KnowledgeSourceSnapshot).where(KnowledgeSourceSnapshot.knowledge_id == item.id)
|
||||||
|
).all()
|
||||||
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == current.id))
|
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == current.id))
|
||||||
cards = db.scalars(select(KnowledgeCard).where(KnowledgeCard.version_id == current.id)).all()
|
cards = db.scalars(select(KnowledgeCard).where(KnowledgeCard.version_id == current.id)).all()
|
||||||
publish_log = db.scalar(
|
publish_log = db.scalar(
|
||||||
@@ -185,7 +168,9 @@ def test_sync_automatically_replaces_current_version_and_keeps_rollback_history(
|
|||||||
assert job.mode == "replace"
|
assert job.mode == "replace"
|
||||||
assert current.status == "published"
|
assert current.status == "published"
|
||||||
assert current.quality_status == "passed"
|
assert current.quality_status == "passed"
|
||||||
assert previous.status == "superseded"
|
assert db.get(KnowledgeVersion, previous_version_id) is None
|
||||||
|
assert [version.id for version in versions] == [current.id]
|
||||||
|
assert [snapshot.id for snapshot in snapshots] == [current.snapshot_id]
|
||||||
assert item.pending_version_id is None
|
assert item.pending_version_id is None
|
||||||
assert item.status == 1
|
assert item.status == 1
|
||||||
assert item.migration_status == "completed"
|
assert item.migration_status == "completed"
|
||||||
|
|||||||
Reference in New Issue
Block a user