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 {
|
||||
AdminProfile,
|
||||
AdminUser,
|
||||
AdminUserDetail,
|
||||
AiLogRecord,
|
||||
ChatDetail,
|
||||
ChatRecord,
|
||||
@@ -41,6 +42,9 @@ const dashboardFilters = reactive({
|
||||
end: "",
|
||||
});
|
||||
const users = ref<AdminUser[]>([]);
|
||||
const selectedUserDetail = ref<AdminUserDetail | null>(null);
|
||||
const userDetailOpen = ref(false);
|
||||
const userDetailLoading = ref(false);
|
||||
const userKeyword = ref("");
|
||||
const entitlementPlans = ref<EntitlementPlan[]>([]);
|
||||
const editingEntitlementPlanId = ref<number | null>(null);
|
||||
@@ -401,6 +405,17 @@ async function assignUserEntitlement(row: AdminUser, planId: number) {
|
||||
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) {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -1138,10 +1153,20 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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 }">
|
||||
<el-button size="small" @click="saveUser(row)">保存</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteUser(row)">删除</el-button>
|
||||
<TableRowActions>
|
||||
<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>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -1610,6 +1635,125 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
||||
</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="检索日志详情">
|
||||
<template v-if="selectedRetrievalLog">
|
||||
<dl class="knowledge-meta">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AdminProfile,
|
||||
AdminUser,
|
||||
AdminUserDetail,
|
||||
AgentDebugResult,
|
||||
AgentDebugStreamComplete,
|
||||
AgentGenerationConfig,
|
||||
@@ -125,6 +126,7 @@ export const api = {
|
||||
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
|
||||
},
|
||||
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>) =>
|
||||
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
|
||||
importUsers: (students: Record<string, unknown>[]) =>
|
||||
|
||||
@@ -2013,6 +2013,47 @@ textarea {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
@@ -66,6 +66,34 @@ export interface AdminUser {
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -458,6 +486,23 @@ export interface TopicSummaryRecord {
|
||||
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 {
|
||||
id: number;
|
||||
userId: number;
|
||||
@@ -472,6 +517,8 @@ export interface TopicSessionRecord {
|
||||
recommendedHomework?: string | null;
|
||||
startedAt: string;
|
||||
endedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
summary?: TopicSummaryRecord | null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user