Kaynağa Gözat

Enhance production environment configuration and add new dashboard and audit features. Update API base URL for production, introduce audit log functionality, and implement dashboard statistics overview. Add new routes and views for dashboard and audit logs, improving overall application monitoring and management capabilities.

yb 3 hafta önce
ebeveyn
işleme
cff8ab19e0

+ 4 - 2
.env.production

@@ -1,6 +1,8 @@
 # 生产环境
+NODE_ENV=production
+
 # 应用标题
 VITE_APP_TITLE=摄像头管理系统
 
-# API 基础路径
-VITE_APP_BASE_API=/api
+# API 基础路径 (生产环境使用完整 URL,因为 Cloudflare Pages 没有代理)
+VITE_APP_BASE_API=https://tg-live-game-api.ifoodme.com/api

+ 23 - 1
CLAUDE.md

@@ -68,12 +68,15 @@ src/
 | 路由 | 页面 | 描述 |
 |------|------|------|
 | `/login` | 登录页 | 用户登录 |
+| `/dashboard` | 仪表盘 | 系统统计概览 (默认首页) |
 | `/camera` | 摄像头管理 | 摄像头列表 |
 | `/stream-test` | Stream 测试 | 播放测试 |
 | `/stream/videos` | 视频管理 | 视频列表 |
 | `/stream/live` | 直播管理 | 直播列表 |
 | `/stream/config` | Stream 配置 | 配置管理 |
 | `/user` | 用户管理 | 用户列表 (admin) |
+| `/stats` | 观看统计 | 观看数据统计分析 |
+| `/audit` | 审计日志 | 系统操作日志 (admin) |
 
 ## API 依赖
 
@@ -105,7 +108,26 @@ pnpm preview          # 预览生产版本
 
 | 变量 | 开发环境 | 生产环境 |
 |------|----------|----------|
-| VITE_API_BASE_URL | http://localhost:8787 | https://tg-live-game-api.ifoodme.com |
+| VITE_APP_BASE_API | /api (Vite 代理) | https://tg-live-game-api.ifoodme.com/api |
+| VITE_APP_TITLE | 摄像头管理系统 | 摄像头管理系统 |
+
+## 部署配置
+
+| 属性 | 值 |
+|------|-----|
+| 部署平台 | Cloudflare Pages |
+| 项目名称 | tg-live-game |
+| 生产域名 | https://tg-live-game.pwtk.cc |
+| 构建命令 | pnpm build |
+| 输出目录 | dist |
+| CI/CD | Jenkins Pipeline |
+
+### Jenkins 凭据要求
+
+| 凭据 ID | 类型 | 用途 |
+|---------|------|------|
+| CLOUDFLARE_API_TOKEN | Secret text | Cloudflare API Token |
+| CLOUDFLARE_ACCOUNT_ID | Secret text | Cloudflare Account ID |
 
 ## 核心文档
 

+ 33 - 1
Jenkinsfile

