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