parent
27d65ea08f
commit
b43fa24165
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,162 @@
|
|||||||
|
// 使用统计和管理员路由
|
||||||
|
import express from 'express'
|
||||||
|
import { db } from '../db/database.js'
|
||||||
|
import { requireAdmin } from './auth.js'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 记录事件(前端调用)
|
||||||
|
router.post('/event', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId, guestId, eventType, eventData } = req.body
|
||||||
|
|
||||||
|
if (!eventType) {
|
||||||
|
return res.status(400).json({ error: '事件类型不能为空' })
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('INSERT INTO stats (user_id, guest_id, event_type, event_data) VALUES (?, ?, ?, ?)').run(
|
||||||
|
userId || null,
|
||||||
|
guestId || null,
|
||||||
|
eventType,
|
||||||
|
eventData ? JSON.stringify(eventData) : null
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ message: '事件记录成功' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('记录事件失败:', err)
|
||||||
|
res.status(500).json({ error: '记录事件失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 管理员:获取总体统计数据
|
||||||
|
router.get('/admin/overview', requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
// 用户总数
|
||||||
|
const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get()
|
||||||
|
|
||||||
|
// 管理员数量
|
||||||
|
const adminCount = db.prepare('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']).get()
|
||||||
|
|
||||||
|
// 总计划数
|
||||||
|
const totalPlans = db.prepare('SELECT COUNT(*) as count FROM plans').get()
|
||||||
|
|
||||||
|
// 游客计划数(未过期)
|
||||||
|
const guestPlans = db.prepare('SELECT COUNT(*) as count FROM guest_plans WHERE expires_at > ?', [new Date().toISOString()]).get()
|
||||||
|
|
||||||
|
// 总事件数
|
||||||
|
const totalEvents = db.prepare('SELECT COUNT(*) as count FROM stats').get()
|
||||||
|
|
||||||
|
// 今日事件数
|
||||||
|
const todayEvents = db.prepare(`SELECT COUNT(*) as count FROM stats WHERE date(created_at) = date('now')`).get()
|
||||||
|
|
||||||
|
// 今日活跃用户
|
||||||
|
const todayActiveUsers = db.prepare(`SELECT COUNT(DISTINCT user_id) as count FROM stats WHERE date(created_at) = date('now') AND user_id IS NOT NULL`).get()
|
||||||
|
|
||||||
|
// 今日活跃游客
|
||||||
|
const todayActiveGuests = db.prepare(`SELECT COUNT(DISTINCT guest_id) as count FROM stats WHERE date(created_at) = date('now') AND guest_id IS NOT NULL`).get()
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
overview: {
|
||||||
|
totalUsers: totalUsers.count,
|
||||||
|
adminCount: adminCount.count,
|
||||||
|
totalPlans: totalPlans.count,
|
||||||
|
activeGuestPlans: guestPlans.count,
|
||||||
|
totalEvents: totalEvents.count,
|
||||||
|
todayEvents: todayEvents.count,
|
||||||
|
todayActiveUsers: todayActiveUsers.count,
|
||||||
|
todayActiveGuests: todayActiveGuests.count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取统计数据失败:', err)
|
||||||
|
res.status(500).json({ error: '获取统计数据失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 管理员:按类型统计事件
|
||||||
|
router.get('/admin/events-by-type', requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { days = 30 } = req.query
|
||||||
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
|
||||||
|
const events = db.prepare(`
|
||||||
|
SELECT event_type, COUNT(*) as count
|
||||||
|
FROM stats
|
||||||
|
WHERE created_at > ?
|
||||||
|
GROUP BY event_type
|
||||||
|
ORDER BY count DESC
|
||||||
|
`).all(since)
|
||||||
|
|
||||||
|
res.json({ events, days })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取事件统计失败:', err)
|
||||||
|
res.status(500).json({ error: '获取事件统计失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 管理员:每日活跃趋势
|
||||||
|
router.get('/admin/daily-active', requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { days = 30 } = req.query
|
||||||
|
|
||||||
|
const dailyUsers = db.prepare(`
|
||||||
|
SELECT date(created_at) as date, COUNT(DISTINCT user_id) as users, COUNT(DISTINCT guest_id) as guests
|
||||||
|
FROM stats
|
||||||
|
WHERE created_at > datetime('now', ?)
|
||||||
|
GROUP BY date(created_at)
|
||||||
|
ORDER BY date ASC
|
||||||
|
`).all(`-${days} days`)
|
||||||
|
|
||||||
|
res.json({ daily: dailyUsers, days })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取活跃趋势失败:', err)
|
||||||
|
res.status(500).json({ error: '获取活跃趋势失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 管理员:用户列表
|
||||||
|
router.get('/admin/users', requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const users = db.prepare(`
|
||||||
|
SELECT u.id, u.username, u.email, u.role, u.created_at,
|
||||||
|
COUNT(DISTINCT p.id) as plan_count,
|
||||||
|
COUNT(DISTINCT s.id) as event_count,
|
||||||
|
MAX(s.created_at) as last_active
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN plans p ON u.id = p.user_id
|
||||||
|
LEFT JOIN stats s ON u.id = s.user_id
|
||||||
|
GROUP BY u.id
|
||||||
|
ORDER BY u.created_at DESC
|
||||||
|
`).all()
|
||||||
|
|
||||||
|
res.json({ users })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取用户列表失败:', err)
|
||||||
|
res.status(500).json({ error: '获取用户列表失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 管理员:热门行程
|
||||||
|
router.get('/admin/popular-plans', requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { limit = 10 } = req.query
|
||||||
|
|
||||||
|
const plans = db.prepare(`
|
||||||
|
SELECT p.id, p.name, p.description, p.created_at, u.username,
|
||||||
|
COUNT(s.id) as view_count
|
||||||
|
FROM plans p
|
||||||
|
LEFT JOIN users u ON p.user_id = u.id
|
||||||
|
LEFT JOIN stats s ON s.event_data LIKE '%' || p.id || '%' AND s.event_type = 'plan_load'
|
||||||
|
GROUP BY p.id
|
||||||
|
ORDER BY view_count DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(limit)
|
||||||
|
|
||||||
|
res.json({ plans })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取热门行程失败:', err)
|
||||||
|
res.status(500).json({ error: '获取热门行程失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
// 使用统计服务
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const API_BASE = 'http://localhost:3001/api'
|
||||||
|
|
||||||
|
export function trackEvent(eventType, eventData = {}) {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
userId: authStore.user?.id || null,
|
||||||
|
guestId: authStore.guestId || null,
|
||||||
|
eventType,
|
||||||
|
eventData
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步发送,不阻塞主流程
|
||||||
|
fetch(`${API_BASE}/stats/event`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}).catch(err => console.error('统计记录失败:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面访问统计
|
||||||
|
export function trackPageView(pageName, extra = {}) {
|
||||||
|
trackEvent('page_view', { page: pageName, ...extra })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规划创建统计
|
||||||
|
export function trackPlanCreate(planName, mode) {
|
||||||
|
trackEvent('plan_create', { planName, mode })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规划加载统计
|
||||||
|
export function trackPlanLoad(planId) {
|
||||||
|
trackEvent('plan_load', { planId })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规划导出统计
|
||||||
|
export function trackPlanExport(planId, format) {
|
||||||
|
trackEvent('plan_export', { planId, format })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聊天开始统计
|
||||||
|
export function trackChatStart(mode) {
|
||||||
|
trackEvent('chat_start', { mode })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方案选择统计
|
||||||
|
export function trackSchemeSelect(schemeName, schemeIndex) {
|
||||||
|
trackEvent('scheme_select', { schemeName, schemeIndex })
|
||||||
|
}
|
||||||
@ -0,0 +1,218 @@
|
|||||||
|
// 用户认证状态管理
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const API_BASE = 'http://localhost:3001/api'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
// 状态
|
||||||
|
const user = ref(null)
|
||||||
|
const token = ref(localStorage.getItem('auth_token') || null)
|
||||||
|
const guestId = ref(localStorage.getItem('guest_id') || null)
|
||||||
|
const guestExpiresAt = ref(localStorage.getItem('guest_expires_at') || null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const isAuthenticated = computed(() => !!user.value && !!token.value)
|
||||||
|
const isGuest = computed(() => !!guestId.value && !user.value)
|
||||||
|
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||||
|
const isGuestDataExpiring = computed(() => {
|
||||||
|
if (!guestExpiresAt.value) return false
|
||||||
|
const expires = new Date(guestExpiresAt.value)
|
||||||
|
const now = new Date()
|
||||||
|
const hoursLeft = (expires - now) / (1000 * 60 * 60)
|
||||||
|
return hoursLeft < 2 // 少于2小时提醒
|
||||||
|
})
|
||||||
|
const isGuestDataExpired = computed(() => {
|
||||||
|
if (!guestExpiresAt.value) return false
|
||||||
|
return new Date(guestExpiresAt.value) < new Date()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
function setAuthHeader() {
|
||||||
|
if (token.value) {
|
||||||
|
return { 'Authorization': `Bearer ${token.value}` }
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || '登录失败')
|
||||||
|
|
||||||
|
user.value = data.user
|
||||||
|
token.value = data.token
|
||||||
|
localStorage.setItem('auth_token', data.token)
|
||||||
|
|
||||||
|
// 如果有游客数据,提示迁移
|
||||||
|
return { success: true, user: data.user }
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(username, email, password) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, email, password })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || '注册失败')
|
||||||
|
|
||||||
|
user.value = data.user
|
||||||
|
token.value = data.token
|
||||||
|
localStorage.setItem('auth_token', data.token)
|
||||||
|
|
||||||
|
return { success: true, user: data.user }
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
user.value = null
|
||||||
|
token.value = null
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initGuestSession() {
|
||||||
|
// 检查现有游客会话是否过期
|
||||||
|
if (guestId.value && guestExpiresAt.value) {
|
||||||
|
const expires = new Date(guestExpiresAt.value)
|
||||||
|
if (expires > new Date()) {
|
||||||
|
return guestId.value // 会话仍有效
|
||||||
|
}
|
||||||
|
// 过期了,清除
|
||||||
|
clearGuestSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新游客会话
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/guest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || '创建游客会话失败')
|
||||||
|
|
||||||
|
guestId.value = data.guestId
|
||||||
|
guestExpiresAt.value = data.expiresAt
|
||||||
|
localStorage.setItem('guest_id', data.guestId)
|
||||||
|
localStorage.setItem('guest_expires_at', data.expiresAt)
|
||||||
|
|
||||||
|
return data.guestId
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearGuestSession() {
|
||||||
|
guestId.value = null
|
||||||
|
guestExpiresAt.value = null
|
||||||
|
localStorage.removeItem('guest_id')
|
||||||
|
localStorage.removeItem('guest_expires_at')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateGuestData() {
|
||||||
|
if (!token.value || !guestId.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/migrate-guest-data`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...setAuthHeader()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ guestId: guestId.value })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || '数据迁移失败')
|
||||||
|
|
||||||
|
clearGuestSession()
|
||||||
|
return data
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCurrentUser() {
|
||||||
|
if (!token.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/me`, {
|
||||||
|
headers: setAuthHeader()
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
user.value = data.user
|
||||||
|
} else {
|
||||||
|
// Token 无效,清除
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载用户信息失败:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化时加载用户信息
|
||||||
|
async function init() {
|
||||||
|
if (token.value) {
|
||||||
|
await loadCurrentUser()
|
||||||
|
} else {
|
||||||
|
await initGuestSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
guestId,
|
||||||
|
guestExpiresAt,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isAuthenticated,
|
||||||
|
isGuest,
|
||||||
|
isAdmin,
|
||||||
|
isGuestDataExpiring,
|
||||||
|
isGuestDataExpired,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
initGuestSession,
|
||||||
|
clearGuestSession,
|
||||||
|
migrateGuestData,
|
||||||
|
loadCurrentUser,
|
||||||
|
init,
|
||||||
|
setAuthHeader
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in new issue