Complete admin chat records
This commit is contained in:
@@ -3,7 +3,16 @@ import { ElMessage } from "element-plus";
|
|||||||
import { onMounted, reactive, ref } from "vue";
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
import { api, clearToken, getToken, saveToken } from "./services/api";
|
import { api, clearToken, getToken, saveToken } from "./services/api";
|
||||||
import type { AdminProfile, AdminUser, DashboardStats, KnowledgeItem, ModelItem } from "./types/api";
|
import type {
|
||||||
|
AdminProfile,
|
||||||
|
AdminUser,
|
||||||
|
AiLogRecord,
|
||||||
|
ChatDetail,
|
||||||
|
ChatRecord,
|
||||||
|
DashboardStats,
|
||||||
|
KnowledgeItem,
|
||||||
|
ModelItem,
|
||||||
|
} from "./types/api";
|
||||||
|
|
||||||
const admin = ref<AdminProfile | null>(null);
|
const admin = ref<AdminProfile | null>(null);
|
||||||
const activeMenu = ref("dashboard");
|
const activeMenu = ref("dashboard");
|
||||||
@@ -16,10 +25,13 @@ const userKeyword = ref("");
|
|||||||
const knowledge = ref<KnowledgeItem[]>([]);
|
const knowledge = ref<KnowledgeItem[]>([]);
|
||||||
const models = ref<ModelItem[]>([]);
|
const models = ref<ModelItem[]>([]);
|
||||||
const configs = ref<Record<string, unknown>[]>([]);
|
const configs = ref<Record<string, unknown>[]>([]);
|
||||||
const chats = ref<Record<string, unknown>[]>([]);
|
const chats = ref<ChatRecord[]>([]);
|
||||||
const aiLogs = ref<Record<string, unknown>[]>([]);
|
const aiLogs = ref<AiLogRecord[]>([]);
|
||||||
const operationLogs = ref<Record<string, unknown>[]>([]);
|
const operationLogs = ref<Record<string, unknown>[]>([]);
|
||||||
const promptContent = ref("");
|
const promptContent = ref("");
|
||||||
|
const chatDetail = ref<ChatDetail | null>(null);
|
||||||
|
const chatDetailOpen = ref(false);
|
||||||
|
const recordTab = ref("chats");
|
||||||
|
|
||||||
const knowledgeForm = reactive({
|
const knowledgeForm = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -45,6 +57,13 @@ const configForm = reactive({
|
|||||||
description: "默认每日聊天次数",
|
description: "默认每日聊天次数",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const chatFilters = reactive({
|
||||||
|
keyword: "",
|
||||||
|
userId: undefined as number | undefined,
|
||||||
|
status: "",
|
||||||
|
dateRange: [] as string[],
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!getToken()) return;
|
if (!getToken()) return;
|
||||||
try {
|
try {
|
||||||
@@ -89,7 +108,7 @@ async function loadCurrentMenu() {
|
|||||||
if (activeMenu.value === "models") models.value = await api.models();
|
if (activeMenu.value === "models") models.value = await api.models();
|
||||||
if (activeMenu.value === "configs") configs.value = await api.configs();
|
if (activeMenu.value === "configs") configs.value = await api.configs();
|
||||||
if (activeMenu.value === "records") {
|
if (activeMenu.value === "records") {
|
||||||
chats.value = await api.chats();
|
chats.value = await api.chats(buildChatQuery());
|
||||||
aiLogs.value = await api.aiLogs();
|
aiLogs.value = await api.aiLogs();
|
||||||
operationLogs.value = await api.operationLogs();
|
operationLogs.value = await api.operationLogs();
|
||||||
}
|
}
|
||||||
@@ -149,6 +168,34 @@ async function saveConfig() {
|
|||||||
ElMessage.success("配置已保存");
|
ElMessage.success("配置已保存");
|
||||||
await loadCurrentMenu();
|
await loadCurrentMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchChats() {
|
||||||
|
chats.value = await api.chats(buildChatQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetChatFilters() {
|
||||||
|
Object.assign(chatFilters, { keyword: "", userId: undefined, status: "", dateRange: [] });
|
||||||
|
await searchChats();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openChatDetail(row: ChatRecord) {
|
||||||
|
chatDetail.value = await api.chatDetail(row.id);
|
||||||
|
chatDetailOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportChats() {
|
||||||
|
await api.exportChats(buildChatQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChatQuery() {
|
||||||
|
return {
|
||||||
|
keyword: chatFilters.keyword,
|
||||||
|
userId: chatFilters.userId,
|
||||||
|
status: chatFilters.status,
|
||||||
|
dateFrom: chatFilters.dateRange?.[0],
|
||||||
|
dateTo: chatFilters.dateRange?.[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -288,13 +335,115 @@ async function saveConfig() {
|
|||||||
|
|
||||||
<template v-if="activeMenu === 'records'">
|
<template v-if="activeMenu === 'records'">
|
||||||
<div class="page-head"><h2>记录审计</h2><p>聊天、AI 请求和后台操作记录。</p></div>
|
<div class="page-head"><h2>记录审计</h2><p>聊天、AI 请求和后台操作记录。</p></div>
|
||||||
<el-tabs>
|
<el-tabs v-model="recordTab">
|
||||||
<el-tab-pane label="聊天会话"><el-table :data="chats" stripe><el-table-column prop="title" label="标题" /><el-table-column prop="messageCount" label="消息数" width="100" /><el-table-column prop="updatedAt" label="更新时间" /></el-table></el-tab-pane>
|
<el-tab-pane label="聊天记录" name="chats">
|
||||||
<el-tab-pane label="AI 请求"><el-table :data="aiLogs" stripe><el-table-column prop="modelName" label="模型" /><el-table-column prop="retrieveCount" label="命中" width="90" /><el-table-column prop="status" label="状态" width="100" /><el-table-column prop="errorMessage" label="错误" /></el-table></el-tab-pane>
|
<div class="record-filter">
|
||||||
<el-tab-pane label="操作日志"><el-table :data="operationLogs" stripe><el-table-column prop="module" label="模块" /><el-table-column prop="action" label="动作" /><el-table-column prop="result" label="结果" /><el-table-column prop="createdAt" label="时间" /></el-table></el-tab-pane>
|
<el-input v-model="chatFilters.keyword" placeholder="搜索标题、手机号、姓名、聊天内容" clearable />
|
||||||
|
<el-input-number v-model="chatFilters.userId" :min="1" placeholder="用户ID" />
|
||||||
|
<el-select v-model="chatFilters.status" placeholder="消息状态" clearable>
|
||||||
|
<el-option label="全部状态" value="" />
|
||||||
|
<el-option label="已完成" value="FINISHED" />
|
||||||
|
<el-option label="生成中" value="GENERATING" />
|
||||||
|
<el-option label="已停止" value="STOPPED" />
|
||||||
|
<el-option label="失败" value="FAILED" />
|
||||||
|
</el-select>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="chatFilters.dateRange"
|
||||||
|
type="datetimerange"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" @click="searchChats">搜索</el-button>
|
||||||
|
<el-button @click="resetChatFilters">重置</el-button>
|
||||||
|
<el-button @click="exportChats">导出</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="chats" stripe>
|
||||||
|
<el-table-column prop="id" label="会话ID" width="90" />
|
||||||
|
<el-table-column prop="userPhone" label="手机号" width="140" />
|
||||||
|
<el-table-column prop="userName" label="用户" width="120" />
|
||||||
|
<el-table-column prop="title" label="会话标题" min-width="220" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="messageCount" label="消息数" width="90" />
|
||||||
|
<el-table-column prop="lastMessageAt" label="最后消息" width="180" />
|
||||||
|
<el-table-column label="操作" width="100" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="openChatDetail(row)">详情</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="AI 请求" name="aiLogs">
|
||||||
|
<el-table :data="aiLogs" stripe>
|
||||||
|
<el-table-column prop="sessionId" label="会话ID" width="90" />
|
||||||
|
<el-table-column prop="modelName" label="模型" width="160" />
|
||||||
|
<el-table-column prop="knowledgeIds" label="知识库" width="120" />
|
||||||
|
<el-table-column prop="retrieveCount" label="命中" width="80" />
|
||||||
|
<el-table-column prop="totalToken" label="Token" width="90" />
|
||||||
|
<el-table-column prop="costMs" label="耗时(ms)" width="100" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100" />
|
||||||
|
<el-table-column prop="errorMessage" label="错误" min-width="220" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="操作日志" name="operationLogs">
|
||||||
|
<el-table :data="operationLogs" stripe>
|
||||||
|
<el-table-column prop="module" label="模块" />
|
||||||
|
<el-table-column prop="action" label="动作" />
|
||||||
|
<el-table-column prop="result" label="结果" />
|
||||||
|
<el-table-column prop="createdAt" label="时间" />
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<el-drawer v-model="chatDetailOpen" size="760px" title="聊天详情">
|
||||||
|
<template v-if="chatDetail">
|
||||||
|
<section class="chat-summary">
|
||||||
|
<div><span>会话ID</span><strong>{{ chatDetail.session.id }}</strong></div>
|
||||||
|
<div><span>用户</span><strong>{{ chatDetail.session.userName || '-' }}</strong></div>
|
||||||
|
<div><span>手机号</span><strong>{{ chatDetail.session.userPhone || '-' }}</strong></div>
|
||||||
|
<div><span>消息数</span><strong>{{ chatDetail.session.messageCount }}</strong></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h3 class="detail-title">完整对话</h3>
|
||||||
|
<section class="conversation-list">
|
||||||
|
<article
|
||||||
|
v-for="message in chatDetail.messages"
|
||||||
|
:key="message.id"
|
||||||
|
class="conversation-message"
|
||||||
|
:class="message.role"
|
||||||
|
>
|
||||||
|
<div class="message-meta">
|
||||||
|
<strong>{{ message.role === 'user' ? '用户' : 'AI 助手' }}</strong>
|
||||||
|
<span>{{ message.createdAt }}</span>
|
||||||
|
</div>
|
||||||
|
<pre>{{ message.content }}</pre>
|
||||||
|
<div class="message-extra">
|
||||||
|
<span>状态:{{ message.messageStatus }}</span>
|
||||||
|
<span v-if="message.tokenInput">输入 Token:{{ message.tokenInput }}</span>
|
||||||
|
<span v-if="message.tokenOutput">输出 Token:{{ message.tokenOutput }}</span>
|
||||||
|
<span v-if="message.responseTimeMs">耗时:{{ message.responseTimeMs }}ms</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h3 class="detail-title">关联 AI 请求</h3>
|
||||||
|
<el-collapse>
|
||||||
|
<el-collapse-item v-for="log in chatDetail.aiLogs" :key="log.id" :title="`请求 #${log.id} / ${log.status}`">
|
||||||
|
<div class="ai-log-grid">
|
||||||
|
<span>模型:{{ log.modelName || '-' }}</span>
|
||||||
|
<span>知识库:{{ log.knowledgeIds || '-' }}</span>
|
||||||
|
<span>命中数:{{ log.retrieveCount }}</span>
|
||||||
|
<span>Token:{{ log.totalToken || '-' }}</span>
|
||||||
|
<span>耗时:{{ log.costMs || '-' }}ms</span>
|
||||||
|
<span>时间:{{ log.createdAt }}</span>
|
||||||
|
</div>
|
||||||
|
<pre class="prompt-preview">{{ log.prompt || '无 Prompt 记录' }}</pre>
|
||||||
|
<p v-if="log.errorMessage" class="error-text">{{ log.errorMessage }}</p>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import type { AdminProfile, ApiResponse, DashboardStats, KnowledgeItem, ModelItem, AdminUser } from "../types/api";
|
import type {
|
||||||
|
AdminProfile,
|
||||||
|
AdminUser,
|
||||||
|
AiLogRecord,
|
||||||
|
ApiResponse,
|
||||||
|
ChatDetail,
|
||||||
|
ChatRecord,
|
||||||
|
ChatRecordQuery,
|
||||||
|
DashboardStats,
|
||||||
|
KnowledgeItem,
|
||||||
|
ModelItem,
|
||||||
|
} from "../types/api";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api";
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api";
|
||||||
const TOKEN_KEY = "ai-kb-admin-token";
|
const TOKEN_KEY = "ai-kb-admin-token";
|
||||||
@@ -28,6 +39,30 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|||||||
return body.data;
|
return body.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function download(path: string, filename: string) {
|
||||||
|
const headers = new Headers();
|
||||||
|
const token = getToken();
|
||||||
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, { headers });
|
||||||
|
if (!response.ok) throw new Error("导出失败");
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryString(query: object) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
Object.entries(query).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== "") params.set(key, String(value));
|
||||||
|
});
|
||||||
|
const text = params.toString();
|
||||||
|
return text ? `?${text}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string) =>
|
||||||
request<{ token: string; admin: AdminProfile }>("/admin/login", {
|
request<{ token: string; admin: AdminProfile }>("/admin/login", {
|
||||||
@@ -56,7 +91,10 @@ export const api = {
|
|||||||
configs: () => request<Record<string, unknown>[]>("/admin/config"),
|
configs: () => request<Record<string, unknown>[]>("/admin/config"),
|
||||||
saveConfig: (payload: Record<string, unknown>) =>
|
saveConfig: (payload: Record<string, unknown>) =>
|
||||||
request<Record<string, unknown>>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
|
request<Record<string, unknown>>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
|
||||||
chats: () => request<Record<string, unknown>[]>("/admin/chat/list"),
|
chats: (query: ChatRecordQuery = {}) => request<ChatRecord[]>(`/admin/chat/list${queryString(query)}`),
|
||||||
aiLogs: () => request<Record<string, unknown>[]>("/admin/ai-log/list"),
|
chatDetail: (sessionId: number) => request<ChatDetail>(`/admin/chat/${sessionId}`),
|
||||||
|
exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"),
|
||||||
|
aiLogs: (query: { sessionId?: number; userId?: number; status?: string } = {}) =>
|
||||||
|
request<AiLogRecord[]>(`/admin/ai-log/list${queryString(query)}`),
|
||||||
operationLogs: () => request<Record<string, unknown>[]>("/admin/log/list"),
|
operationLogs: () => request<Record<string, unknown>[]>("/admin/log/list"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -188,3 +188,102 @@ textarea {
|
|||||||
.actions {
|
.actions {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.record-filter {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1.4fr) 140px 150px 360px 72px 72px 72px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-summary div {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #dfe8e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-summary span {
|
||||||
|
display: block;
|
||||||
|
color: #6b7d77;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-summary strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #1f2d2a;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
margin: 18px 0 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-message {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #dfe8e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-message.user {
|
||||||
|
border-color: #cde7df;
|
||||||
|
background: #f2faf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-meta,
|
||||||
|
.message-extra {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
color: #6b7d77;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-meta strong {
|
||||||
|
color: #0f735d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-message pre,
|
||||||
|
.prompt-preview {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f4f7f6;
|
||||||
|
color: #223631;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: "SFMono-Regular", Consolas, "PingFang SC", monospace;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-log-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px 14px;
|
||||||
|
color: #40524b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,3 +50,60 @@ export interface ModelItem {
|
|||||||
timeoutSecond: number;
|
timeoutSecond: number;
|
||||||
enabled: number;
|
enabled: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatRecord {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
userPhone: string;
|
||||||
|
userName: string;
|
||||||
|
title: string;
|
||||||
|
messageCount: number;
|
||||||
|
lastMessageAt?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessageRecord {
|
||||||
|
id: number;
|
||||||
|
sessionId: number;
|
||||||
|
userId: number;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
messageStatus: string;
|
||||||
|
tokenInput?: number | null;
|
||||||
|
tokenOutput?: number | null;
|
||||||
|
responseTimeMs?: number | null;
|
||||||
|
modelId?: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiLogRecord {
|
||||||
|
id: number;
|
||||||
|
sessionId?: number | null;
|
||||||
|
messageId?: number | null;
|
||||||
|
userId?: number | null;
|
||||||
|
modelName?: string | null;
|
||||||
|
knowledgeIds?: string | null;
|
||||||
|
retrieveCount: number;
|
||||||
|
inputToken?: number | null;
|
||||||
|
outputToken?: number | null;
|
||||||
|
totalToken?: number | null;
|
||||||
|
costMs?: number | null;
|
||||||
|
status: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
prompt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatDetail {
|
||||||
|
session: ChatRecord;
|
||||||
|
messages: ChatMessageRecord[];
|
||||||
|
aiLogs: AiLogRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatRecordQuery {
|
||||||
|
keyword?: string;
|
||||||
|
userId?: number | null;
|
||||||
|
status?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from datetime import datetime
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy import exists, or_, 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
|
||||||
@@ -10,34 +15,66 @@ from app.core.responses import api_success
|
|||||||
from app.models.admin import Admin
|
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
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/chat/list")
|
@router.get("/chat/list")
|
||||||
def chat_list(
|
def chat_list(
|
||||||
|
keyword: str = Query(default=""),
|
||||||
|
userId: int | None = Query(default=None),
|
||||||
|
status: str = Query(default=""),
|
||||||
|
dateFrom: datetime | None = Query(default=None),
|
||||||
|
dateTo: datetime | None = Query(default=None),
|
||||||
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:
|
||||||
sessions = db.scalars(
|
rows = db.execute(
|
||||||
select(ChatSession)
|
_chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo)
|
||||||
.where(ChatSession.is_deleted == 0)
|
|
||||||
.order_by(ChatSession.updated_at.desc())
|
.order_by(ChatSession.updated_at.desc())
|
||||||
.limit(200)
|
.limit(200)
|
||||||
).all()
|
).all()
|
||||||
return api_success(
|
return api_success([_chat_row_dict(session, user) for session, user in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chat/export")
|
||||||
|
def export_chats(
|
||||||
|
keyword: str = Query(default=""),
|
||||||
|
userId: int | None = Query(default=None),
|
||||||
|
status: str = Query(default=""),
|
||||||
|
dateFrom: datetime | None = Query(default=None),
|
||||||
|
dateTo: datetime | None = Query(default=None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> Response:
|
||||||
|
rows = db.execute(
|
||||||
|
_chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo)
|
||||||
|
.order_by(ChatSession.updated_at.desc())
|
||||||
|
.limit(1000)
|
||||||
|
).all()
|
||||||
|
output = StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["会话ID", "用户ID", "手机号", "姓名", "标题", "消息数", "最后消息时间", "更新时间"])
|
||||||
|
for session, user in rows:
|
||||||
|
writer.writerow(
|
||||||
[
|
[
|
||||||
{
|
session.id,
|
||||||
"id": item.id,
|
session.user_id,
|
||||||
"userId": item.user_id,
|
user.phone if user else "",
|
||||||
"title": item.title,
|
user.name if user else "",
|
||||||
"messageCount": item.message_count,
|
session.title,
|
||||||
"lastMessageAt": item.last_message_at,
|
session.message_count,
|
||||||
"updatedAt": item.updated_at,
|
session.last_message_at,
|
||||||
}
|
session.updated_at,
|
||||||
for item in sessions
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
content = "\ufeff" + output.getvalue()
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="chat_records.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/chat/{session_id}")
|
@router.get("/chat/{session_id}")
|
||||||
@@ -46,25 +83,33 @@ def chat_detail(
|
|||||||
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:
|
||||||
|
session_row = db.execute(
|
||||||
|
select(ChatSession, User)
|
||||||
|
.join(User, User.id == ChatSession.user_id, isouter=True)
|
||||||
|
.where(ChatSession.id == session_id)
|
||||||
|
).first()
|
||||||
|
if session_row is None:
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
|
||||||
|
|
||||||
|
session, user = session_row
|
||||||
messages = db.scalars(
|
messages = db.scalars(
|
||||||
select(ChatMessage)
|
select(ChatMessage)
|
||||||
.where(ChatMessage.session_id == session_id)
|
.where(ChatMessage.session_id == session_id)
|
||||||
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
||||||
).all()
|
).all()
|
||||||
|
ai_logs = db.scalars(
|
||||||
|
select(AiRequestLog)
|
||||||
|
.where(AiRequestLog.session_id == session_id)
|
||||||
|
.order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc())
|
||||||
|
).all()
|
||||||
return api_success(
|
return api_success(
|
||||||
[
|
|
||||||
{
|
{
|
||||||
"id": item.id,
|
"session": _chat_row_dict(session, user),
|
||||||
"role": item.role,
|
"messages": [_message_dict(item) for item in messages],
|
||||||
"content": item.content,
|
"aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs],
|
||||||
"messageStatus": item.message_status,
|
|
||||||
"tokenInput": item.token_input,
|
|
||||||
"tokenOutput": item.token_output,
|
|
||||||
"responseTimeMs": item.response_time_ms,
|
|
||||||
"createdAt": item.created_at,
|
|
||||||
}
|
}
|
||||||
for item in messages
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -95,24 +140,110 @@ def operation_logs(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/ai-log/list")
|
@router.get("/ai-log/list")
|
||||||
def ai_logs(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
def ai_logs(
|
||||||
logs = db.scalars(select(AiRequestLog).order_by(AiRequestLog.created_at.desc()).limit(200)).all()
|
sessionId: int | None = Query(default=None),
|
||||||
return api_success(
|
userId: int | None = Query(default=None),
|
||||||
[
|
status: str = Query(default=""),
|
||||||
{
|
db: Session = Depends(get_db),
|
||||||
"id": item.id,
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
"sessionId": item.session_id,
|
) -> dict:
|
||||||
"messageId": item.message_id,
|
query = select(AiRequestLog).order_by(AiRequestLog.created_at.desc()).limit(200)
|
||||||
"userId": item.user_id,
|
if sessionId is not None:
|
||||||
"modelName": item.model_name,
|
query = query.where(AiRequestLog.session_id == sessionId)
|
||||||
"knowledgeIds": item.knowledge_ids,
|
if userId is not None:
|
||||||
"retrieveCount": item.retrieve_count,
|
query = query.where(AiRequestLog.user_id == userId)
|
||||||
"totalToken": item.total_token,
|
if status:
|
||||||
"costMs": item.cost_ms,
|
query = query.where(AiRequestLog.status == status)
|
||||||
"status": item.status,
|
logs = db.scalars(query).all()
|
||||||
"errorMessage": item.error_message,
|
return api_success([_ai_log_dict(item) for item in logs])
|
||||||
"createdAt": item.created_at,
|
|
||||||
}
|
|
||||||
for item in logs
|
def _chat_query(
|
||||||
]
|
*,
|
||||||
|
keyword: str,
|
||||||
|
user_id: int | None,
|
||||||
|
status: str,
|
||||||
|
date_from: datetime | None,
|
||||||
|
date_to: datetime | None,
|
||||||
|
):
|
||||||
|
query = (
|
||||||
|
select(ChatSession, User)
|
||||||
|
.join(User, User.id == ChatSession.user_id, isouter=True)
|
||||||
|
.where(ChatSession.is_deleted == 0)
|
||||||
)
|
)
|
||||||
|
if user_id is not None:
|
||||||
|
query = query.where(ChatSession.user_id == user_id)
|
||||||
|
if date_from is not None:
|
||||||
|
query = query.where(ChatSession.updated_at >= date_from.replace(tzinfo=None))
|
||||||
|
if date_to is not None:
|
||||||
|
query = query.where(ChatSession.updated_at <= date_to.replace(tzinfo=None))
|
||||||
|
if status:
|
||||||
|
query = query.where(
|
||||||
|
exists()
|
||||||
|
.where(ChatMessage.session_id == ChatSession.id)
|
||||||
|
.where(ChatMessage.message_status == status)
|
||||||
|
)
|
||||||
|
if keyword:
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
query = query.where(
|
||||||
|
or_(
|
||||||
|
ChatSession.title.like(like),
|
||||||
|
User.phone.like(like),
|
||||||
|
User.name.like(like),
|
||||||
|
exists()
|
||||||
|
.where(ChatMessage.session_id == ChatSession.id)
|
||||||
|
.where(ChatMessage.content.like(like)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_row_dict(session: ChatSession, user: User | None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": session.id,
|
||||||
|
"userId": session.user_id,
|
||||||
|
"userPhone": user.phone if user else "",
|
||||||
|
"userName": user.name if user else "",
|
||||||
|
"title": session.title,
|
||||||
|
"messageCount": session.message_count,
|
||||||
|
"lastMessageAt": session.last_message_at,
|
||||||
|
"updatedAt": session.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _message_dict(message: ChatMessage) -> dict:
|
||||||
|
return {
|
||||||
|
"id": message.id,
|
||||||
|
"sessionId": message.session_id,
|
||||||
|
"userId": message.user_id,
|
||||||
|
"role": message.role,
|
||||||
|
"content": message.content,
|
||||||
|
"messageStatus": message.message_status,
|
||||||
|
"tokenInput": message.token_input,
|
||||||
|
"tokenOutput": message.token_output,
|
||||||
|
"responseTimeMs": message.response_time_ms,
|
||||||
|
"modelId": message.model_id,
|
||||||
|
"createdAt": message.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False) -> dict:
|
||||||
|
data = {
|
||||||
|
"id": log.id,
|
||||||
|
"sessionId": log.session_id,
|
||||||
|
"messageId": log.message_id,
|
||||||
|
"userId": log.user_id,
|
||||||
|
"modelName": log.model_name,
|
||||||
|
"knowledgeIds": log.knowledge_ids,
|
||||||
|
"retrieveCount": log.retrieve_count,
|
||||||
|
"inputToken": log.input_token,
|
||||||
|
"outputToken": log.output_token,
|
||||||
|
"totalToken": log.total_token,
|
||||||
|
"costMs": log.cost_ms,
|
||||||
|
"status": log.status,
|
||||||
|
"errorMessage": log.error_message,
|
||||||
|
"createdAt": log.created_at,
|
||||||
|
}
|
||||||
|
if include_prompt:
|
||||||
|
data["prompt"] = log.prompt
|
||||||
|
return data
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class Settings(BaseSettings):
|
|||||||
default_factory=lambda: [
|
default_factory=lambda: [
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://127.0.0.1:5173",
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:5174",
|
||||||
|
"http://127.0.0.1:5174",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://127.0.0.1:3000",
|
"http://127.0.0.1:3000",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -31,7 +31,16 @@ class AdminAuthService:
|
|||||||
db.refresh(admin)
|
db.refresh(admin)
|
||||||
|
|
||||||
token, expired_at = create_access_token(str(admin.id), "admin")
|
token, expired_at = create_access_token(str(admin.id), "admin")
|
||||||
return {"token": token, "expiredAt": expired_at, "admin": admin}
|
return {
|
||||||
|
"token": token,
|
||||||
|
"expiredAt": expired_at,
|
||||||
|
"admin": {
|
||||||
|
"id": admin.id,
|
||||||
|
"username": admin.username,
|
||||||
|
"name": admin.name,
|
||||||
|
"status": admin.status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def ensure_bootstrap_admin(db: Session) -> None:
|
def ensure_bootstrap_admin(db: Session) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 管理后台聊天记录补齐记录
|
||||||
|
|
||||||
|
日期:2026-07-07
|
||||||
|
|
||||||
|
## 本次目标
|
||||||
|
|
||||||
|
把管理后台“记录审计”里的聊天记录做完整,让管理员可以按会话查询、搜索聊天内容、查看完整对话,并追溯关联的 AI 请求日志和 Prompt。
|
||||||
|
|
||||||
|
## 已完成后端能力
|
||||||
|
|
||||||
|
- 增强 `GET /api/admin/chat/list`:
|
||||||
|
- 支持按关键词搜索。
|
||||||
|
- 关键词覆盖会话标题、用户手机号、用户姓名和聊天消息内容。
|
||||||
|
- 支持按用户 ID 过滤。
|
||||||
|
- 支持按消息状态过滤。
|
||||||
|
- 支持按更新时间范围过滤。
|
||||||
|
- 返回用户手机号、用户姓名、会话标题、消息数、最后消息时间等列表字段。
|
||||||
|
- 增强 `GET /api/admin/chat/{session_id}`:
|
||||||
|
- 返回会话信息。
|
||||||
|
- 返回完整聊天消息。
|
||||||
|
- 返回关联 AI 请求日志。
|
||||||
|
- AI 请求日志详情包含 Prompt,方便排查模型回答依据。
|
||||||
|
- 新增 `GET /api/admin/chat/export`:
|
||||||
|
- 按当前筛选条件导出聊天会话 CSV。
|
||||||
|
- CSV 带 UTF-8 BOM,便于 Excel 打开中文。
|
||||||
|
- 增强 `GET /api/admin/ai-log/list`:
|
||||||
|
- 支持按会话 ID、用户 ID 和状态过滤。
|
||||||
|
|
||||||
|
## 已完成前端能力
|
||||||
|
|
||||||
|
- 记录审计新增聊天记录筛选区:
|
||||||
|
- 关键词
|
||||||
|
- 用户 ID
|
||||||
|
- 消息状态
|
||||||
|
- 时间范围
|
||||||
|
- 搜索、重置、导出
|
||||||
|
- 聊天记录列表展示:
|
||||||
|
- 会话 ID
|
||||||
|
- 用户手机号
|
||||||
|
- 用户姓名
|
||||||
|
- 会话标题
|
||||||
|
- 消息数
|
||||||
|
- 最后消息时间
|
||||||
|
- 新增聊天详情抽屉:
|
||||||
|
- 会话摘要
|
||||||
|
- 完整用户/AI 对话
|
||||||
|
- 消息状态、Token、耗时
|
||||||
|
- 关联 AI 请求日志
|
||||||
|
- Prompt 预览
|
||||||
|
- 错误信息展示
|
||||||
|
|
||||||
|
## 顺手修正
|
||||||
|
|
||||||
|
- 后端 CORS 增加管理后台本地地址 `5174`。
|
||||||
|
- 管理员登录返回值改为明确字典,避免直接返回 ORM 对象导致前端解析不稳定。
|
||||||
|
|
||||||
|
## 当前边界
|
||||||
|
|
||||||
|
- 导出当前只导出会话列表,不导出每条消息全文;如果后续需要“完整对话导出”,建议单独增加明细 CSV 或 Excel 导出。
|
||||||
|
- 聊天详情里的 AI 请求日志展示 Prompt,但不会展示模型 API Key 等敏感信息。
|
||||||
|
- 当前列表分页仍使用最多 200 条的简单限制,后续数据量上来后应改成分页参数。
|
||||||
@@ -32,4 +32,5 @@
|
|||||||
| `2026-07-06-phase3-user-client-completion.md` | 阶段三用户端 H5 主链路补齐记录。 |
|
| `2026-07-06-phase3-user-client-completion.md` | 阶段三用户端 H5 主链路补齐记录。 |
|
||||||
| `2026-07-06-phase4-rag-external-services.md` | 阶段四 RAG 和外部服务接入骨架记录。 |
|
| `2026-07-06-phase4-rag-external-services.md` | 阶段四 RAG 和外部服务接入骨架记录。 |
|
||||||
| `2026-07-06-phase5-admin-console.md` | 阶段五管理后台记录。 |
|
| `2026-07-06-phase5-admin-console.md` | 阶段五管理后台记录。 |
|
||||||
|
| `2026-07-07-admin-chat-records-completion.md` | 管理后台聊天记录补齐记录。 |
|
||||||
| `../test_records/2026-07-06-phase6-verification.md` | 阶段六验证记录。 |
|
| `../test_records/2026-07-06-phase6-verification.md` | 阶段六验证记录。 |
|
||||||
|
|||||||
Reference in New Issue
Block a user