feat: add admin user detail insights

This commit is contained in:
2026-07-31 16:30:39 +08:00
parent 763c78bd7e
commit 88ed92d78c
6 changed files with 361 additions and 5 deletions

View File

@@ -12,6 +12,7 @@ import { api, clearToken, getToken, saveToken } from "./services/api";
import type { import type {
AdminProfile, AdminProfile,
AdminUser, AdminUser,
AdminUserDetail,
AiLogRecord, AiLogRecord,
ChatDetail, ChatDetail,
ChatRecord, ChatRecord,
@@ -41,6 +42,9 @@ const dashboardFilters = reactive({
end: "", end: "",
}); });
const users = ref<AdminUser[]>([]); const users = ref<AdminUser[]>([]);
const selectedUserDetail = ref<AdminUserDetail | null>(null);
const userDetailOpen = ref(false);
const userDetailLoading = ref(false);
const userKeyword = ref(""); const userKeyword = ref("");
const entitlementPlans = ref<EntitlementPlan[]>([]); const entitlementPlans = ref<EntitlementPlan[]>([]);
const editingEntitlementPlanId = ref<number | null>(null); const editingEntitlementPlanId = ref<number | null>(null);
@@ -401,6 +405,17 @@ async function assignUserEntitlement(row: AdminUser, planId: number) {
ElMessage.success("用户权益已更新"); ElMessage.success("用户权益已更新");
} }
async function openUserDetail(row: AdminUser) {
userDetailOpen.value = true;
selectedUserDetail.value = null;
userDetailLoading.value = true;
try {
selectedUserDetail.value = await api.userDetail(row.id);
} finally {
userDetailLoading.value = false;
}
}
async function loadRecordTab(tab = recordTab.value) { async function loadRecordTab(tab = recordTab.value) {
loading.value = true; loading.value = true;
try { try {
@@ -1138,10 +1153,20 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="lastLoginAt" label="最近登录" width="180" /> <el-table-column prop="lastLoginAt" label="最近登录" width="180" />
<el-table-column label="操作" width="160" fixed="right"> <el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="saveUser(row)">保存</el-button> <TableRowActions>
<el-button size="small" type="danger" @click="deleteUser(row)">删除</el-button> <el-button size="small" @click="openUserDetail(row)">详情</el-button>
<el-button size="small" type="primary" @click="saveUser(row)">保存</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="deleteUser(row)">删除用户</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</TableRowActions>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -1610,6 +1635,125 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</section> </section>
</section> </section>
<el-drawer v-model="userDetailOpen" size="860px" title="用户详情">
<div v-loading="userDetailLoading" class="user-detail-body">
<template v-if="selectedUserDetail">
<section class="user-detail-head">
<div>
<h3>{{ selectedUserDetail.user.name || selectedUserDetail.user.nickname || selectedUserDetail.user.phone }}</h3>
<p>{{ selectedUserDetail.user.phone }} · {{ selectedUserDetail.user.status === 1 ? '已启用' : '已停用' }}</p>
</div>
<div class="user-detail-tags">
<el-tag v-if="selectedUserDetail.metrics.isHighFrequency" type="warning" effect="plain">高频使用</el-tag>
<el-tag v-if="selectedUserDetail.metrics.isInactive" type="info" effect="plain">近30天低活跃</el-tag>
<el-tag type="success" effect="plain">{{ selectedUserDetail.user.entitlement?.name || '默认基础版' }}</el-tag>
</div>
</section>
<section class="chat-summary user-metric-grid">
<div><span>本月主题</span><strong>{{ selectedUserDetail.metrics.monthTopics }}/{{ selectedUserDetail.user.entitlement?.monthlyTopicLimit ?? '不限' }}</strong></div>
<div><span>近30天活跃</span><strong>{{ selectedUserDetail.metrics.recentActiveDays }} </strong></div>
<div><span>总会话</span><strong>{{ selectedUserDetail.metrics.totalSessions }}</strong></div>
<div><span>总消息</span><strong>{{ selectedUserDetail.metrics.totalMessages }}</strong></div>
<div><span>主题总数</span><strong>{{ selectedUserDetail.metrics.totalTopics }}</strong></div>
<div><span>AI 请求</span><strong>{{ selectedUserDetail.metrics.aiRequestCount }}</strong></div>
<div><span> Token</span><strong>{{ selectedUserDetail.metrics.totalToken }}</strong></div>
<div><span>估算成本</span><strong>{{ formatMoney(selectedUserDetail.metrics.estimatedCost, selectedUserDetail.metrics.costCurrency) }}</strong></div>
</section>
<section class="audit-detail-grid">
<div><span>权益有效期</span><strong>{{ selectedUserDetail.user.entitlement?.expiredAt || selectedUserDetail.user.expiredAt || '长期有效' }}</strong></div>
<div><span>最近登录</span><strong>{{ selectedUserDetail.user.lastLoginAt || '-' }}</strong></div>
<div><span>最近发言</span><strong>{{ selectedUserDetail.metrics.lastMessageAt || '-' }}</strong></div>
<div><span>生成记录</span><strong>求助卡 {{ selectedUserDetail.metrics.helpCardCount }} / 分享稿 {{ selectedUserDetail.metrics.shareDraftCount }}</strong></div>
</section>
<h3 class="detail-title">成长档案摘要</h3>
<section v-if="selectedUserDetail.growthProfile" class="topic-summary-card">
<pre>{{ selectedUserDetail.growthProfile.profileText || '暂无成长档案摘要' }}</pre>
<div class="topic-summary-fields">
<p v-if="selectedUserDetail.growthProfile.recurringTopics"><strong>反复议题</strong>{{ selectedUserDetail.growthProfile.recurringTopics }}</p>
<p v-if="selectedUserDetail.growthProfile.commonEmotions"><strong>常见情绪</strong>{{ selectedUserDetail.growthProfile.commonEmotions }}</p>
<p v-if="selectedUserDetail.growthProfile.bodyPatterns"><strong>身体模式</strong>{{ selectedUserDetail.growthProfile.bodyPatterns }}</p>
<p v-if="selectedUserDetail.growthProfile.homeworkDone"><strong>做过功课</strong>{{ selectedUserDetail.growthProfile.homeworkDone }}</p>
<p v-if="selectedUserDetail.growthProfile.effectiveHomework"><strong>有效功课</strong>{{ selectedUserDetail.growthProfile.effectiveHomework }}</p>
<p v-if="selectedUserDetail.growthProfile.recentProgress"><strong>近期变化</strong>{{ selectedUserDetail.growthProfile.recentProgress }}</p>
</div>
</section>
<el-empty v-else description="该用户暂无成长档案沉淀" :image-size="64" />
<h3 class="detail-title">最近主题</h3>
<el-collapse v-if="selectedUserDetail.recentTopics.length" class="topic-summary-collapse">
<el-collapse-item
v-for="topic in selectedUserDetail.recentTopics"
:key="topic.id"
:title="`#${topic.id} ${topic.title} / ${topic.status}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>核心问题{{ topic.coreQuestion }}</span>
<span>消息数{{ topic.messageCount }}</span>
<span>Token{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span>
<span>额度{{ topic.quotaDeducted ? '已计入' : '不计入' }}</span>
<span>开始{{ topic.startedAt }}</span>
<span>更新{{ topic.updatedAt }}</span>
</div>
<template v-if="topic.summary">
<pre>{{ topic.summary.summary }}</pre>
<div class="topic-summary-fields">
<p v-if="topic.summary.emotions"><strong>情绪</strong>{{ topic.summary.emotions }}</p>
<p v-if="topic.summary.bodyFeelings"><strong>身体感受</strong>{{ topic.summary.bodyFeelings }}</p>
<p v-if="topic.summary.recommendedHomework"><strong>推荐功课</strong>{{ topic.summary.recommendedHomework }}</p>
<p v-if="topic.summary.nextObservation"><strong>下一步观察</strong>{{ topic.summary.nextObservation }}</p>
</div>
</template>
<el-empty v-else description="该主题尚未沉淀摘要" :image-size="60" />
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该用户暂无主题记录" :image-size="64" />
<h3 class="detail-title">最近老师求助卡{{ selectedUserDetail.recentHelpCards.length }}</h3>
<el-collapse v-if="selectedUserDetail.recentHelpCards.length" class="topic-summary-collapse">
<el-collapse-item
v-for="card in selectedUserDetail.recentHelpCards"
:key="card.id"
:title="`#${card.id} / 主题 #${card.topicSessionId} / ${card.copied ? '用户已复制' : '未复制'}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成{{ card.createdAt }}</span>
<span>复制{{ card.copiedAt || '-' }}</span>
<span>来源{{ card.source }}</span>
</div>
<pre>{{ card.content }}</pre>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该用户暂无求助卡生成记录" :image-size="64" />
<h3 class="detail-title">最近班级分享稿{{ selectedUserDetail.recentShareDrafts.length }}</h3>
<el-collapse v-if="selectedUserDetail.recentShareDrafts.length" class="topic-summary-collapse">
<el-collapse-item
v-for="draft in selectedUserDetail.recentShareDrafts"
:key="draft.id"
:title="`#${draft.id} / 主题 #${draft.topicSessionId} / ${draft.copied ? '用户已复制' : '未复制'}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成{{ draft.createdAt }}</span>
<span>复制{{ draft.copiedAt || '-' }}</span>
<span>来源{{ draft.source }}</span>
</div>
<pre>{{ draft.content }}</pre>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该用户暂无分享稿生成记录" :image-size="64" />
</template>
</div>
</el-drawer>
<el-drawer v-model="retrievalDetailOpen" size="820px" title="检索日志详情"> <el-drawer v-model="retrievalDetailOpen" size="820px" title="检索日志详情">
<template v-if="selectedRetrievalLog"> <template v-if="selectedRetrievalLog">
<dl class="knowledge-meta"> <dl class="knowledge-meta">

View File

@@ -1,6 +1,7 @@
import type { import type {
AdminProfile, AdminProfile,
AdminUser, AdminUser,
AdminUserDetail,
AgentDebugResult, AgentDebugResult,
AgentDebugStreamComplete, AgentDebugStreamComplete,
AgentGenerationConfig, AgentGenerationConfig,
@@ -125,6 +126,7 @@ export const api = {
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`); return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
}, },
users: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`), users: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
createUser: (payload: Record<string, unknown>) => createUser: (payload: Record<string, unknown>) =>
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }), request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
importUsers: (students: Record<string, unknown>[]) => importUsers: (students: Record<string, unknown>[]) =>

View File

@@ -2013,6 +2013,47 @@ textarea {
padding-right: 4px; padding-right: 4px;
} }
.user-detail-body {
min-height: 320px;
max-height: calc(100vh - 96px);
overflow-y: auto;
padding-right: 4px;
}
.user-detail-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 16px;
padding: 16px;
border: 1px solid #dfe8e5;
border-radius: 14px;
background: linear-gradient(135deg, #f8fbfa 0%, #eef7f3 100%);
}
.user-detail-head h3 {
margin: 0 0 6px;
color: #183b33;
font-size: 20px;
}
.user-detail-head p {
margin: 0;
color: #637973;
}
.user-detail-tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.user-metric-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.chat-summary { .chat-summary {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));

View File

@@ -66,6 +66,34 @@ export interface AdminUser {
entitlement?: UserEntitlementSummary | null; entitlement?: UserEntitlementSummary | null;
} }
export interface AdminUserDetail {
user: AdminUser;
metrics: AdminUserMetrics;
growthProfile?: UserGrowthProfileRecord | null;
recentTopics: TopicSessionRecord[];
recentHelpCards: TeacherHelpCardRecord[];
recentShareDrafts: ShareDraftRecord[];
}
export interface AdminUserMetrics {
totalSessions: number;
totalMessages: number;
totalTopics: number;
monthTopics: number;
recentActiveDays: number;
lastMessageAt?: string | null;
aiRequestCount: number;
inputToken: number;
outputToken: number;
totalToken: number;
estimatedCost: number;
costCurrency: string;
helpCardCount: number;
shareDraftCount: number;
isHighFrequency: boolean;
isInactive: boolean;
}
export interface EntitlementPlan { export interface EntitlementPlan {
id: number; id: number;
name: string; name: string;
@@ -458,6 +486,23 @@ export interface TopicSummaryRecord {
generatedAt: string; generatedAt: string;
} }
export interface UserGrowthProfileRecord {
id: number;
userId: number;
profileText: string;
recurringTopics?: string | null;
commonEmotions?: string | null;
bodyPatterns?: string | null;
relationPatterns?: string | null;
homeworkDone?: string | null;
effectiveHomework?: string | null;
recentProgress?: string | null;
lastTopicSummaryId?: number | null;
updatedBy: string;
createdAt: string;
updatedAt: string;
}
export interface TopicSessionRecord { export interface TopicSessionRecord {
id: number; id: number;
userId: number; userId: number;
@@ -472,6 +517,8 @@ export interface TopicSessionRecord {
recommendedHomework?: string | null; recommendedHomework?: string | null;
startedAt: string; startedAt: string;
endedAt?: string | null; endedAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
summary?: TopicSummaryRecord | null; summary?: TopicSummaryRecord | null;
} }

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import UTC, date, datetime from datetime import UTC, date, datetime, timedelta
from io import BytesIO from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
@@ -17,11 +17,16 @@ from app.core.responses import api_success
from app.models.admin import Admin from app.models.admin import Admin
from app.models.ai_config import SystemConfig from app.models.ai_config import SystemConfig
from app.models.entitlement import EntitlementPlan, UserEntitlement from app.models.entitlement import EntitlementPlan, UserEntitlement
from app.models.chat import TopicSession from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import ShareDraft, TeacherHelpCard, TopicSummary
from app.models.logs import AiRequestLog
from app.models.user import User from app.models.user import User
from app.schemas.admin import AdminUserCreateRequest, AdminUserImportItem, AdminUserImportRequest, AdminUserUpdateRequest from app.schemas.admin import AdminUserCreateRequest, AdminUserImportItem, AdminUserImportRequest, AdminUserUpdateRequest
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
from app.services.entitlement_service import EntitlementService, entitlement_dict, view_from_plan from app.services.entitlement_service import EntitlementService, entitlement_dict, view_from_plan
from app.services.growth_profile_service import GrowthProfileService, growth_profile_dict, topic_dict, topic_summary_dict
from app.services.help_card_service import help_card_dict
from app.services.share_draft_service import share_draft_dict
from app.api.pagination import page_result from app.api.pagination import page_result
router = APIRouter() router = APIRouter()
@@ -234,6 +239,30 @@ def user_detail(
return api_success(_user_dict(user)) return api_success(_user_dict(user))
@router.get("/user/{user_id}/detail")
def user_operation_detail(
user_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
entitlement = EntitlementService.active_entitlement(
db,
user,
monthly_topic_used=_monthly_topic_counts(db, [user.id]).get(user.id, 0),
)
return api_success(
{
"user": _user_dict(user, entitlement_dict(entitlement)),
"metrics": _user_metrics(db, user=user, monthly_topic_limit=entitlement.monthly_topic_limit),
"growthProfile": growth_profile_dict(GrowthProfileService.get_growth_profile(db, user.id)),
"recentTopics": _recent_topics(db, user.id),
"recentHelpCards": [help_card_dict(item) for item in _recent_help_cards(db, user.id)],
"recentShareDrafts": [share_draft_dict(item) for item in _recent_share_drafts(db, user.id)],
}
)
@router.put("/user/{user_id}") @router.put("/user/{user_id}")
def update_user( def update_user(
user_id: int, user_id: int,
@@ -345,6 +374,95 @@ def _active_entitlement_rows(db: Session, user_ids: list[int]) -> dict[int, tupl
return result return result
def _user_metrics(db: Session, *, user: User, monthly_topic_limit: int | None) -> dict:
now = datetime.now(UTC).replace(tzinfo=None)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
recent_start = now - timedelta(days=30)
total_sessions = db.scalar(select(func.count(ChatSession.id)).where(ChatSession.user_id == user.id, ChatSession.is_deleted == 0)) or 0
total_messages = db.scalar(select(func.count(ChatMessage.id)).where(ChatMessage.user_id == user.id)) or 0
total_topics = db.scalar(select(func.count(TopicSession.id)).where(TopicSession.user_id == user.id)) or 0
month_topics = db.scalar(
select(func.count(TopicSession.id)).where(TopicSession.user_id == user.id, TopicSession.started_at >= month_start)
) or 0
recent_active_days = db.scalar(
select(func.count(func.distinct(func.date(ChatMessage.created_at)))).where(
ChatMessage.user_id == user.id,
ChatMessage.created_at >= recent_start,
)
) or 0
last_message_at = db.scalar(select(func.max(ChatMessage.created_at)).where(ChatMessage.user_id == user.id))
token_row = db.execute(
select(
func.coalesce(func.sum(AiRequestLog.input_token), 0),
func.coalesce(func.sum(AiRequestLog.output_token), 0),
func.coalesce(func.sum(AiRequestLog.total_token), 0),
func.coalesce(func.sum(AiRequestLog.estimated_cost), 0),
func.max(AiRequestLog.currency),
func.count(AiRequestLog.id),
).where(AiRequestLog.user_id == user.id)
).one()
help_card_count = db.scalar(select(func.count(TeacherHelpCard.id)).where(TeacherHelpCard.user_id == user.id)) or 0
share_draft_count = db.scalar(select(func.count(ShareDraft.id)).where(ShareDraft.user_id == user.id)) or 0
usage_ratio = (month_topics / monthly_topic_limit) if monthly_topic_limit else None
return {
"totalSessions": int(total_sessions),
"totalMessages": int(total_messages),
"totalTopics": int(total_topics),
"monthTopics": int(month_topics),
"recentActiveDays": int(recent_active_days),
"lastMessageAt": last_message_at,
"aiRequestCount": int(token_row[5] or 0),
"inputToken": int(token_row[0] or 0),
"outputToken": int(token_row[1] or 0),
"totalToken": int(token_row[2] or 0),
"estimatedCost": float(token_row[3] or 0),
"costCurrency": token_row[4] or "CNY",
"helpCardCount": int(help_card_count),
"shareDraftCount": int(share_draft_count),
"isHighFrequency": bool(usage_ratio is not None and usage_ratio >= 0.8) or recent_active_days >= 15,
"isInactive": total_sessions == 0 or last_message_at is None or last_message_at < recent_start,
}
def _recent_topics(db: Session, user_id: int, *, limit: int = 10) -> list[dict]:
rows = db.execute(
select(TopicSession, TopicSummary)
.join(TopicSummary, TopicSummary.topic_session_id == TopicSession.id, isouter=True)
.where(TopicSession.user_id == user_id)
.order_by(TopicSession.updated_at.desc(), TopicSession.id.desc())
.limit(limit)
).all()
return [
{
**topic_dict(topic),
"summary": topic_summary_dict(summary) if summary else None,
}
for topic, summary in rows
]
def _recent_help_cards(db: Session, user_id: int, *, limit: int = 10) -> list[TeacherHelpCard]:
return list(
db.scalars(
select(TeacherHelpCard)
.where(TeacherHelpCard.user_id == user_id)
.order_by(TeacherHelpCard.created_at.desc(), TeacherHelpCard.id.desc())
.limit(limit)
)
)
def _recent_share_drafts(db: Session, user_id: int, *, limit: int = 10) -> list[ShareDraft]:
return list(
db.scalars(
select(ShareDraft)
.where(ShareDraft.user_id == user_id)
.order_by(ShareDraft.created_at.desc(), ShareDraft.id.desc())
.limit(limit)
)
)
def _monthly_topic_counts(db: Session, user_ids: list[int]) -> dict[int, int]: def _monthly_topic_counts(db: Session, user_ids: list[int]) -> dict[int, int]:
if not user_ids: if not user_ids:
return {} return {}

View File

@@ -727,6 +727,10 @@ AI 日志增加:
- 可以识别几乎未使用用户; - 可以识别几乎未使用用户;
- 可以看到深度版价值是否被使用。 - 可以看到深度版价值是否被使用。
#### 开发进度
- 2026-07-31后台用户管理新增“详情”抽屉独立接口按需读取用户运营摘要展示当前权益、权益有效期、本月主题数、近30天活跃天数、会话/消息/Token/成本、最近主题、成长档案摘要、老师求助卡和班级分享稿记录。列表接口仍只返回分页摘要,避免用户管理打开变慢。
--- ---
### 12. 周报、月报、五个月总结 ### 12. 周报、月报、五个月总结