You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

445 lines
11 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="admin-page">
<div class="admin-header">
<h1>📊 管理后台 <span class="header-slogan">说走就走剩下的交给我</span></h1>
<button class="back-btn" @click="$router.push('/')"></button>
</div>
<div v-if="loading" class="admin-loading">加载中...</div>
<template v-else>
<!-- 概览卡片 -->
<div class="overview-grid">
<div class="stat-card">
<div class="stat-value">{{ overview.totalUsers }}</div>
<div class="stat-label">总用户数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.totalPlans }}</div>
<div class="stat-label">总行程数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.todayActiveUsers }}</div>
<div class="stat-label">今日活跃用户</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.todayActiveGuests }}</div>
<div class="stat-label">今日活跃游客</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.todayEvents }}</div>
<div class="stat-label">今日事件数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.totalEvents }}</div>
<div class="stat-label">总事件数</div>
</div>
</div>
<div class="admin-content">
<!-- 事件类型统计 -->
<div class="admin-section">
<h2>事件类型统计近30天</h2>
<table class="data-table">
<thead>
<tr>
<th>事件类型</th>
<th>次数</th>
<th>占比</th>
</tr>
</thead>
<tbody>
<tr v-for="event in events" :key="event.event_type">
<td>{{ getEventLabel(event.event_type) }}</td>
<td>{{ event.count }}</td>
<td>{{ getPercentage(event.count) }}%</td>
</tr>
</tbody>
</table>
</div>
<!-- 用户列表 -->
<div class="admin-section">
<h2>用户列表</h2>
<table class="data-table">
<thead>
<tr>
<th>用户名</th>
<th>角色</th>
<th>行程数</th>
<th>活跃次数</th>
<th>最后登录</th>
<th>注册时间</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.username }}</td>
<td>
<span class="role-badge" :class="user.role">{{ user.role === 'admin' ? '管理员' : '用户' }}</span>
</td>
<td>{{ user.plan_count }}</td>
<td>{{ user.event_count }}</td>
<td>{{ user.last_active ? formatDate(user.last_active) : '未活跃' }}</td>
<td>{{ formatDate(user.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- AI 使用量统计 -->
<div class="admin-section">
<h2>🤖 Token 使用量近30天</h2>
<div class="usage-summary">
<div class="usage-stat">
<span class="usage-number">{{ formatNumber(usageStats.totalTokens) }}</span>
<span class="usage-label">总 token 消耗</span>
</div>
<div class="usage-stat">
<span class="usage-number">{{ formatNumber(usageStats.totalCalls) }}</span>
<span class="usage-label">总调用次数</span>
</div>
</div>
<table class="data-table" v-if="usageStats.rows && usageStats.rows.length">
<thead>
<tr>
<th>用户</th>
<th>调用类型</th>
<th>调用次数</th>
<th>Prompt Tokens</th>
<th>Completion Tokens</th>
<th>总 Tokens</th>
<th>耗时</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in usageStats.rows" :key="i">
<td><strong>{{ row.username }}</strong></td>
<td>{{ getEventLabel(row.event_type) }}</td>
<td>{{ row.call_count }}</td>
<td>{{ formatNumber(row.prompt_tokens) }}</td>
<td>{{ formatNumber(row.completion_tokens) }}</td>
<td><strong>{{ formatNumber(row.total_tokens) }}</strong></td>
<td>{{ formatDuration(row.total_duration_ms) }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty-hint">暂无数据AI 调用后会自动记录</div>
</div>
<!-- 大模型调用次数统计 -->
<div class="admin-section">
<h2>📞 大模型调用统计近30天</h2>
<div class="usage-summary">
<div class="usage-stat" v-for="m in modelStats.models" :key="m.model">
<span class="usage-number">{{ m.call_count }}</span>
<span class="usage-label">{{ m.model }}</span>
</div>
<div class="usage-stat" v-if="!modelStats.models || !modelStats.models.length">
<span class="usage-number">—</span>
<span class="usage-label"></span>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(true)
const overview = ref({})
const events = ref([])
const users = ref([])
const usageStats = ref({ rows: [], totalTokens: 0, totalCalls: 0 })
const modelStats = ref({ models: [] })
const totalEventsCount = ref(0)
onMounted(async () => {
// 检查管理员权限
if (!authStore.isAdmin) {
alert('需要管理员权限')
router.push('/')
return
}
await loadData()
})
async function loadData() {
loading.value = true
try {
const API_BASE = 'http://localhost:3001/api'
const headers = { 'Authorization': `Bearer ${authStore.token}` }
async function apiFetch(url) {
const res = await fetch(url, { headers })
const data = await res.json()
if (!res.ok) throw new Error(data.error || `请求失败: ${res.status}`)
return data
}
// 并行加载所有数据
const [overviewData, eventsData, usersData, usageData, modelsData] = await Promise.all([
apiFetch(`${API_BASE}/stats/admin/overview`),
apiFetch(`${API_BASE}/stats/admin/events-by-type?days=30`),
apiFetch(`${API_BASE}/stats/admin/users`),
apiFetch(`${API_BASE}/stats/admin/usage/tokens?days=30`),
apiFetch(`${API_BASE}/stats/admin/usage/models?days=30`)
])
overview.value = overviewData.overview
events.value = eventsData.events
totalEventsCount.value = eventsData.events.reduce((sum, e) => sum + e.count, 0)
users.value = usersData.users
usageStats.value = usageData
modelStats.value = modelsData
} catch (err) {
console.error('加载数据失败:', err)
alert('加载数据失败')
} finally {
loading.value = false
}
}
function formatNumber(n) {
if (!n && n !== 0) return '—'
return n.toLocaleString()
}
function formatDuration(ms) {
if (!ms) return '—'
if (ms < 1000) return ms + 'ms'
if (ms < 60000) return (ms / 1000).toFixed(1) + 's'
return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's'
}
function getEventLabel(type) {
const labels = {
'page_view': '页面访问',
'plan_create': '创建行程',
'plan_load': '加载行程',
'plan_export': '导出行程',
'chat_start': '开始聊天',
'scheme_select': '选择方案',
'user_register': '用户注册',
'user_login': '用户登录',
'guest_data_migrated': '游客数据迁移',
'chat': 'AI 对话',
'gather': '需求收集',
'generate': '方案生成',
'quick_plan': '快速规划',
'custom_plan': '自定义规划',
'replace': 'AI 替换',
'enrich': 'AI 充实',
'reorganize': 'AI 重排'
}
return labels[type] || type
}
function getPercentage(count) {
if (totalEventsCount.value === 0) return 0
return ((count / totalEventsCount.value) * 100).toFixed(1)
}
function formatDate(dateStr) {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<style scoped>
.admin-page {
min-height: 100vh;
background: #f5f7fa;
padding: 20px;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.admin-header h1 {
font-size: 28px;
color: #2d3436;
}
.admin-header h1 .header-slogan {
font-size: 14px;
font-weight: 400;
color: #b2bec3;
margin-left: 12px;
padding-left: 12px;
border-left: 1px solid #dfe6e9;
letter-spacing: 0.5px;
}
.back-btn {
background: #6c5ce7;
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s;
}
.back-btn:hover {
opacity: 0.9;
}
.admin-loading {
text-align: center;
padding: 40px;
color: #636e72;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 24px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.stat-value {
font-size: 36px;
font-weight: 700;
color: #6c5ce7;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #636e72;
}
.admin-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.admin-section {
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.admin-section h2 {
font-size: 18px;
color: #2d3436;
margin-bottom: 16px;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e9ecef;
}
.data-table th {
font-size: 13px;
font-weight: 600;
color: #636e72;
background: #f5f7fa;
}
.data-table td {
font-size: 14px;
color: #2d3436;
}
.data-table tr:hover {
background: #f8f9ff;
}
.role-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.role-badge.admin {
background: #ffe0e0;
color: #d63031;
}
.role-badge.user {
background: #e8e4ff;
color: #6c5ce7;
}
/* Usage stats */
.usage-summary {
display: flex;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.usage-stat {
background: #f5f7fa;
border-radius: 10px;
padding: 14px 20px;
text-align: center;
min-width: 120px;
flex: 1;
}
.usage-number {
display: block;
font-size: 28px;
font-weight: 700;
color: #6c5ce7;
}
.usage-label {
display: block;
font-size: 12px;
color: #636e72;
margin-top: 4px;
}
.empty-hint {
text-align: center;
padding: 30px;
color: #b2bec3;
font-size: 14px;
}
</style>