Complete admin chat records

This commit is contained in:
2026-07-07 12:17:23 +08:00
parent 08dd371480
commit 08c301c136
9 changed files with 608 additions and 61 deletions

View File

@@ -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>

View File

@@ -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"),
};

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -1,7 +1,12 @@
from __future__ import annotations
import csv
from datetime import datetime
from io import StringIO
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 app.core.database import get_db
@@ -10,33 +15,65 @@ from app.core.responses import api_success
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.logs import AiRequestLog, OperationLog
from app.models.user import User
router = APIRouter()
@router.get("/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),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
sessions = db.scalars(
select(ChatSession)
.where(ChatSession.is_deleted == 0)
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(200)
).all()
return api_success(
[
{
"id": item.id,
"userId": item.user_id,
"title": item.title,
"messageCount": item.message_count,
"lastMessageAt": item.last_message_at,
"updatedAt": item.updated_at,
}
for item in sessions
]
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,
session.user_id,
user.phone if user else "",
user.name if user else "",
session.title,
session.message_count,
session.last_message_at,
session.updated_at,
]
)
content = "\ufeff" + output.getvalue()
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="chat_records.csv"'},
)
@@ -46,25 +83,33 @@ def chat_detail(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> 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(
select(ChatMessage)
.where(ChatMessage.session_id == session_id)
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
).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(
[
{
"id": item.id,
"role": item.role,
"content": item.content,
"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
]
{
"session": _chat_row_dict(session, user),
"messages": [_message_dict(item) for item in messages],
"aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs],
}
)
@@ -95,24 +140,110 @@ def operation_logs(
@router.get("/ai-log/list")
def ai_logs(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
logs = db.scalars(select(AiRequestLog).order_by(AiRequestLog.created_at.desc()).limit(200)).all()
return api_success(
[
{
"id": item.id,
"sessionId": item.session_id,
"messageId": item.message_id,
"userId": item.user_id,
"modelName": item.model_name,
"knowledgeIds": item.knowledge_ids,
"retrieveCount": item.retrieve_count,
"totalToken": item.total_token,
"costMs": item.cost_ms,
"status": item.status,
"errorMessage": item.error_message,
"createdAt": item.created_at,
}
for item in logs
]
def ai_logs(
sessionId: int | None = Query(default=None),
userId: int | None = Query(default=None),
status: str = Query(default=""),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(AiRequestLog).order_by(AiRequestLog.created_at.desc()).limit(200)
if sessionId is not None:
query = query.where(AiRequestLog.session_id == sessionId)
if userId is not None:
query = query.where(AiRequestLog.user_id == userId)
if status:
query = query.where(AiRequestLog.status == status)
logs = db.scalars(query).all()
return api_success([_ai_log_dict(item) 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

View File

@@ -17,6 +17,8 @@ class Settings(BaseSettings):
default_factory=lambda: [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
"http://localhost:3000",
"http://127.0.0.1:3000",
]

View File

@@ -31,7 +31,16 @@ class AdminAuthService:
db.refresh(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
def ensure_bootstrap_admin(db: Session) -> None: