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:

View File

@@ -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 条的简单限制,后续数据量上来后应改成分页参数。

View File

@@ -32,4 +32,5 @@
| `2026-07-06-phase3-user-client-completion.md` | 阶段三用户端 H5 主链路补齐记录。 |
| `2026-07-06-phase4-rag-external-services.md` | 阶段四 RAG 和外部服务接入骨架记录。 |
| `2026-07-06-phase5-admin-console.md` | 阶段五管理后台记录。 |
| `2026-07-07-admin-chat-records-completion.md` | 管理后台聊天记录补齐记录。 |
| `../test_records/2026-07-06-phase6-verification.md` | 阶段六验证记录。 |