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 { 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 activeMenu = ref("dashboard");
|
||||
@@ -16,10 +25,13 @@ const userKeyword = ref("");
|
||||
const knowledge = ref<KnowledgeItem[]>([]);
|
||||
const models = ref<ModelItem[]>([]);
|
||||
const configs = ref<Record<string, unknown>[]>([]);
|
||||
const chats = ref<Record<string, unknown>[]>([]);
|
||||
const aiLogs = ref<Record<string, unknown>[]>([]);
|
||||
const chats = ref<ChatRecord[]>([]);
|
||||
const aiLogs = ref<AiLogRecord[]>([]);
|
||||
const operationLogs = ref<Record<string, unknown>[]>([]);
|
||||
const promptContent = ref("");
|
||||
const chatDetail = ref<ChatDetail | null>(null);
|
||||
const chatDetailOpen = ref(false);
|
||||
const recordTab = ref("chats");
|
||||
|
||||
const knowledgeForm = reactive({
|
||||
name: "",
|
||||
@@ -45,6 +57,13 @@ const configForm = reactive({
|
||||
description: "默认每日聊天次数",
|
||||
});
|
||||
|
||||
const chatFilters = reactive({
|
||||
keyword: "",
|
||||
userId: undefined as number | undefined,
|
||||
status: "",
|
||||
dateRange: [] as string[],
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (!getToken()) return;
|
||||
try {
|
||||
@@ -89,7 +108,7 @@ async function loadCurrentMenu() {
|
||||
if (activeMenu.value === "models") models.value = await api.models();
|
||||
if (activeMenu.value === "configs") configs.value = await api.configs();
|
||||
if (activeMenu.value === "records") {
|
||||
chats.value = await api.chats();
|
||||
chats.value = await api.chats(buildChatQuery());
|
||||
aiLogs.value = await api.aiLogs();
|
||||
operationLogs.value = await api.operationLogs();
|
||||
}
|
||||
@@ -149,6 +168,34 @@ async function saveConfig() {
|
||||
ElMessage.success("配置已保存");
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -288,13 +335,115 @@ async function saveConfig() {
|
||||
|
||||
<template v-if="activeMenu === 'records'">
|
||||
<div class="page-head"><h2>记录审计</h2><p>聊天、AI 请求和后台操作记录。</p></div>
|
||||
<el-tabs>
|
||||
<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="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>
|
||||
<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-tabs v-model="recordTab">
|
||||
<el-tab-pane label="聊天记录" name="chats">
|
||||
<div class="record-filter">
|
||||
<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>
|
||||
</template>
|
||||
</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>
|
||||
</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 TOKEN_KEY = "ai-kb-admin-token";
|
||||
@@ -28,6 +39,30 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
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 = {
|
||||
login: (username: string, password: string) =>
|
||||
request<{ token: string; admin: AdminProfile }>("/admin/login", {
|
||||
@@ -56,7 +91,10 @@ export const api = {
|
||||
configs: () => request<Record<string, unknown>[]>("/admin/config"),
|
||||
saveConfig: (payload: Record<string, unknown>) =>
|
||||
request<Record<string, unknown>>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
|
||||
chats: () => request<Record<string, unknown>[]>("/admin/chat/list"),
|
||||
aiLogs: () => request<Record<string, unknown>[]>("/admin/ai-log/list"),
|
||||
chats: (query: ChatRecordQuery = {}) => request<ChatRecord[]>(`/admin/chat/list${queryString(query)}`),
|
||||
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"),
|
||||
};
|
||||
|
||||
@@ -188,3 +188,102 @@ textarea {
|
||||
.actions {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user