@@ -37,10 +37,15 @@ pipeline {
         TELEGRAM_BOT_TOKEN = '8132916684:AAEd1oObTMPxxcLV6LICKIDTPf1vBSn2rGk'
         TELEGRAM_CHAT_ID = '7061322031'
 
-        // 禅道配置 (可选)
+        // 禅道配置 (可选,设置 ZENTAO_ENABLED=true 启用)
+        ZENTAO_ENABLED = 'false'  // 默认关闭,需要配置好凭据后开启
         ZENTAO_PRODUCT_ID = '1'
         ZENTAO_API_URL = 'https://zentao.pwtk.cc/zentao/api.php/v1'
         ZENTAO_BUG_BASE_URL = 'https://zentao.pwtk.cc/zentao/bug-view-'
+        // 禅道凭据: 在 Jenkins 中配置 ZENTAO_CREDENTIALS (username/password)
+
+        // Git 提交链接配置
+        COMMIT_BASE_URL = 'https://github.com/pwtk/tg-live-game-web/commit/'
     }
 
     options {
@@ -419,6 +424,33 @@ pipeline {
                     echo "${LAST_COMMIT_HASH}" > ${WORKSPACE}/.last_success_commit
                     log_message "保存成功构建标记: ${LAST_COMMIT_HASH}"
                 '''
+
+                // 禅道 Bug 自动解决 (可选功能)
+                script {
+                    if (env.ZENTAO_ENABLED == 'true') {
+                        withCredentials([
+                            usernamePassword(credentialsId: 'ZENTAO_CREDENTIALS',
+                                           usernameVariable: 'ZENTAO_USERNAME',
+                                           passwordVariable: 'ZENTAO_PASSWORD')
+                        ]) {
+                            sh '''
+                                source ${WORKSPACE}/jenkins/lib/logger.sh
+                                source ${WORKSPACE}/jenkins/lib/telegram.sh
+                                source ${WORKSPACE}/jenkins/lib/lark_dev.sh
+                                source ${WORKSPACE}/jenkins/lib/zentao_dev.sh
+
+                                log_message "=============== 禅道 Bug 追踪 ==============="
+
+                                # 调用禅道 Bug 自动解决
+                                resolve_zentao_bugs "${WORKSPACE}" || true
+
+                                log_message "=============== 禅道 Bug 追踪完成 ==============="
+                            '''
+                        }
+                    } else {
+                        echo "禅道集成未启用 (设置 ZENTAO_ENABLED=true 启用)"
+                    }
+                }
             }
         }
 

+ 63 - 0
src/api/audit.ts

@@ -0,0 +1,63 @@
+import { get } from '@/utils/request'
+import type { ApiResponse, PageResult } from '@/types'
+
+// 审计日志类型
+export interface AuditLog {
+  id: string
+  user_id: string
+  username?: string
+  action: string
+  resource: string
+  resource_id?: string
+  details?: string
+  parsedDetails?: Record<string, any>
+  ip_address?: string
+  user_agent?: string
+  created_at: number
+}
+
+// 查询参数
+export interface AuditLogQueryParams {
+  action?: string
+  resource?: string
+  user_id?: string
+  start_date?: string
+  end_date?: string
+  page?: number
+  pageSize?: number
+}
+
+// 统计数据
+export interface AuditLogStats {
+  period_days: number
+  total: number
+  by_action: Array<{ action: string; count: number }>
+  by_resource: Array<{ resource: string; count: number }>
+  by_user: Array<{ user_id: string; username: string; count: number }>
+  daily: Array<{ date: string; count: number }>
+}
+
+// 获取审计日志列表
+export function getAuditLogs(params?: AuditLogQueryParams): Promise<ApiResponse<PageResult<AuditLog>>> {
+  return get('/audit-logs', params)
+}
+
+// 获取审计日志详情
+export function getAuditLogDetail(id: string): Promise<ApiResponse<AuditLog>> {
+  return get(`/audit-logs/${id}`)
+}
+
+// 获取审计日志统计
+export function getAuditLogStats(days = 7): Promise<ApiResponse<AuditLogStats>> {
+  return get('/audit-logs/stats/summary', { days })
+}
+
+// 获取用户操作历史
+export function getUserAuditLogs(userId: string, params?: { page?: number; pageSize?: number }): Promise<ApiResponse<PageResult<AuditLog>>> {
+  return get(`/audit-logs/user/${userId}`, params)
+}
+
+// 获取资源操作历史
+export function getResourceAuditLogs(resource: string, resourceId: string, params?: { page?: number; pageSize?: number }): Promise<ApiResponse<PageResult<AuditLog>>> {
+  return get(`/audit-logs/resource/${resource}/${resourceId}`, params)
+}

+ 69 - 0
src/api/stats.ts

@@ -0,0 +1,69 @@
+import { get } from '@/utils/request'
+import type { ApiResponse } from '@/types'
+
+// 用户统计
+export interface UserStats {
+  total: number
+  admin_count: number
+  operator_count: number
+  viewer_count: number
+  active_count: number
+  inactive_count: number
+}
+
+// 摄像头统计
+export interface CameraStats {
+  total: number
+  online_count: number
+  offline_count: number
+  error_count: number
+}
+
+// 视频统计
+export interface VideoStats {
+  total: number
+  total_views: number
+  total_duration: number
+}
+
+// 直播会话统计
+export interface LiveSessionStats {
+  total: number
+  live_count: number
+  ended_count: number
+  max_peak_viewers: number
+}
+
+// 今日统计
+export interface TodayStats {
+  today_views: number
+  today_new_users: number
+  today_sessions: number
+}
+
+// 系统状态
+export interface SystemStatus {
+  database: string
+  api_version: string
+  uptime: number
+}
+
+// 仪表盘数据
+export interface DashboardData {
+  users: UserStats
+  cameras: CameraStats
+  videos: VideoStats
+  live_sessions: LiveSessionStats
+  today: TodayStats
+  system: SystemStatus
+}
+
+// 获取仪表盘统计数据
+export function getDashboardStats(): Promise<ApiResponse<DashboardData>> {
+  return get('/stats/dashboard')
+}
+
+// 获取统计概览
+export function getStatsOverview(days = 7): Promise<ApiResponse<any>> {
+  return get('/stats/overview', { days })
+}

+ 19 - 1
src/router/index.ts

@@ -13,8 +13,14 @@ const routes: RouteRecordRaw[] = [
   {
     path: '/',
     component: Layout,
-    redirect: '/camera',
+    redirect: '/dashboard',
     children: [
+      {
+        path: 'dashboard',
+        name: 'Dashboard',
+        component: () => import('@/views/dashboard/index.vue'),
+        meta: { title: '仪表盘', icon: 'DataLine' }
+      },
       {
         path: 'camera',
         name: 'Camera',
@@ -62,6 +68,18 @@ const routes: RouteRecordRaw[] = [
         name: 'User',
         component: () => import('@/views/user/index.vue'),
         meta: { title: '用户管理', icon: 'User' }
+      },
+      {
+        path: 'stats',
+        name: 'Stats',
+        component: () => import('@/views/stats/index.vue'),
+        meta: { title: '观看统计', icon: 'DataAnalysis' }
+      },
+      {
+        path: 'audit',
+        name: 'Audit',
+        component: () => import('@/views/audit/index.vue'),
+        meta: { title: '审计日志', icon: 'Document' }
       }
     ]
   },

+ 341 - 0
src/views/audit/index.vue

@@ -0,0 +1,341 @@
+<template>
+  <div class="audit-container">
+    <!-- 搜索区域 -->
+    <el-card class="search-card" shadow="hover">
+      <el-form :model="queryParams" inline>
+        <el-form-item label="操作类型">
+          <el-select v-model="queryParams.action" placeholder="全部" clearable style="width: 120px">
+            <el-option label="创建" value="create" />
+            <el-option label="更新" value="update" />
+            <el-option label="删除" value="delete" />
+            <el-option label="登录" value="login" />
+            <el-option label="登出" value="logout" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="资源类型">
+          <el-select v-model="queryParams.resource" placeholder="全部" clearable style="width: 120px">
+            <el-option label="用户" value="user" />
+            <el-option label="摄像头" value="camera" />
+            <el-option label="视频" value="video" />
+            <el-option label="直播" value="session" />
+            <el-option label="认证" value="auth" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="时间范围">
+          <el-date-picker
+            v-model="dateRange"
+            type="daterange"
+            range-separator="至"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            value-format="YYYY-MM-DD"
+            style="width: 240px"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Search" @click="handleQuery">搜索</el-button>
+          <el-button :icon="Refresh" @click="resetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 统计卡片 -->
+    <el-row :gutter="20" class="stat-row">
+      <el-col :xs="12" :sm="6">
+        <el-card shadow="hover" class="stat-card">
+          <el-statistic title="总操作数" :value="total" />
+        </el-card>
+      </el-col>
+      <el-col :xs="12" :sm="6">
+        <el-card shadow="hover" class="stat-card">
+          <el-statistic title="创建操作" :value="getActionCount('create')" />
+        </el-card>
+      </el-col>
+      <el-col :xs="12" :sm="6">
+        <el-card shadow="hover" class="stat-card">
+          <el-statistic title="更新操作" :value="getActionCount('update')" />
+        </el-card>
+      </el-col>
+      <el-col :xs="12" :sm="6">
+        <el-card shadow="hover" class="stat-card">
+          <el-statistic title="删除操作" :value="getActionCount('delete')" />
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 数据表格 -->
+    <el-card shadow="hover">
+      <template #header>
+        <div class="card-header">
+          <span>审计日志列表</span>
+          <el-button type="success" :icon="Refresh" link @click="getList">刷新</el-button>
+        </div>
+      </template>
+
+      <el-table v-loading="loading" :data="auditList" border stripe>
+        <el-table-column prop="created_at" label="时间" width="170" align="center">
+          <template #default="{ row }">
+            {{ formatTime(row.created_at) }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="username" label="操作用户" width="120" align="center">
+          <template #default="{ row }">
+            {{ row.username || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="action" label="操作类型" width="100" align="center">
+          <template #default="{ row }">
+            <el-tag :type="getActionTagType(row.action)" size="small">
+              {{ getActionLabel(row.action) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="resource" label="资源类型" width="100" align="center">
+          <template #default="{ row }">
+            <el-tag type="info" size="small">{{ getResourceLabel(row.resource) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="resource_id" label="资源 ID" width="120" show-overflow-tooltip />
+        <el-table-column prop="ip_address" label="IP 地址" width="130" show-overflow-tooltip />
+        <el-table-column label="详情" min-width="200">
+          <template #default="{ row }">
+            <span v-if="row.parsedDetails" class="details-text">
+              {{ formatDetails(row.parsedDetails) }}
+            </span>
+            <span v-else class="details-text">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="80" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" link size="small" @click="showDetail(row)">
+              详情
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- 分页 -->
+      <el-pagination
+        v-model:current-page="queryParams.page"
+        v-model:page-size="queryParams.pageSize"
+        :page-sizes="[20, 50, 100]"
+        :total="total"
+        layout="total, sizes, prev, pager, next, jumper"
+        class="pagination"
+        @size-change="getList"
+        @current-change="getList"
+      />
+    </el-card>
+
+    <!-- 详情弹窗 -->
+    <el-dialog v-model="detailVisible" title="审计日志详情" width="600px">
+      <el-descriptions v-if="currentLog" :column="1" border>
+        <el-descriptions-item label="日志 ID">{{ currentLog.id }}</el-descriptions-item>
+        <el-descriptions-item label="操作时间">{{ formatTime(currentLog.created_at) }}</el-descriptions-item>
+        <el-descriptions-item label="操作用户">{{ currentLog.username || currentLog.user_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="操作类型">
+          <el-tag :type="getActionTagType(currentLog.action)">{{ getActionLabel(currentLog.action) }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="资源类型">{{ getResourceLabel(currentLog.resource) }}</el-descriptions-item>
+        <el-descriptions-item label="资源 ID">{{ currentLog.resource_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="IP 地址">{{ currentLog.ip_address || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="User Agent">
+          <span class="user-agent-text">{{ currentLog.user_agent || '-' }}</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="详细信息">
+          <pre v-if="currentLog.parsedDetails" class="details-json">{{ JSON.stringify(currentLog.parsedDetails, null, 2) }}</pre>
+          <span v-else>-</span>
+        </el-descriptions-item>
+      </el-descriptions>
+      <template #footer>
+        <el-button @click="detailVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Search, Refresh } from '@element-plus/icons-vue'
+import { getAuditLogs, type AuditLog, type AuditLogQueryParams } from '@/api/audit'
+
+const loading = ref(false)
+const auditList = ref<AuditLog[]>([])
+const total = ref(0)
+const detailVisible = ref(false)
+const currentLog = ref<AuditLog | null>(null)
+const dateRange = ref<[string, string] | null>(null)
+
+const queryParams = reactive<AuditLogQueryParams & { page: number; pageSize: number }>({
+  action: '',
+  resource: '',
+  user_id: '',
+  start_date: '',
+  end_date: '',
+  page: 1,
+  pageSize: 20
+})
+
+// 简单的操作计数 (基于当前页数据)
+function getActionCount(action: string): number {
+  return auditList.value.filter(log => log.action === action).length
+}
+
+function getActionLabel(action: string): string {
+  const map: Record<string, string> = {
+    create: '创建',
+    update: '更新',
+    delete: '删除',
+    login: '登录',
+    logout: '登出',
+    view: '查看',
+  }
+  return map[action] || action
+}
+
+function getActionTagType(action: string): '' | 'success' | 'warning' | 'danger' | 'info' {
+  const map: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
+    create: 'success',
+    update: 'warning',
+    delete: 'danger',
+    login: '',
+    logout: 'info',
+    view: 'info',
+  }
+  return map[action] || 'info'
+}
+
+function getResourceLabel(resource: string): string {
+  const map: Record<string, string> = {
+    user: '用户',
+    camera: '摄像头',
+    video: '视频',
+    session: '直播',
+    live_input: '直播源',
+    auth: '认证',
+  }
+  return map[resource] || resource
+}
+
+function formatTime(timestamp: number): string {
+  if (!timestamp) return '-'
+  return new Date(timestamp * 1000).toLocaleString('zh-CN')
+}
+
+function formatDetails(details: Record<string, any>): string {
+  if (!details) return '-'
+  const parts: string[] = []
+  if (details.message) parts.push(details.message)
+  if (details.title) parts.push(`标题: ${details.title}`)
+  if (details.name) parts.push(`名称: ${details.name}`)
+  if (details.username) parts.push(`用户: ${details.username}`)
+  return parts.length > 0 ? parts.join(', ') : JSON.stringify(details).substring(0, 50) + '...'
+}
+
+function showDetail(row: AuditLog) {
+  currentLog.value = row
+  detailVisible.value = true
+}
+
+async function getList() {
+  loading.value = true
+  try {
+    const params: any = {
+      page: queryParams.page,
+      pageSize: queryParams.pageSize
+    }
+    if (queryParams.action) params.action = queryParams.action
+    if (queryParams.resource) params.resource = queryParams.resource
+    if (queryParams.user_id) params.user_id = queryParams.user_id
+    if (dateRange.value) {
+      params.start_date = dateRange.value[0]
+      params.end_date = dateRange.value[1]
+    }
+
+    const res = await getAuditLogs(params)
+    if (res.code === 200) {
+      auditList.value = res.data?.rows || []
+      total.value = res.data?.total || 0
+    } else {
+      ElMessage.error(res.msg || '获取审计日志失败')
+    }
+  } catch (error) {
+    console.error('Failed to load audit logs:', error)
+    ElMessage.error('获取审计日志失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleQuery() {
+  queryParams.page = 1
+  getList()
+}
+
+function resetQuery() {
+  queryParams.action = ''
+  queryParams.resource = ''
+  queryParams.user_id = ''
+  dateRange.value = null
+  queryParams.page = 1
+  getList()
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+.audit-container {
+  padding: 20px;
+}
+
+.search-card {
+  margin-bottom: 20px;
+}
+
+.stat-row {
+  margin-bottom: 20px;
+
+  .el-col {
+    margin-bottom: 15px;
+  }
+
+  .stat-card {
+    text-align: center;
+  }
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.pagination {
+  margin-top: 20px;
+  justify-content: flex-end;
+}
+
+.details-text {
+  font-size: 12px;
+  color: #606266;
+}
+
+.user-agent-text {
+  font-size: 12px;
+  word-break: break-all;
+}
+
+.details-json {
+  font-size: 12px;
+  background: #f5f7fa;
+  padding: 10px;
+  border-radius: 4px;
+  max-height: 200px;
+  overflow: auto;
+  margin: 0;
+}
+</style>

+ 302 - 0
src/views/dashboard/index.vue

@@ -0,0 +1,302 @@
+<template>
+  <div class="dashboard-container">
+    <!-- 统计卡片 -->
+    <el-row :gutter="20" class="stat-cards">
+      <el-col :xs="24" :sm="12" :lg="6">
+        <el-card class="stat-card users" shadow="hover">
+          <div class="stat-content">
+            <div class="stat-icon">
+              <el-icon :size="40"><User /></el-icon>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ dashboardData?.users?.total || 0 }}</div>
+              <div class="stat-label">用户总数</div>
+            </div>
+          </div>
+          <div class="stat-footer">
+            <span>管理员: {{ dashboardData?.users?.admin_count || 0 }}</span>
+            <span>操作员: {{ dashboardData?.users?.operator_count || 0 }}</span>
+            <span>观察者: {{ dashboardData?.users?.viewer_count || 0 }}</span>
+          </div>
+        </el-card>
+      </el-col>
+
+      <el-col :xs="24" :sm="12" :lg="6">
+        <el-card class="stat-card cameras" shadow="hover">
+          <div class="stat-content">
+            <div class="stat-icon">
+              <el-icon :size="40"><VideoCamera /></el-icon>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ dashboardData?.cameras?.total || 0 }}</div>
+              <div class="stat-label">摄像头总数</div>
+            </div>
+          </div>
+          <div class="stat-footer">
+            <span class="online">在线: {{ dashboardData?.cameras?.online_count || 0 }}</span>
+            <span class="offline">离线: {{ dashboardData?.cameras?.offline_count || 0 }}</span>
+            <span class="error">异常: {{ dashboardData?.cameras?.error_count || 0 }}</span>
+          </div>
+        </el-card>
+      </el-col>
+
+      <el-col :xs="24" :sm="12" :lg="6">
+        <el-card class="stat-card videos" shadow="hover">
+          <div class="stat-content">
+            <div class="stat-icon">
+              <el-icon :size="40"><Film /></el-icon>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ dashboardData?.videos?.total || 0 }}</div>
+              <div class="stat-label">视频总数</div>
+            </div>
+          </div>
+          <div class="stat-footer">
+            <span>总播放: {{ dashboardData?.videos?.total_views || 0 }}</span>
+            <span>总时长: {{ formatDuration(dashboardData?.videos?.total_duration || 0) }}</span>
+          </div>
+        </el-card>
+      </el-col>
+
+      <el-col :xs="24" :sm="12" :lg="6">
+        <el-card class="stat-card live" shadow="hover">
+          <div class="stat-content">
+            <div class="stat-icon">
+              <el-icon :size="40"><VideoCameraFilled /></el-icon>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ dashboardData?.live_sessions?.live_count || 0 }}</div>
+              <div class="stat-label">正在直播</div>
+            </div>
+          </div>
+          <div class="stat-footer">
+            <span>会话总数: {{ dashboardData?.live_sessions?.total || 0 }}</span>
+            <span>峰值观众: {{ dashboardData?.live_sessions?.max_peak_viewers || 0 }}</span>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 今日统计 -->
+    <el-row :gutter="20" class="today-stats">
+      <el-col :span="24">
+        <el-card shadow="hover">
+          <template #header>
+            <div class="card-header">
+              <span>今日统计</span>
+              <el-button type="primary" link :icon="Refresh" @click="loadDashboardData">刷新</el-button>
+            </div>
+          </template>
+          <el-row :gutter="40">
+            <el-col :xs="24" :sm="8">
+              <el-statistic title="今日观看次数" :value="dashboardData?.today?.today_views || 0">
+                <template #prefix>
+                  <el-icon><View /></el-icon>
+                </template>
+              </el-statistic>
+            </el-col>
+            <el-col :xs="24" :sm="8">
+              <el-statistic title="今日新增用户" :value="dashboardData?.today?.today_new_users || 0">
+                <template #prefix>
+                  <el-icon><UserFilled /></el-icon>
+                </template>
+              </el-statistic>
+            </el-col>
+            <el-col :xs="24" :sm="8">
+              <el-statistic title="今日直播场次" :value="dashboardData?.today?.today_sessions || 0">
+                <template #prefix>
+                  <el-icon><Videocamera /></el-icon>
+                </template>
+              </el-statistic>
+            </el-col>
+          </el-row>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 系统状态 -->
+    <el-row :gutter="20" class="system-status">
+      <el-col :span="24">
+        <el-card shadow="hover">
+          <template #header>
+            <div class="card-header">
+              <span>系统状态</span>
+            </div>
+          </template>
+          <el-descriptions :column="3" border>
+            <el-descriptions-item label="数据库状态">
+              <el-tag :type="dashboardData?.system?.database === 'healthy' ? 'success' : 'danger'">
+                {{ dashboardData?.system?.database === 'healthy' ? '正常' : '异常' }}
+              </el-tag>
+            </el-descriptions-item>
+            <el-descriptions-item label="API 版本">
+              {{ dashboardData?.system?.api_version || '-' }}
+            </el-descriptions-item>
+            <el-descriptions-item label="数据更新时间">
+              {{ lastUpdateTime }}
+            </el-descriptions-item>
+          </el-descriptions>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, computed } from 'vue'
+import { ElMessage } from 'element-plus'
+import {
+  User,
+  VideoCamera,
+  Film,
+  VideoCameraFilled,
+  Refresh,
+  View,
+  UserFilled,
+  Videocamera
+} from '@element-plus/icons-vue'
+import { getDashboardStats, type DashboardData } from '@/api/stats'
+
+const loading = ref(false)
+const dashboardData = ref<DashboardData | null>(null)
+const lastUpdate = ref<Date | null>(null)
+
+const lastUpdateTime = computed(() => {
+  if (!lastUpdate.value) return '-'
+  return lastUpdate.value.toLocaleString('zh-CN')
+})
+
+function formatDuration(seconds: number): string {
+  if (!seconds) return '0秒'
+  const hours = Math.floor(seconds / 3600)
+  const minutes = Math.floor((seconds % 3600) / 60)
+  if (hours > 0) {
+    return `${hours}小时${minutes}分钟`
+  }
+  return `${minutes}分钟`
+}
+
+async function loadDashboardData() {
+  loading.value = true
+  try {
+    const res = await getDashboardStats()
+    if (res.code === 200) {
+      dashboardData.value = res.data
+      lastUpdate.value = new Date()
+    } else {
+      ElMessage.error(res.msg || '获取统计数据失败')
+    }
+  } catch (error) {
+    console.error('Failed to load dashboard data:', error)
+    ElMessage.error('获取统计数据失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(() => {
+  loadDashboardData()
+})
+</script>
+
+<style lang="scss" scoped>
+.dashboard-container {
+  padding: 20px;
+}
+
+.stat-cards {
+  margin-bottom: 20px;
+
+  .el-col {
+    margin-bottom: 20px;
+  }
+}
+
+.stat-card {
+  height: 160px;
+
+  .stat-content {
+    display: flex;
+    align-items: center;
+    padding: 10px 0;
+  }
+
+  .stat-icon {
+    width: 80px;
+    height: 80px;
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 20px;
+  }
+
+  .stat-info {
+    flex: 1;
+  }
+
+  .stat-value {
+    font-size: 32px;
+    font-weight: bold;
+    line-height: 1.2;
+  }
+
+  .stat-label {
+    font-size: 14px;
+    color: #909399;
+    margin-top: 5px;
+  }
+
+  .stat-footer {
+    display: flex;
+    justify-content: space-between;
+    padding-top: 10px;
+    border-top: 1px solid #ebeef5;
+    font-size: 12px;
+    color: #909399;
+
+    .online { color: #67c23a; }
+    .offline { color: #909399; }
+    .error { color: #f56c6c; }
+  }
+
+  &.users .stat-icon {
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    color: #fff;
+  }
+
+  &.cameras .stat-icon {
+    background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
+    color: #fff;
+  }
+
+  &.videos .stat-icon {
+    background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+    color: #fff;
+  }
+
+  &.live .stat-icon {
+    background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
+    color: #fff;
+  }
+}
+
+.today-stats {
+  margin-bottom: 20px;
+
+  .el-statistic {
+    text-align: center;
+    padding: 20px 0;
+  }
+}
+
+.system-status {
+  margin-bottom: 20px;
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+</style>

+ 429 - 0
src/views/stats/index.vue

@@ -0,0 +1,429 @@
+<template>
+  <div class="stats-container">
+    <!-- 时间范围选择 -->
+    <el-card class="filter-card" shadow="hover">
+      <el-form inline>
+        <el-form-item label="统计周期">
+          <el-select v-model="days" @change="loadStats">
+            <el-option label="最近 7 天" :value="7" />
+            <el-option label="最近 14 天" :value="14" />
+            <el-option label="最近 30 天" :value="30" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Refresh" @click="loadStats">刷新</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 总体统计 -->
+    <el-row :gutter="20" class="summary-cards">
+      <el-col :xs="12" :sm="6">
+        <el-card shadow="hover" class="summary-card">
+          <el-statistic title="总观看次数" :value="statsData?.total?.total_views || 0">
+            <template #prefix><el-icon><View /></el-icon></template>
+          </el-statistic>
+        </el-card>
+      </el-col>
+      <el-col :xs="12" :sm="6">
+        <el-card shadow="hover" class="summary-card">
+          <el-statistic title="独立用户" :value="statsData?.total?.unique_users || 0">
+            <template #prefix><el-icon><User /></el-icon></template>
+          </el-statistic>
+        </el-card>
+      </el-col>
+      <el-col :xs="12" :sm="6">
+        <el-card shadow="hover" class="summary-card">
+          <el-statistic title="独立 IP" :value="statsData?.total?.unique_ips || 0">
+            <template #prefix><el-icon><Connection /></el-icon></template>
+          </el-statistic>
+        </el-card>
+      </el-col>
+      <el-col :xs="12" :sm="6">
+        <el-card shadow="hover" class="summary-card">
+          <el-statistic title="总观看时长" :value="formatDuration(statsData?.total?.total_watch_time || 0)">
+            <template #prefix><el-icon><Timer /></el-icon></template>
+          </el-statistic>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 每日趋势 -->
+    <el-card class="trend-card" shadow="hover">
+      <template #header>
+        <div class="card-header">
+          <span>每日观看趋势</span>
+        </div>
+      </template>
+      <div class="trend-chart" v-if="statsData?.daily_trend?.length">
+        <div class="chart-bars">
+          <div
+            v-for="item in statsData.daily_trend"
+            :key="item.date"
+            class="bar-item"
+          >
+            <div class="bar-wrapper">
+              <div
+                class="bar"
+                :style="{ height: getBarHeight(item.views) + '%' }"
+                :title="`${item.views} 次观看`"
+              ></div>
+            </div>
+            <div class="bar-label">{{ formatDate(item.date) }}</div>
+            <div class="bar-value">{{ item.views }}</div>
+          </div>
+        </div>
+      </div>
+      <el-empty v-else description="暂无数据" />
+    </el-card>
+
+    <el-row :gutter="20">
+      <!-- 热门视频 -->
+      <el-col :xs="24" :lg="12">
+        <el-card class="rank-card" shadow="hover">
+          <template #header>
+            <div class="card-header">
+              <span>热门视频 TOP 10</span>
+            </div>
+          </template>
+          <el-table :data="statsData?.top_videos || []" stripe size="small">
+            <el-table-column type="index" label="排名" width="60" align="center">
+              <template #default="{ $index }">
+                <el-tag :type="getRankType($index)" size="small">{{ $index + 1 }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="title" label="视频标题" min-width="150" show-overflow-tooltip />
+            <el-table-column prop="recent_views" label="近期观看" width="100" align="center" />
+            <el-table-column prop="view_count" label="总观看" width="80" align="center" />
+          </el-table>
+        </el-card>
+      </el-col>
+
+      <!-- 热门直播 -->
+      <el-col :xs="24" :lg="12">
+        <el-card class="rank-card" shadow="hover">
+          <template #header>
+            <div class="card-header">
+              <span>热门直播 TOP 10</span>
+            </div>
+          </template>
+          <el-table :data="statsData?.top_sessions || []" stripe size="small">
+            <el-table-column type="index" label="排名" width="60" align="center">
+              <template #default="{ $index }">
+                <el-tag :type="getRankType($index)" size="small">{{ $index + 1 }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="camera_name" label="摄像头" min-width="120" show-overflow-tooltip />
+            <el-table-column prop="total_views" label="观看次数" width="100" align="center" />
+            <el-table-column prop="peak_viewers" label="峰值观众" width="100" align="center" />
+          </el-table>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 地区分布 -->
+    <el-card class="geo-card" shadow="hover">
+      <template #header>
+        <div class="card-header">
+          <span>观看地区分布</span>
+        </div>
+      </template>
+      <div class="geo-list" v-if="statsData?.geo_distribution?.length">
+        <div
+          v-for="(item, index) in statsData.geo_distribution.slice(0, 10)"
+          :key="item.country"
+          class="geo-item"
+        >
+          <div class="geo-info">
+            <span class="geo-rank">{{ index + 1 }}</span>
+            <span class="geo-country">{{ getCountryName(item.country) }}</span>
+          </div>
+          <div class="geo-bar-wrapper">
+            <el-progress
+              :percentage="getGeoPercentage(item.count)"
+              :stroke-width="16"
+              :show-text="false"
+            />
+          </div>
+          <span class="geo-count">{{ item.count }}</span>
+        </div>
+      </div>
+      <el-empty v-else description="暂无地区数据" />
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Refresh, View, User, Connection, Timer } from '@element-plus/icons-vue'
+import { getStatsOverview } from '@/api/stats'
+
+interface StatsData {
+  period_days: number
+  total: {
+    total_views: number
+    unique_users: number
+    unique_ips: number
+    total_watch_time: number
+  }
+  daily_trend: Array<{
+    date: string
+    views: number
+    unique_users: number
+    watch_time: number
+  }>
+  top_videos: Array<{
+    id: string
+    title: string
+    view_count: number
+    recent_views: number
+  }>
+  top_sessions: Array<{
+    id: string
+    camera_name: string
+    peak_viewers: number
+    total_views: number
+  }>
+  geo_distribution: Array<{
+    country: string
+    count: number
+  }>
+}
+
+const loading = ref(false)
+const days = ref(7)
+const statsData = ref<StatsData | null>(null)
+
+const maxViews = computed(() => {
+  if (!statsData.value?.daily_trend?.length) return 1
+  return Math.max(...statsData.value.daily_trend.map(d => d.views), 1)
+})
+
+const maxGeoCount = computed(() => {
+  if (!statsData.value?.geo_distribution?.length) return 1
+  return Math.max(...statsData.value.geo_distribution.map(d => d.count), 1)
+})
+
+function formatDuration(seconds: number): string {
+  if (!seconds) return '0'
+  const hours = Math.floor(seconds / 3600)
+  const minutes = Math.floor((seconds % 3600) / 60)
+  if (hours > 0) {
+    return `${hours}h ${minutes}m`
+  }
+  return `${minutes}m`
+}
+
+function formatDate(dateStr: string): string {
+  const date = new Date(dateStr)
+  return `${date.getMonth() + 1}/${date.getDate()}`
+}
+
+function getBarHeight(views: number): number {
+  return Math.max((views / maxViews.value) * 100, 5)
+}
+
+function getGeoPercentage(count: number): number {
+  return Math.round((count / maxGeoCount.value) * 100)
+}
+
+function getRankType(index: number): '' | 'success' | 'warning' | 'danger' | 'info' {
+  if (index === 0) return 'danger'
+  if (index === 1) return 'warning'
+  if (index === 2) return 'success'
+  return 'info'
+}
+
+function getCountryName(code: string): string {
+  const countryMap: Record<string, string> = {
+    CN: '中国',
+    US: '美国',
+    JP: '日本',
+    KR: '韩国',
+    HK: '香港',
+    TW: '台湾',
+    SG: '新加坡',
+    MY: '马来西亚',
+    TH: '泰国',
+    VN: '越南',
+    PH: '菲律宾',
+    ID: '印尼',
+    AU: '澳大利亚',
+    CA: '加拿大',
+    UK: '英国',
+    DE: '德国',
+    FR: '法国',
+  }
+  return countryMap[code] || code || '未知'
+}
+
+async function loadStats() {
+  loading.value = true
+  try {
+    const res = await getStatsOverview(days.value)
+    if (res.code === 200) {
+      statsData.value = res.data
+    } else {
+      ElMessage.error(res.msg || '获取统计数据失败')
+    }
+  } catch (error) {
+    console.error('Failed to load stats:', error)
+    ElMessage.error('获取统计数据失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(() => {
+  loadStats()
+})
+</script>
+
+<style lang="scss" scoped>
+.stats-container {
+  padding: 20px;
+}
+
+.filter-card {
+  margin-bottom: 20px;
+}
+
+.summary-cards {
+  margin-bottom: 20px;
+
+  .el-col {
+    margin-bottom: 15px;
+  }
+
+  .summary-card {
+    text-align: center;
+
+    :deep(.el-statistic__head) {
+      font-size: 14px;
+    }
+
+    :deep(.el-statistic__content) {
+      font-size: 24px;
+    }
+  }
+}
+
+.trend-card {
+  margin-bottom: 20px;
+}
+
+.trend-chart {
+  padding: 20px 0;
+
+  .chart-bars {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-end;
+    height: 200px;
+    gap: 8px;
+  }
+
+  .bar-item {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    min-width: 30px;
+  }
+
+  .bar-wrapper {
+    width: 100%;
+    height: 150px;
+    display: flex;
+    align-items: flex-end;
+    justify-content: center;
+  }
+
+  .bar {
+    width: 80%;
+    max-width: 40px;
+    background: linear-gradient(180deg, #409eff 0%, #79bbff 100%);
+    border-radius: 4px 4px 0 0;
+    transition: height 0.3s ease;
+    cursor: pointer;
+
+    &:hover {
+      background: linear-gradient(180deg, #337ecc 0%, #409eff 100%);
+    }
+  }
+
+  .bar-label {
+    font-size: 11px;
+    color: #909399;
+    margin-top: 8px;
+  }
+
+  .bar-value {
+    font-size: 12px;
+    color: #606266;
+    font-weight: 500;
+  }
+}
+
+.rank-card {
+  margin-bottom: 20px;
+}
+
+.geo-card {
+  margin-bottom: 20px;
+
+  .geo-list {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  .geo-item {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+  }
+
+  .geo-info {
+    width: 100px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .geo-rank {
+    width: 20px;
+    height: 20px;
+    border-radius: 50%;
+    background: #f0f2f5;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 12px;
+    color: #606266;
+  }
+
+  .geo-country {
+    font-size: 14px;
+    color: #303133;
+  }
+
+  .geo-bar-wrapper {
+    flex: 1;
+  }
+
+  .geo-count {
+    width: 60px;
+    text-align: right;
+    font-size: 14px;
+    color: #606266;
+  }
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+</style>