fix: complete admin knowledge audit improvements

This commit is contained in:
2026-07-13 11:40:34 +08:00
parent 82e0c02bac
commit 120fe185f9
16 changed files with 502 additions and 41 deletions

View File

@@ -4,6 +4,7 @@ import { computed, onMounted, reactive, ref } from "vue";
import AdminLoginView from "./components/AdminLoginView.vue";
import KnowledgeManagementView from "./components/KnowledgeManagementView.vue";
import TableRowActions from "./components/TableRowActions.vue";
import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings";
import { api, clearToken, getToken, saveToken } from "./services/api";
import type {
@@ -27,6 +28,11 @@ const activeMenu = ref("dashboard");
const loading = ref(false);
const stats = ref<DashboardStats | null>(null);
const storageStats = ref<Record<string, any> | null>(null);
const storageLoading = ref(false);
const cleanupBefore = ref("");
const retentionDays = ref<number | null>(90);
const retentionState = ref<{ lastRunAt: string | null; lastResult: string | null } | null>(null);
const dashboardFilters = reactive({
start: "",
end: "",
@@ -249,8 +255,15 @@ async function previewKnowledge(knowledgeId: number) {
async function loadDashboard() {
stats.value = await api.dashboard(dashboardFilters.start, dashboardFilters.end);
storageLoading.value = true;
api.storageStats().then((value) => { storageStats.value = value; }).catch(() => { storageStats.value = null; }).finally(() => { storageLoading.value = false; });
}
async function refreshStorage() { storageLoading.value = true; try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); } finally { storageLoading.value = false; } }
function formatBytes(value: unknown) { if (typeof value !== "number") return "无法统计"; const units = ["B", "KB", "MB", "GB", "TB"]; let size = value, i = 0; while (size >= 1024 && i < units.length - 1) { size /= 1024; i += 1; } return `${size.toFixed(i ? 2 : 0)} ${units[i]}`; }
async function cleanupRetrievalLogs() { if (!cleanupBefore.value) return ElMessage.warning("请选择清理日期"); const estimate = await api.estimateRetrievalCleanup(cleanupBefore.value); await ElMessageBox.confirm(`预计清理 ${estimate.estimatedCount} 条检索日志及关联明细,操作日志不会被删除。`, "确认清理", { type: "warning" }); const result = await api.cleanupRetrievalLogs(cleanupBefore.value); ElMessage.success(`已清理 ${result.deleted} 条日志`); await loadCurrentMenu(); }
async function saveRetention() { await api.saveRetrievalRetention(retentionDays.value); ElMessage.success(retentionDays.value == null ? "已设为永久保留" : `已设为保留 ${retentionDays.value}`); await loadCurrentMenu(); }
async function loadCurrentMenu() {
loading.value = true;
try {
@@ -262,6 +275,7 @@ async function loadCurrentMenu() {
promptContent.value = prompt.promptContent;
models.value = modelRows;
knowledge.value = knowledgeRows;
agentForm.knowledgeIds = knowledgeRows.filter((item) => item.status === 1 && item.lifecycleStatus === "active").map((item) => item.id);
if (!agentForm.modelId && modelRows.length) {
agentForm.modelId = modelRows.find((model) => model.enabled === 1)?.id ?? modelRows[0].id;
}
@@ -276,7 +290,7 @@ async function loadCurrentMenu() {
aiLogs.value = await api.aiLogs();
operationLogs.value = await api.operationLogs();
}
if (activeMenu.value === "retrievals") retrievalLogs.value = await api.retrievalLogs();
if (activeMenu.value === "retrievals") { const [logs, retention] = await Promise.all([api.retrievalLogs(), api.retrievalRetention()]); retrievalLogs.value = logs; retentionDays.value = retention.retentionDays; retentionState.value = retention; }
if (activeMenu.value === "attention") attentionRecords.value = await api.attentionList();
} finally {
loading.value = false;
@@ -937,6 +951,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</div>
</div>
</section>
<section class="storage-panel" v-loading="storageLoading"><div class="storage-panel-head"><div><h3>项目存储占用</h3><p>数据库、Redis 与附件分别统计;无权限或不支持时显示“无法统计”。</p></div><el-button :loading="storageLoading" @click="refreshStorage">手动刷新</el-button></div><div class="storage-grid"><div><span>项目总量</span><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong></div><div><span>数据库</span><strong>{{ formatBytes(storageStats?.detail?.database?.bytes) }}</strong></div><div><span>Redis</span><strong>{{ formatBytes(storageStats?.detail?.redis?.bytes) }}</strong></div><div><span>附件/文件</span><strong>{{ formatBytes(storageStats?.detail?.files?.bytes) }}</strong></div><div><span>近7天增长</span><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong></div><div><span>近30天增长</span><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong></div></div><small>最后统计{{ storageStats?.createdAt || '尚未统计' }}</small></section>
<el-table :data="users" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="phone" label="手机号" width="150" />
@@ -986,8 +1001,8 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</el-select>
</el-form-item>
<el-form-item label="调试知识库">
<el-select v-model="agentForm.knowledgeIds" multiple collapse-tags placeholder="默认使用全部启用知识库">
<el-option v-for="item in knowledge" :key="item.id" :label="item.name" :value="item.id" />
<el-select v-model="agentForm.knowledgeIds" class="debug-knowledge-select" multiple filterable collapse-tags collapse-tags-tooltip :max-collapse-tags="2" popper-class="debug-knowledge-popper" placeholder="默认使用全部已开放知识库">
<el-option v-for="item in knowledge" :key="item.id" :label="`${item.name}${item.status === 0 ? ' · 已关闭(仅本次预览)' : ''}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="Temperature">
@@ -1067,10 +1082,12 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</div>
<div class="agent-trace-list">
<el-empty v-if="agentDebugTrace.length === 0" description="发送问题后查看目录、检索与章节回读" :image-size="64" />
<article v-for="(step, index) in agentDebugTrace" :key="index">
<strong>{{ index + 1 }}. {{ step.tool }}</strong>
<pre>{{ JSON.stringify(step, null, 2) }}</pre>
</article>
<details v-for="(step, index) in agentDebugTrace" :key="index" class="tool-trace-node">
<summary><strong>{{ index + 1 }}. {{ step.tool }}</strong><el-tag size="small" :type="step.status === 'failed' ? 'danger' : 'success'">{{ step.status || '完成' }}</el-tag><span>{{ step.durationMs ?? 0 }} ms</span></summary>
<h5>请求</h5><pre>{{ JSON.stringify(step.request ?? {}, null, 2) }}</pre>
<h5>响应</h5><pre>{{ step.response == null ? '返回为空' : JSON.stringify(step.response, null, 2) }}</pre>
<el-alert v-if="step.error" :title="step.error" type="error" :closable="false" />
</details>
</div>
</aside>
</section>
@@ -1180,12 +1197,9 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-table-column prop="baseUrl" label="Base URL" min-width="220" show-overflow-tooltip />
<el-table-column prop="authType" label="鉴权" width="110" />
<el-table-column prop="enabled" label="启用" width="90" />
<el-table-column label="操作" width="210" fixed="right">
<el-table-column label="操作" width="190" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" @click="editModel(row)">编辑</el-button>
<el-button size="small" @click="testModel(row.id)">测试</el-button>
<el-button size="small" type="primary" @click="enableModel(row.id)">启用</el-button>
<el-button size="small" type="danger" @click="deleteModel(row.id)">删除</el-button>
<TableRowActions><el-button size="small" @click="editModel(row)">编辑</el-button><el-button size="small" @click="testModel(row.id)">测试</el-button><el-dropdown trigger="click" teleported @command="(command: string) => command === 'enable' ? enableModel(row.id) : deleteModel(row.id)"><el-button size="small">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="enable">启用</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>
@@ -1232,7 +1246,8 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</template>
<template v-if="activeMenu === 'retrievals'">
<div class="page-head"><h2>检索日志</h2><p>查看每条回答使用的知识目录Agent 决策搜索候选重排和章节回读</p></div>
<div class="page-head inline"><div><h2>检索日志</h2><p>查看每条回答使用的知识目录Agent 决策搜索候选重排和章节回读</p></div><div class="cleanup-controls"><el-date-picker v-model="cleanupBefore" value-format="YYYY-MM-DD" type="date" placeholder="清理此日期前" /><el-button type="danger" plain @click="cleanupRetrievalLogs">清理日志</el-button></div></div>
<section class="retention-panel"><div><strong>定期清理</strong><span>每日自动分批删除过期检索日志和关联候选明细</span></div><el-select v-model="retentionDays" placeholder="保留时间"><el-option label="永久保留" :value="null" /><el-option label="30天" :value="30" /><el-option label="90天" :value="90" /><el-option label="180天" :value="180" /></el-select><el-button type="primary" @click="saveRetention">保存规则</el-button><small>最近执行{{ retentionState?.lastRunAt || '尚未执行' }}</small></section>
<el-table :data="retrievalLogs" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="sessionId" label="会话" width="90" />

View File

@@ -3,6 +3,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import TableRowActions from "./TableRowActions.vue";
import type {
KnowledgeDetail,
KnowledgeItem,
@@ -29,6 +30,9 @@ const editOpen = ref(false);
const editSaving = ref(false);
const batchOpen = ref(false);
const batchSaving = ref(false);
const resolvingNode = ref(false);
const nodeResolved = ref(false);
const sourceTitle = ref("");
const filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" });
const form = reactive({
name: "",
@@ -159,13 +163,14 @@ async function restore(item: KnowledgeItem) {
}
async function createKnowledge() {
if (!form.name || !form.feishuSpaceId || !form.feishuNodeId) {
ElMessage.warning("请填写名称、SpaceID 和 NodeID");
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", reviewMode: "manual", status: 0, remark: "" });
nodeResolved.value = false; sourceTitle.value = "";
ElMessage.success("知识库已新增,请进行首次同步");
await load();
} catch (error) {
@@ -173,6 +178,21 @@ async function createKnowledge() {
}
}
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) {
detailOpen.value = true;
activeTab.value = "overview";
@@ -387,10 +407,15 @@ async function confirmAction(
</section>
<section class="knowledge-work-panel">
<h3>新增知识库</h3>
<div class="knowledge-create-grid">
<el-input v-model="form.feishuSpaceId" placeholder="SpaceID" />
<el-input v-model="form.feishuNodeId" placeholder="NodeID" />
<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 label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" />
@@ -398,6 +423,7 @@ async function confirmAction(
<el-select v-model="form.reviewMode">
<el-option label="必须人工审核" value="manual" /><el-option label="允许自动发布" value="automatic" />
</el-select>
<el-input v-model="form.remark" placeholder="备注(可修改)" />
<el-button type="primary" @click="createKnowledge">保存并准备同步</el-button>
</div>
</section>
@@ -422,7 +448,7 @@ async function confirmAction(
<el-table-column label="版本" width="130"><template #default="{ row }"><span>正式 {{ row.currentVersionId ? `#${row.currentVersionId}` : '无' }}</span><small class="table-subline">待发布 {{ row.pendingVersionId ? `#${row.pendingVersionId}` : '无' }}</small></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 label="操作" width="220" fixed="right"><template #default="{ row }"><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, 'review')">同步</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></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, 'review')">同步</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>
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>

View File

@@ -0,0 +1,3 @@
<template>
<div class="table-row-actions"><slot /></div>
</template>

View File

@@ -116,6 +116,7 @@ export const api = {
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
knowledge: () => request<KnowledgeItem[]>("/admin/knowledge/list"),
resolveKnowledgeNode: (nodeId: string) => request<{ nodeId: string; spaceId: string; sourceTitle: string; name: string; remark: string }>("/admin/knowledge/resolve-node", { method: "POST", body: JSON.stringify({ nodeId }) }),
createKnowledge: (payload: Record<string, unknown>) =>
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
@@ -182,6 +183,11 @@ export const api = {
aiLogDetail: (id: number) => request<AiLogRecord>(`/admin/ai-log/${id}`),
operationLogs: () => request<Record<string, unknown>[]>("/admin/log/list"),
retrievalLogs: () => request<RetrievalLogItem[]>("/admin/retrieval-log/list"),
estimateRetrievalCleanup: (before: string) => request<{ before: string; estimatedCount: number }>("/admin/retrieval-log/cleanup/estimate", { method: "POST", body: JSON.stringify({ before }) }),
cleanupRetrievalLogs: (before: string) => request<{ before: string; deleted: number }>("/admin/retrieval-log/cleanup", { method: "POST", body: JSON.stringify({ before }) }),
retrievalRetention: () => request<{ enabled: boolean; retentionDays: number | null; lastRunAt: string | null; lastResult: string | null }>("/admin/retrieval-log/retention"),
saveRetrievalRetention: (retentionDays: number | null) => request<{ enabled: boolean; retentionDays: number | null }>("/admin/retrieval-log/retention", { method: "PUT", body: JSON.stringify({ retentionDays }) }),
storageStats: (refresh = false) => request<Record<string, any>>(`/admin/dashboard/storage${refresh ? "?refresh=true" : ""}`),
retrievalLogDetail: (id: number) => request<Record<string, unknown>>(`/admin/retrieval-log/${id}`),
attentionList: () => request<AttentionItem[]>("/admin/attention/list"),
updateAttention: (id: number, status: string, note?: string) =>

View File

@@ -275,6 +275,16 @@ textarea {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.storage-panel { margin-top: 18px; padding: 18px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; }
.storage-panel-head { display: flex; justify-content: space-between; align-items: start; gap: 16px; }
.storage-panel-head h3, .storage-panel-head p { margin: 0 0 6px; }
.storage-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
.storage-grid > div { padding: 14px; background: #f6f8f7; border-radius: 8px; }
.storage-grid span, .storage-grid strong { display: block; }
.storage-grid strong { margin-top: 8px; font-size: 20px; }
.cleanup-controls { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.retention-panel { display: grid; grid-template-columns: minmax(260px, 1fr) 180px auto auto; align-items: center; gap: 12px; padding: 14px 16px; margin-bottom: 14px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; }
.retention-panel div span { display: block; margin-top: 4px; color: #667a73; font-size: 12px; }
.stat {
padding: 18px;
@@ -393,6 +403,9 @@ textarea {
gap: 8px;
}
.node-resolve-row { display: grid; grid-template-columns: minmax(280px, 1fr) auto; gap: 12px; margin-bottom: 12px; }
.node-resolve-row .el-button { min-width: 170px; }
.knowledge-filters {
display: grid;
grid-template-columns: minmax(240px, 1.6fr) repeat(4, minmax(130px, 0.8fr));
@@ -699,6 +712,19 @@ textarea {
width: 100%;
}
.debug-knowledge-select { width: 100%; min-width: 0; }
.debug-knowledge-select .el-select__wrapper { min-height: 40px; max-height: 76px; overflow-y: auto; }
.debug-knowledge-popper { max-width: min(620px, 90vw); z-index: 4000 !important; }
.debug-knowledge-popper .el-select-dropdown__item { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.table-row-actions { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; white-space: nowrap; }
.table-row-actions .el-button + .el-button { margin-left: 0; }
.table-row-actions .el-button { min-width: 48px; height: 32px; white-space: nowrap; }
.tool-trace-node { border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; overflow: hidden; }
.tool-trace-node summary { cursor: pointer; display: flex; align-items: center; gap: 10px; padding: 12px; list-style: none; }
.tool-trace-node summary span { margin-left: auto; color: #667a73; font-size: 12px; }
.tool-trace-node h5 { margin: 10px 12px 4px; }
.tool-trace-node pre { max-height: 320px; overflow: auto; }
.agent-preview-head {
display: flex;
align-items: center;