|
|
|
|
|
<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>
|