perf: paginate admin audit lists

This commit is contained in:
2026-07-13 13:09:15 +08:00
parent 0864f9305b
commit 00d53c2569
14 changed files with 300 additions and 80 deletions

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import AdminLoginView from "./components/AdminLoginView.vue"; import AdminLoginView from "./components/AdminLoginView.vue";
import AdminPagination from "./components/AdminPagination.vue";
import KnowledgeManagementView from "./components/KnowledgeManagementView.vue"; import KnowledgeManagementView from "./components/KnowledgeManagementView.vue";
import TableRowActions from "./components/TableRowActions.vue"; import TableRowActions from "./components/TableRowActions.vue";
import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings"; import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings";
@@ -55,6 +56,14 @@ const chatDetailOpen = ref(false);
const selectedAiLog = ref<AiLogRecord | null>(null); const selectedAiLog = ref<AiLogRecord | null>(null);
const aiLogDetailOpen = ref(false); const aiLogDetailOpen = ref(false);
const recordTab = ref("chats"); const recordTab = ref("chats");
const pagers = reactive({
users: { page: 1, pageSize: 20, total: 0 },
chats: { page: 1, pageSize: 20, total: 0 },
aiLogs: { page: 1, pageSize: 20, total: 0 },
operationLogs: { page: 1, pageSize: 20, total: 0 },
retrievals: { page: 1, pageSize: 20, total: 0 },
attention: { page: 1, pageSize: 20, total: 0 },
});
const editingModelId = ref<number | null>(null); const editingModelId = ref<number | null>(null);
const editingKnowledgeId = ref<number | null>(null); const editingKnowledgeId = ref<number | null>(null);
const batchNodeToken = ref(""); const batchNodeToken = ref("");
@@ -210,6 +219,10 @@ const agentForm = reactive({
question: "", question: "",
}); });
watch(recordTab, async (tab) => {
if (activeMenu.value === "records") await loadRecordTab(tab);
});
onMounted(async () => { onMounted(async () => {
if (!getToken()) return; if (!getToken()) return;
try { try {
@@ -268,10 +281,9 @@ async function loadCurrentMenu() {
loading.value = true; loading.value = true;
try { try {
if (activeMenu.value === "dashboard") await loadDashboard(); if (activeMenu.value === "dashboard") await loadDashboard();
if (activeMenu.value === "users") users.value = await api.users(userKeyword.value); if (activeMenu.value === "users") await loadUsers(pagers.users.page, pagers.users.pageSize);
if (activeMenu.value === "knowledge") knowledge.value = await api.knowledge();
if (activeMenu.value === "prompt") { if (activeMenu.value === "prompt") {
const [prompt, modelRows, knowledgeRows] = await Promise.all([api.prompt(), api.models(), api.knowledge()]); const [prompt, modelRows, knowledgeRows] = await Promise.all([api.prompt(), api.models(), api.knowledgeOptions()]);
promptContent.value = prompt.promptContent; promptContent.value = prompt.promptContent;
models.value = modelRows; models.value = modelRows;
knowledge.value = knowledgeRows; knowledge.value = knowledgeRows;
@@ -285,18 +297,51 @@ async function loadCurrentMenu() {
configs.value = await api.configs(); configs.value = await api.configs();
hydrateSystemSettings(); hydrateSystemSettings();
} }
if (activeMenu.value === "records") { if (activeMenu.value === "records") await loadRecordTab(recordTab.value);
chats.value = await api.chats(buildChatQuery()); if (activeMenu.value === "retrievals") { const [logs, retention] = await Promise.all([api.retrievalLogs({ page: pagers.retrievals.page, pageSize: pagers.retrievals.pageSize }), api.retrievalRetention()]); retrievalLogs.value = logs.items; Object.assign(pagers.retrievals, { page: logs.page, pageSize: logs.pageSize, total: logs.total }); retentionDays.value = retention.retentionDays; retentionState.value = retention; }
aiLogs.value = await api.aiLogs(); if (activeMenu.value === "attention") await loadAttention(pagers.attention.page, pagers.attention.pageSize);
operationLogs.value = await api.operationLogs();
}
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 { } finally {
loading.value = false; loading.value = false;
} }
} }
async function loadUsers(page = 1, pageSize = pagers.users.pageSize) {
const result = await api.users({ keyword: userKeyword.value, page, pageSize });
users.value = result.items;
Object.assign(pagers.users, { page: result.page, pageSize: result.pageSize, total: result.total });
}
async function loadRecordTab(tab = recordTab.value) {
loading.value = true;
try {
if (tab === "chats") {
const result = await api.chats({ ...buildChatQuery(), page: pagers.chats.page, pageSize: pagers.chats.pageSize });
chats.value = result.items; Object.assign(pagers.chats, { page: result.page, pageSize: result.pageSize, total: result.total });
} else if (tab === "aiLogs") {
const result = await api.aiLogs({ page: pagers.aiLogs.page, pageSize: pagers.aiLogs.pageSize });
aiLogs.value = result.items; Object.assign(pagers.aiLogs, { page: result.page, pageSize: result.pageSize, total: result.total });
} else {
const result = await api.operationLogs({ page: pagers.operationLogs.page, pageSize: pagers.operationLogs.pageSize });
operationLogs.value = result.items; Object.assign(pagers.operationLogs, { page: result.page, pageSize: result.pageSize, total: result.total });
}
} finally { loading.value = false; }
}
async function changeRecordPage(tab: "chats" | "aiLogs" | "operationLogs", page: number, pageSize: number) {
Object.assign(pagers[tab], { page, pageSize });
await loadRecordTab(tab);
}
async function loadRetrievals(page = 1, pageSize = pagers.retrievals.pageSize) {
const result = await api.retrievalLogs({ page, pageSize });
retrievalLogs.value = result.items; Object.assign(pagers.retrievals, { page: result.page, pageSize: result.pageSize, total: result.total });
}
async function loadAttention(page = 1, pageSize = pagers.attention.pageSize) {
const result = await api.attentionList({ page, pageSize });
attentionRecords.value = result.items; Object.assign(pagers.attention, { page: result.page, pageSize: result.pageSize, total: result.total });
}
async function saveUser(row: AdminUser) { async function saveUser(row: AdminUser) {
await api.updateUser(row.id, { status: row.status, dailyChatLimit: row.dailyChatLimit }); await api.updateUser(row.id, { status: row.status, dailyChatLimit: row.dailyChatLimit });
ElMessage.success("用户已更新"); ElMessage.success("用户已更新");
@@ -766,7 +811,7 @@ async function openRetrievalDetail(row: RetrievalLogItem) {
async function updateAttentionStatus(row: AttentionItem, status: string) { async function updateAttentionStatus(row: AttentionItem, status: string) {
await api.updateAttention(row.id, status); await api.updateAttention(row.id, status);
ElMessage.success("人工关注状态已更新"); ElMessage.success("人工关注状态已更新");
attentionRecords.value = await api.attentionList(); await loadAttention(pagers.attention.page, pagers.attention.pageSize);
} }
async function deleteAttentionRecord(row: AttentionItem) { async function deleteAttentionRecord(row: AttentionItem) {
@@ -779,7 +824,7 @@ async function deleteAttentionRecord(row: AttentionItem) {
} }
await api.deleteAttention(row.id); await api.deleteAttention(row.id);
ElMessage.success("人工关注记录已删除"); ElMessage.success("人工关注记录已删除");
attentionRecords.value = await api.attentionList(); await loadAttention(pagers.attention.page, pagers.attention.pageSize);
} }
function attentionPriorityLabel(priority: string) { function attentionPriorityLabel(priority: string) {
@@ -796,7 +841,8 @@ async function clearFeishuCache() {
} }
async function searchChats() { async function searchChats() {
chats.value = await api.chats(buildChatQuery()); pagers.chats.page = 1;
await loadRecordTab("chats");
} }
async function resetChatFilters() { async function resetChatFilters() {
@@ -900,7 +946,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<template v-if="activeMenu === 'users'"> <template v-if="activeMenu === 'users'">
<div class="page-head inline"> <div class="page-head inline">
<div><h2>用户管理</h2><p>维护学员名单只有名单内启用学员可以登录用户端</p></div> <div><h2>用户管理</h2><p>维护学员名单只有名单内启用学员可以登录用户端</p></div>
<el-input v-model="userKeyword" placeholder="手机号或姓名" clearable @change="loadCurrentMenu" /> <el-input v-model="userKeyword" placeholder="手机号或姓名" clearable @change="loadUsers(1)" />
</div> </div>
<section class="student-tools"> <section class="student-tools">
<div class="student-tool-panel"> <div class="student-tool-panel">
@@ -972,6 +1018,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<AdminPagination :page="pagers.users.page" :page-size="pagers.users.pageSize" :total="pagers.users.total" @change="loadUsers" />
</template> </template>
<template v-if="activeMenu === 'knowledge'"> <template v-if="activeMenu === 'knowledge'">
@@ -1260,6 +1307,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-table-column prop="createdAt" label="时间" width="180" /> <el-table-column prop="createdAt" label="时间" width="180" />
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openRetrievalDetail(row)">详情</el-button></template></el-table-column> <el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openRetrievalDetail(row)">详情</el-button></template></el-table-column>
</el-table> </el-table>
<AdminPagination :page="pagers.retrievals.page" :page-size="pagers.retrievals.pageSize" :total="pagers.retrievals.total" @change="loadRetrievals" />
</template> </template>
<template v-if="activeMenu === 'attention'"> <template v-if="activeMenu === 'attention'">
@@ -1274,6 +1322,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-table-column prop="createdAt" label="创建时间" width="180" /> <el-table-column prop="createdAt" label="创建时间" width="180" />
<el-table-column label="操作" width="130" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openChatSession(row.sessionId)">原会话</el-button><el-button link type="danger" @click="deleteAttentionRecord(row)">删除</el-button></template></el-table-column> <el-table-column label="操作" width="130" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openChatSession(row.sessionId)">原会话</el-button><el-button link type="danger" @click="deleteAttentionRecord(row)">删除</el-button></template></el-table-column>
</el-table> </el-table>
<AdminPagination :page="pagers.attention.page" :page-size="pagers.attention.pageSize" :total="pagers.attention.total" @change="loadAttention" />
</template> </template>
<template v-if="activeMenu === 'records'"> <template v-if="activeMenu === 'records'">
@@ -1307,7 +1356,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-button @click="exportChats">导出</el-button> <el-button @click="exportChats">导出</el-button>
</div> </div>
</div> </div>
<el-table :data="chats" stripe> <el-table v-loading="loading" :data="chats" stripe>
<el-table-column prop="id" label="会话ID" width="90" /> <el-table-column prop="id" label="会话ID" width="90" />
<el-table-column prop="userPhone" label="手机号" width="140" /> <el-table-column prop="userPhone" label="手机号" width="140" />
<el-table-column prop="userName" label="用户" width="120" /> <el-table-column prop="userName" label="用户" width="120" />
@@ -1320,9 +1369,10 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<AdminPagination :page="pagers.chats.page" :page-size="pagers.chats.pageSize" :total="pagers.chats.total" @change="(page, pageSize) => changeRecordPage('chats', page, pageSize)" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="AI 请求" name="aiLogs"> <el-tab-pane label="AI 请求" name="aiLogs">
<el-table :data="aiLogs" stripe> <el-table v-loading="loading" :data="aiLogs" stripe>
<el-table-column prop="sessionId" label="会话ID" width="90" /> <el-table-column prop="sessionId" label="会话ID" width="90" />
<el-table-column prop="modelName" label="模型" width="160" /> <el-table-column prop="modelName" label="模型" width="160" />
<el-table-column prop="knowledgeIds" label="知识库" width="120" /> <el-table-column prop="knowledgeIds" label="知识库" width="120" />
@@ -1342,14 +1392,16 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<AdminPagination :page="pagers.aiLogs.page" :page-size="pagers.aiLogs.pageSize" :total="pagers.aiLogs.total" @change="(page, pageSize) => changeRecordPage('aiLogs', page, pageSize)" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="操作日志" name="operationLogs"> <el-tab-pane label="操作日志" name="operationLogs">
<el-table :data="operationLogs" stripe> <el-table v-loading="loading" :data="operationLogs" stripe>
<el-table-column prop="module" label="模块" /> <el-table-column prop="module" label="模块" />
<el-table-column prop="action" label="动作" /> <el-table-column prop="action" label="动作" />
<el-table-column prop="result" label="结果" /> <el-table-column prop="result" label="结果" />
<el-table-column prop="createdAt" label="时间" /> <el-table-column prop="createdAt" label="时间" />
</el-table> </el-table>
<AdminPagination :page="pagers.operationLogs.page" :page-size="pagers.operationLogs.pageSize" :total="pagers.operationLogs.total" @change="(page, pageSize) => changeRecordPage('operationLogs', page, pageSize)" />
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</template> </template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{ page: number; pageSize: number; total: number }>();
const emit = defineEmits<{ change: [page: number, pageSize: number] }>();
function changePage(page: number, pageSize: number) { emit("change", page, pageSize); }
function changeSize(pageSize: number) { emit("change", 1, pageSize); }
</script>
<template>
<div class="admin-pagination">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="changePage($event, pageSize)"
@size-change="changeSize"
/>
</div>
</template>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue"; import { onMounted, reactive, ref } from "vue";
import { api } from "../services/api"; import { api } from "../services/api";
import TableRowActions from "./TableRowActions.vue"; import TableRowActions from "./TableRowActions.vue";
import AdminPagination from "./AdminPagination.vue";
import type { import type {
KnowledgeDetail, KnowledgeDetail,
KnowledgeItem, KnowledgeItem,
@@ -34,6 +35,7 @@ const resolvingNode = ref(false);
const nodeResolved = ref(false); const nodeResolved = ref(false);
const sourceTitle = ref(""); const sourceTitle = ref("");
const filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" }); const filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" });
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const form = reactive({ const form = reactive({
name: "", name: "",
feishuSpaceId: "", feishuSpaceId: "",
@@ -46,22 +48,19 @@ const form = reactive({
const editForm = reactive({ id: 0, name: "", knowledgeType: "general", reviewMode: "manual" }); const editForm = reactive({ id: 0, name: "", knowledgeType: "general", reviewMode: "manual" });
const batchForm = reactive({ knowledgeType: "", reviewMode: "" }); const batchForm = reactive({ knowledgeType: "", reviewMode: "" });
const filteredRows = computed(() => rows.value.filter((item) => {
if (filter.keyword && !`${item.name}${item.sourceTitle || ""}`.includes(filter.keyword)) return false;
if (filter.type && item.knowledgeType !== filter.type) return false;
if (filter.open && String(item.status) !== filter.open) return false;
if (filter.source && item.sourceStatus !== filter.source) return false;
if (filter.pending === "yes" && !item.pendingVersionId) return false;
if (filter.pending === "no" && item.pendingVersionId) return false;
return true;
}));
onMounted(load); onMounted(load);
async function load() { async function load(page = pager.page, pageSize = pager.pageSize) {
loading.value = true; loading.value = true;
try { try {
[rows.value, migration.value] = await Promise.all([api.knowledge(), api.migrationSummary()]); const result = await api.knowledge({
keyword: filter.keyword, knowledgeType: filter.type, openStatus: filter.open,
sourceStatus: filter.source, pending: filter.pending === "" ? undefined : filter.pending === "yes",
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) { } catch (error) {
ElMessage.error(errorMessage(error, "知识库列表加载失败")); ElMessage.error(errorMessage(error, "知识库列表加载失败"));
} finally { } finally {
@@ -429,11 +428,11 @@ async function confirmAction(
</section> </section>
<div class="knowledge-filters"> <div class="knowledge-filters">
<el-input v-model="filter.keyword" placeholder="搜索后台名称或飞书标题" clearable /> <el-input v-model="filter.keyword" placeholder="搜索后台名称或飞书标题" clearable @change="load(1)" />
<el-select v-model="filter.type" placeholder="知识库类型" clearable><el-option label="课程" value="course" /><el-option label="答疑" value="qa" /><el-option label="通用" value="general" /></el-select> <el-select v-model="filter.type" placeholder="知识库类型" clearable @change="load(1)"><el-option label="课程" value="course" /><el-option label="答疑" value="qa" /><el-option label="通用" value="general" /></el-select>
<el-select v-model="filter.open" placeholder="开放状态" clearable><el-option label="已开放" value="1" /><el-option label="已关闭" value="0" /></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><el-option label="正常" value="normal" /><el-option label="源异常" value="error" /></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>
<el-select v-model="filter.pending" placeholder="待发布版本" clearable><el-option label="有待发布版本" value="yes" /><el-option label="无待发布版本" value="no" /></el-select> <el-select v-model="filter.pending" placeholder="待发布版本" clearable @change="load(1)"><el-option label="有待发布版本" value="yes" /><el-option label="无待发布版本" value="no" /></el-select>
</div> </div>
<div class="knowledge-batch-bar"> <div class="knowledge-batch-bar">
@@ -441,7 +440,7 @@ async function confirmAction(
<el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型/审核方式</el-button> <el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型/审核方式</el-button>
</div> </div>
<el-table v-loading="loading" :data="filteredRows" stripe @selection-change="selectedRows = $event"> <el-table v-loading="loading" :data="rows" stripe @selection-change="selectedRows = $event">
<el-table-column type="selection" width="48" /> <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="知识库" 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="150"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}<small class="table-subline">{{ row.reviewMode === 'manual' ? '人工审核' : '允许自动发布' }}</small></template></el-table-column> <el-table-column label="类型/审核" width="150"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}<small class="table-subline">{{ row.reviewMode === 'manual' ? '人工审核' : '允许自动发布' }}</small></template></el-table-column>
@@ -450,6 +449,7 @@ async function confirmAction(
<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, '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-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-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> <el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
<template v-if="detail"> <template v-if="detail">

View File

@@ -16,6 +16,7 @@ import {
ElInput, ElInput,
ElInputNumber, ElInputNumber,
ElOption, ElOption,
ElPagination,
ElProgress, ElProgress,
ElSelect, ElSelect,
ElSwitch, ElSwitch,
@@ -44,6 +45,7 @@ import "element-plus/theme-chalk/el-message.css";
import "element-plus/theme-chalk/el-message-box.css"; import "element-plus/theme-chalk/el-message-box.css";
import "element-plus/theme-chalk/el-option.css"; import "element-plus/theme-chalk/el-option.css";
import "element-plus/theme-chalk/el-overlay.css"; import "element-plus/theme-chalk/el-overlay.css";
import "element-plus/theme-chalk/el-pagination.css";
import "element-plus/theme-chalk/el-progress.css"; import "element-plus/theme-chalk/el-progress.css";
import "element-plus/theme-chalk/el-popper.css"; import "element-plus/theme-chalk/el-popper.css";
import "element-plus/theme-chalk/el-scrollbar.css"; import "element-plus/theme-chalk/el-scrollbar.css";
@@ -79,6 +81,7 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
ElInput, ElInput,
ElInputNumber, ElInputNumber,
ElOption, ElOption,
ElPagination,
ElProgress, ElProgress,
ElSelect, ElSelect,
ElSwitch, ElSwitch,

View File

@@ -20,6 +20,7 @@ import type {
ModelItem, ModelItem,
SystemConfigItem, SystemConfigItem,
UserImportResult, UserImportResult,
PageResult,
} from "../types/api"; } from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
@@ -101,7 +102,7 @@ export const api = {
const qs = params.toString(); const qs = params.toString();
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`); return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
}, },
users: (keyword = "") => request<AdminUser[]>(`/admin/user/list?keyword=${encodeURIComponent(keyword)}`), users: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
createUser: (payload: Record<string, unknown>) => createUser: (payload: Record<string, unknown>) =>
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }), request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
importUsers: (students: Record<string, unknown>[]) => importUsers: (students: Record<string, unknown>[]) =>
@@ -115,7 +116,8 @@ export const api = {
updateUser: (id: number, payload: Record<string, unknown>) => updateUser: (id: number, payload: Record<string, unknown>) =>
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }), request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }), deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
knowledge: () => request<KnowledgeItem[]>("/admin/knowledge/list"), knowledge: (query: Record<string, unknown> = {}) => request<PageResult<KnowledgeItem>>(`/admin/knowledge/list${queryString(query)}`),
knowledgeOptions: () => request<KnowledgeItem[]>("/admin/knowledge/options"),
resolveKnowledgeNode: (nodeId: string) => request<{ nodeId: string; spaceId: string; sourceTitle: string; name: string; remark: string }>("/admin/knowledge/resolve-node", { method: "POST", body: JSON.stringify({ nodeId }) }), 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>) => createKnowledge: (payload: Record<string, unknown>) =>
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }), request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
@@ -175,21 +177,21 @@ export const api = {
configs: () => request<SystemConfigItem[]>("/admin/config"), configs: () => request<SystemConfigItem[]>("/admin/config"),
saveConfig: (payload: Record<string, unknown>) => saveConfig: (payload: Record<string, unknown>) =>
request<SystemConfigItem>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }), request<SystemConfigItem>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
chats: (query: ChatRecordQuery = {}) => request<ChatRecord[]>(`/admin/chat/list${queryString(query)}`), chats: (query: ChatRecordQuery = {}) => request<PageResult<ChatRecord>>(`/admin/chat/list${queryString(query)}`),
chatDetail: (sessionId: number) => request<ChatDetail>(`/admin/chat/${sessionId}`), chatDetail: (sessionId: number) => request<ChatDetail>(`/admin/chat/${sessionId}`),
exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"), exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"),
aiLogs: (query: { sessionId?: number; userId?: number; status?: string } = {}) => aiLogs: (query: { sessionId?: number; userId?: number; status?: string; page?: number; pageSize?: number } = {}) =>
request<AiLogRecord[]>(`/admin/ai-log/list${queryString(query)}`), request<PageResult<AiLogRecord>>(`/admin/ai-log/list${queryString(query)}`),
aiLogDetail: (id: number) => request<AiLogRecord>(`/admin/ai-log/${id}`), aiLogDetail: (id: number) => request<AiLogRecord>(`/admin/ai-log/${id}`),
operationLogs: () => request<Record<string, unknown>[]>("/admin/log/list"), operationLogs: (query: { module?: string; page?: number; pageSize?: number } = {}) => request<PageResult<Record<string, unknown>>>(`/admin/log/list${queryString(query)}`),
retrievalLogs: () => request<RetrievalLogItem[]>("/admin/retrieval-log/list"), retrievalLogs: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<RetrievalLogItem>>(`/admin/retrieval-log/list${queryString(query)}`),
estimateRetrievalCleanup: (before: string) => request<{ before: string; estimatedCount: number }>("/admin/retrieval-log/cleanup/estimate", { method: "POST", body: JSON.stringify({ before }) }), 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 }) }), 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"), 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 }) }), 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" : ""}`), storageStats: (refresh = false) => request<Record<string, any>>(`/admin/dashboard/storage${refresh ? "?refresh=true" : ""}`),
retrievalLogDetail: (id: number) => request<Record<string, unknown>>(`/admin/retrieval-log/${id}`), retrievalLogDetail: (id: number) => request<Record<string, unknown>>(`/admin/retrieval-log/${id}`),
attentionList: () => request<AttentionItem[]>("/admin/attention/list"), attentionList: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<AttentionItem>>(`/admin/attention/list${queryString(query)}`),
updateAttention: (id: number, status: string, note?: string) => updateAttention: (id: number, status: string, note?: string) =>
request<AttentionItem>(`/admin/attention/${id}`, { method: "PUT", body: JSON.stringify({ status, note }) }), request<AttentionItem>(`/admin/attention/${id}`, { method: "PUT", body: JSON.stringify({ status, note }) }),
deleteAttention: (id: number) => request<null>(`/admin/attention/${id}`, { method: "DELETE" }), deleteAttention: (id: number) => request<null>(`/admin/attention/${id}`, { method: "DELETE" }),

View File

@@ -726,6 +726,11 @@ textarea {
.table-row-actions { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; white-space: nowrap; } .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 + .el-button { margin-left: 0; }
.table-row-actions .el-button { min-width: 48px; height: 32px; white-space: nowrap; } .table-row-actions .el-button { min-width: 48px; height: 32px; white-space: nowrap; }
.admin-pagination { display: flex; justify-content: flex-end; align-items: center; min-height: 48px; padding: 14px 0 2px; overflow-x: auto; }
.admin-pagination .el-pagination { flex-wrap: nowrap; white-space: nowrap; }
.admin-pagination .el-pager li,
.admin-pagination .btn-prev,
.admin-pagination .btn-next { min-width: 32px; height: 32px; border-radius: 6px; }
.tool-trace-node { border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; overflow: hidden; } .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 { 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 summary span { margin-left: auto; color: #667a73; font-size: 12px; }

View File

@@ -4,6 +4,13 @@ export interface ApiResponse<T> {
data: T; data: T;
} }
export interface PageResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface AdminProfile { export interface AdminProfile {
id: number; id: number;
username: string; username: string;
@@ -175,7 +182,6 @@ export interface KnowledgeSyncJob {
export interface MigrationSummary { export interface MigrationSummary {
total: number; total: number;
counts: Record<string, number>; counts: Record<string, number>;
items: KnowledgeItem[];
} }
export interface KnowledgeBatchUpdateResult { export interface KnowledgeBatchUpdateResult {
@@ -278,4 +284,6 @@ export interface ChatRecordQuery {
status?: string; status?: string;
dateFrom?: string; dateFrom?: string;
dateTo?: string; dateTo?: string;
page?: number;
pageSize?: number;
} }

View File

@@ -21,6 +21,7 @@ from app.schemas.knowledge import AttentionUpdateRequest
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
from app.models.logs import LogRetentionPolicy from app.models.logs import LogRetentionPolicy
from app.services.maintenance_service import MaintenanceService from app.services.maintenance_service import MaintenanceService
from app.api.pagination import page_result
router = APIRouter() router = APIRouter()
@@ -48,8 +49,9 @@ def retrieval_logs(
query = query.where(KnowledgeRetrievalLog.status == statusValue) query = query.where(KnowledgeRetrievalLog.status == statusValue)
if attentionCreated is not None: if attentionCreated is not None:
query = query.where(KnowledgeRetrievalLog.attention_created == (1 if attentionCreated else 0)) query = query.where(KnowledgeRetrievalLog.attention_created == (1 if attentionCreated else 0))
query = query.offset((page - 1) * pageSize).limit(pageSize) total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
return api_success([_retrieval_dict(item, detail=False) for item in db.scalars(query).all()]) rows = db.scalars(query.offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_retrieval_dict(item, detail=False) for item in rows], total=total, page=page, page_size=pageSize))
@router.post("/retrieval-log/cleanup/estimate") @router.post("/retrieval-log/cleanup/estimate")
@@ -133,15 +135,19 @@ def retrieval_detail(
def attention_list( def attention_list(
priority: str = Query(default=""), priority: str = Query(default=""),
statusValue: str = Query(default="", alias="status"), statusValue: str = Query(default="", alias="status"),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
query = select(HumanAttentionRecord).order_by(HumanAttentionRecord.id.desc()).limit(300) query = select(HumanAttentionRecord)
if priority: if priority:
query = query.where(HumanAttentionRecord.priority == priority) query = query.where(HumanAttentionRecord.priority == priority)
if statusValue: if statusValue:
query = query.where(HumanAttentionRecord.status == statusValue) query = query.where(HumanAttentionRecord.status == statusValue)
return api_success([_attention_dict(item) for item in db.scalars(query).all()]) total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
rows = db.scalars(query.order_by(HumanAttentionRecord.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_attention_dict(item) for item in rows], total=total, page=page, page_size=pageSize))
@router.put("/attention/{attention_id}") @router.put("/attention/{attention_id}")

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -26,13 +26,42 @@ from app.schemas.admin import KnowledgeSaveRequest
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
from app.services.feishu_service import FeishuKnowledgeService from app.services.feishu_service import FeishuKnowledgeService
from app.services.knowledge_catalog_cache_service import KnowledgeCatalogCacheService from app.services.knowledge_catalog_cache_service import KnowledgeCatalogCacheService
from app.api.pagination import page_result
router = APIRouter() router = APIRouter()
@router.get("/knowledge/list") @router.get("/knowledge/list")
def list_knowledge(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict: def list_knowledge(
items = db.scalars(select(Knowledge).order_by(Knowledge.id.desc())).all() keyword: str = Query(default=""),
knowledgeType: str = Query(default=""),
openStatus: int | None = Query(default=None),
sourceStatus: str = Query(default=""),
pending: bool | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(Knowledge)
if keyword:
query = query.where((Knowledge.name.like(f"%{keyword}%")) | (Knowledge.source_title.like(f"%{keyword}%")))
if knowledgeType:
query = query.where(Knowledge.knowledge_type == knowledgeType)
if openStatus is not None:
query = query.where(Knowledge.status == openStatus)
if sourceStatus:
query = query.where(Knowledge.source_status == sourceStatus)
if pending is not None:
query = query.where(Knowledge.pending_version_id.is_not(None) if pending else Knowledge.pending_version_id.is_(None))
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
items = db.scalars(query.order_by(Knowledge.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_knowledge_dict(item) for item in items], total=total, page=page, page_size=pageSize))
@router.get("/knowledge/options")
def knowledge_options(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
items = db.scalars(select(Knowledge).where(Knowledge.lifecycle_status == "active").order_by(Knowledge.id.desc())).all()
return api_success([_knowledge_dict(item) for item in items]) return api_success([_knowledge_dict(item) for item in items])

View File

@@ -367,11 +367,11 @@ def migration_summary(
db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin) db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)
) -> dict: ) -> dict:
_require_super_admin(current_admin) _require_super_admin(current_admin)
rows = db.scalars(select(Knowledge).order_by(Knowledge.id)).all() rows = db.execute(
counts: dict[str, int] = {} select(Knowledge.migration_status, func.count(Knowledge.id)).group_by(Knowledge.migration_status)
for item in rows: ).all()
counts[item.migration_status] = counts.get(item.migration_status, 0) + 1 counts = {migration_status: count for migration_status, count in rows}
return api_success({"total": len(rows), "counts": counts, "items": [_knowledge_detail(item) for item in rows]}) return api_success({"total": sum(counts.values()), "counts": counts})
@router.post("/knowledge-migration/run") @router.post("/knowledge-migration/run")

View File

@@ -8,8 +8,8 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response from fastapi.responses import Response
from sqlalchemy import exists, or_, select from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, load_only
from app.core.database import get_db from app.core.database import get_db
from app.core.dependencies import get_current_admin from app.core.dependencies import get_current_admin
@@ -18,6 +18,7 @@ from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession from app.models.chat import ChatMessage, ChatSession
from app.models.logs import AiRequestLog, OperationLog from app.models.logs import AiRequestLog, OperationLog
from app.models.user import User from app.models.user import User
from app.api.pagination import page_result
router = APIRouter() router = APIRouter()
@@ -29,15 +30,15 @@ def chat_list(
status: str = Query(default=""), status: str = Query(default=""),
dateFrom: datetime | None = Query(default=None), dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None), dateTo: datetime | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
rows = db.execute( query = _chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo)
_chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo) total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
.order_by(ChatSession.updated_at.desc()) rows = db.execute(query.order_by(ChatSession.updated_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
.limit(200) return api_success(page_result([_chat_row_dict(session, user) for session, user in rows], total=total, page=page, page_size=pageSize))
).all()
return api_success([_chat_row_dict(session, user) for session, user in rows])
@router.get("/chat/export") @router.get("/chat/export")
@@ -118,14 +119,20 @@ def chat_detail(
@router.get("/log/list") @router.get("/log/list")
def operation_logs( def operation_logs(
module: str = Query(default=""), module: str = Query(default=""),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
query = select(OperationLog).order_by(OperationLog.created_at.desc()).limit(200) query = select(OperationLog)
if module: if module:
query = query.where(OperationLog.module == module) query = query.where(OperationLog.module == module)
logs = db.scalars(query).all() total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
return api_success( logs = db.scalars(
query.options(load_only(OperationLog.id, OperationLog.admin_id, OperationLog.module, OperationLog.action, OperationLog.target_id, OperationLog.result, OperationLog.created_at))
.order_by(OperationLog.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)
).all()
return api_success(page_result(
[ [
{ {
"id": item.id, "id": item.id,
@@ -137,8 +144,8 @@ def operation_logs(
"createdAt": item.created_at, "createdAt": item.created_at,
} }
for item in logs for item in logs
] ], total=total, page=page, page_size=pageSize
) ))
@router.get("/ai-log/list") @router.get("/ai-log/list")
@@ -146,18 +153,28 @@ def ai_logs(
sessionId: int | None = Query(default=None), sessionId: int | None = Query(default=None),
userId: int | None = Query(default=None), userId: int | None = Query(default=None),
status: str = Query(default=""), status: str = Query(default=""),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
query = select(AiRequestLog).order_by(AiRequestLog.created_at.desc()).limit(200) query = select(AiRequestLog)
if sessionId is not None: if sessionId is not None:
query = query.where(AiRequestLog.session_id == sessionId) query = query.where(AiRequestLog.session_id == sessionId)
if userId is not None: if userId is not None:
query = query.where(AiRequestLog.user_id == userId) query = query.where(AiRequestLog.user_id == userId)
if status: if status:
query = query.where(AiRequestLog.status == status) query = query.where(AiRequestLog.status == status)
logs = db.scalars(query).all() total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
return api_success([_ai_log_dict(item) for item in logs]) logs = db.scalars(
query.options(load_only(
AiRequestLog.id, AiRequestLog.session_id, AiRequestLog.message_id, AiRequestLog.user_id,
AiRequestLog.model_name, AiRequestLog.knowledge_ids, AiRequestLog.retrieve_count,
AiRequestLog.input_token, AiRequestLog.output_token, AiRequestLog.total_token,
AiRequestLog.cost_ms, AiRequestLog.status, AiRequestLog.error_message, AiRequestLog.created_at,
)).order_by(AiRequestLog.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)
).all()
return api_success(page_result([_ai_log_dict(item, include_chunks=False) for item in logs], total=total, page=page, page_size=pageSize))
@router.get("/ai-log/{log_id}") @router.get("/ai-log/{log_id}")
@@ -241,7 +258,7 @@ def _message_dict(message: ChatMessage) -> dict:
} }
def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False) -> dict: def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False, include_chunks: bool = True) -> dict:
data = { data = {
"id": log.id, "id": log.id,
"sessionId": log.session_id, "sessionId": log.session_id,
@@ -250,7 +267,7 @@ def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False) -> dict:
"modelName": log.model_name, "modelName": log.model_name,
"knowledgeIds": log.knowledge_ids, "knowledgeIds": log.knowledge_ids,
"retrieveCount": log.retrieve_count, "retrieveCount": log.retrieve_count,
"retrievedChunks": _parse_retrieved_chunks(log.retrieved_chunks), "retrievedChunks": _parse_retrieved_chunks(log.retrieved_chunks) if include_chunks else [],
"inputToken": log.input_token, "inputToken": log.input_token,
"outputToken": log.output_token, "outputToken": log.output_token,
"totalToken": log.total_token, "totalToken": log.total_token,

View File

@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile,
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook from openpyxl import Workbook, load_workbook
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
@@ -19,6 +19,7 @@ from app.models.ai_config import SystemConfig
from app.models.user import User from app.models.user import User
from app.schemas.admin import AdminUserCreateRequest, AdminUserImportItem, AdminUserImportRequest, AdminUserUpdateRequest from app.schemas.admin import AdminUserCreateRequest, AdminUserImportItem, AdminUserImportRequest, AdminUserUpdateRequest
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
from app.api.pagination import page_result
router = APIRouter() router = APIRouter()
@@ -28,6 +29,8 @@ STUDENT_TEMPLATE_HEADERS = ["手机号", "姓名", "昵称", "每日聊天额度
@router.get("/user/list") @router.get("/user/list")
def list_users( def list_users(
keyword: str = Query(default=""), keyword: str = Query(default=""),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
@@ -35,8 +38,9 @@ def list_users(
if keyword: if keyword:
like = f"%{keyword}%" like = f"%{keyword}%"
query = query.where((User.phone.like(like)) | (User.name.like(like))) query = query.where((User.phone.like(like)) | (User.name.like(like)))
users = db.scalars(query.limit(200)).all() total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
return api_success([_user_dict(user) for user in users]) users = db.scalars(query.offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_user_dict(user) for user in users], total=total, page=page, page_size=pageSize))
@router.post("/user") @router.post("/user")

View File

@@ -0,0 +1,7 @@
from __future__ import annotations
from typing import Any
def page_result(items: list[Any], *, total: int, page: int, page_size: int) -> dict[str, Any]:
return {"items": items, "total": total, "page": page, "pageSize": page_size}

View File

@@ -0,0 +1,65 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.api.admin_agent_records import attention_list, retrieval_logs
from app.api.admin_records import ai_logs
from app.api.admin_users import list_users
from app.models import Base
from app.models.knowledge import HumanAttentionRecord, KnowledgeRetrievalLog
from app.models.logs import AiRequestLog
from app.models.user import User
def _database() -> Session:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
return Session(engine)
def test_user_list_uses_database_pagination():
with _database() as db:
db.add_all([
User(id=i + 1, phone=f"1380000{i:04d}", name=f"学员{i}", daily_chat_limit=10)
for i in range(25)
])
db.commit()
response = list_users(keyword="", page=2, pageSize=10, db=db, current_admin=object())
assert response["data"]["total"] == 25
assert response["data"]["page"] == 2
assert len(response["data"]["items"]) == 10
def test_ai_log_page_does_not_load_large_detail_fields():
with _database() as db:
db.add_all([
AiRequestLog(status="success", prompt="p" * 5000, retrieved_chunks='[{"content":"large"}]')
for _ in range(23)
])
db.commit()
response = ai_logs(sessionId=None, userId=None, status="", page=2, pageSize=10, db=db, current_admin=object())
assert response["data"]["total"] == 23
assert len(response["data"]["items"]) == 10
assert all(item["retrievedChunks"] == [] for item in response["data"]["items"])
def test_retrieval_and_attention_lists_are_paginated():
with _database() as db:
for index in range(21):
db.add(KnowledgeRetrievalLog(
question=f"q{index}", catalog_snapshot="[]", catalog_hash=str(index),
safety_rule_version="v1", status="success",
))
db.add(HumanAttentionRecord(
session_id=index + 1, message_id=index + 1, user_id=index + 1,
trigger_message="test", problem_summary="test", trigger_reason="test",
priority="normal", status="pending",
))
db.commit()
retrieval = retrieval_logs(
sessionId=None, knowledgeId=None, knowledgeCalled=None, statusValue="", attentionCreated=None,
page=3, pageSize=10, db=db, current_admin=object(),
)["data"]
attention = attention_list(priority="", statusValue="", page=3, pageSize=10, db=db, current_admin=object())["data"]
assert retrieval["total"] == attention["total"] == 21
assert len(retrieval["items"]) == len(attention["items"]) == 1