Bladeren bron

update code

yb 1 maand geleden
bovenliggende
commit
33a0a2d9b8

+ 5 - 0
.env

@@ -0,0 +1,5 @@
+# 应用标题
+VITE_APP_TITLE=摄像头管理系统
+
+# API 基础路径
+VITE_APP_BASE_API=/api

+ 8 - 0
.env.development

@@ -0,0 +1,8 @@
+# 开发环境
+NODE_ENV=development
+
+# 应用标题
+VITE_APP_TITLE=摄像头管理系统
+
+# API 基础路径
+VITE_APP_BASE_API=/api

+ 6 - 0
.env.production

@@ -0,0 +1,6 @@
+# 生产环境
+# 应用标题
+VITE_APP_TITLE=摄像头管理系统
+
+# API 基础路径
+VITE_APP_BASE_API=/api

+ 4 - 2
index.html

@@ -1,10 +1,12 @@
 <!doctype html>
-<html lang="en">
+<html lang="zh-CN">
   <head>
     <meta charset="UTF-8" />
     <link rel="icon" type="image/svg+xml" href="/vite.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>tg-live-game-web</title>
+    <title>摄像头管理系统</title>
+    <!-- Jessibuca 播放器 -->
+    <script src="/js/jessibuca/jessibuca.js"></script>
   </head>
   <body>
     <div id="app"></div>

+ 2 - 2
package-lock.json

@@ -17,7 +17,7 @@
         "vue-router": "^4.6.4"
       },
       "devDependencies": {
-        "@types/node": "^24.10.1",
+        "@types/node": "^24.10.4",
         "@vitejs/plugin-vue": "^6.0.1",
         "@vue/tsconfig": "^0.8.1",
         "typescript": "~5.9.3",
@@ -1208,7 +1208,7 @@
     },
     "node_modules/@types/node": {
       "version": "24.10.4",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
+      "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.10.4.tgz",
       "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
       "dev": true,
       "license": "MIT",

+ 1 - 1
package.json

@@ -18,7 +18,7 @@
     "vue-router": "^4.6.4"
   },
   "devDependencies": {
-    "@types/node": "^24.10.1",
+    "@types/node": "^24.10.4",
     "@vitejs/plugin-vue": "^6.0.1",
     "@vue/tsconfig": "^0.8.1",
     "typescript": "~5.9.3",

+ 16 - 0
public/js/jessibuca/README.md

@@ -0,0 +1,16 @@
+# Jessibuca 播放器
+
+请从以下地址下载 Jessibuca 播放器文件,并放置在此目录下:
+
+- 下载地址: https://github.com/nicoxiang/jessibuca/releases
+
+需要的文件:
+- jessibuca.js
+- decoder.js
+- decoder.wasm
+
+或者使用 npm 安装后复制:
+```bash
+npm install jessibuca-pro
+# 然后从 node_modules/jessibuca-pro/dist 复制文件
+```

+ 5 - 26
src/App.vue

@@ -1,30 +1,9 @@
-<script setup lang="ts">
-import HelloWorld from './components/HelloWorld.vue'
-</script>
-
 <template>
-  <div>
-    <a href="https://vite.dev" target="_blank">
-      <img src="/vite.svg" class="logo" alt="Vite logo" />
-    </a>
-    <a href="https://vuejs.org/" target="_blank">
-      <img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
-    </a>
-  </div>
-  <HelloWorld msg="Vite + Vue" />
+  <router-view />
 </template>
 
-<style scoped>
-.logo {
-  height: 6em;
-  padding: 1.5em;
-  will-change: filter;
-  transition: filter 300ms;
-}
-.logo:hover {
-  filter: drop-shadow(0 0 2em #646cffaa);
-}
-.logo.vue:hover {
-  filter: drop-shadow(0 0 2em #42b883aa);
-}
+<script setup lang="ts">
+</script>
+
+<style>
 </style>

+ 87 - 0
src/api/camera.ts

@@ -0,0 +1,87 @@
+import { get, post, put, del } from '@/utils/request'
+import type { ApiResponse, PageResult, CameraDevice, CameraChannel, PlayResponse, RecordItem } from '@/types'
+
+// 获取设备列表
+export function listDevice(params?: object): Promise<ApiResponse<PageResult<CameraDevice>>> {
+  return get('/iot/sip/device/list', params)
+}
+
+// 获取设备详情
+export function getDevice(deviceId: string): Promise<ApiResponse<CameraDevice>> {
+  return get(`/iot/sip/device/${deviceId}`)
+}
+
+// 添加设备
+export function addDevice(data: Partial<CameraDevice>): Promise<ApiResponse<null>> {
+  return post('/iot/sip/device', data)
+}
+
+// 修改设备
+export function updateDevice(data: Partial<CameraDevice>): Promise<ApiResponse<null>> {
+  return put('/iot/sip/device', data)
+}
+
+// 删除设备
+export function delDevice(deviceId: string): Promise<ApiResponse<null>> {
+  return del(`/iot/sip/device/${deviceId}`)
+}
+
+// 获取设备通道列表
+export function listChannel(deviceId: string, params?: object): Promise<ApiResponse<PageResult<CameraChannel>>> {
+  return get(`/iot/sip/channel/${deviceId}/list`, params)
+}
+
+// 同步通道
+export function syncChannel(deviceId: string): Promise<ApiResponse<null>> {
+  return post(`/iot/sip/device/${deviceId}/sync`)
+}
+
+// 播放实时视频
+export function play(deviceId: string, channelId: string): Promise<ApiResponse<PlayResponse>> {
+  return get(`/iot/sip/channel/${deviceId}/${channelId}/play`)
+}
+
+// 停止播放
+export function stopPlay(deviceId: string, channelId: string): Promise<ApiResponse<null>> {
+  return get(`/iot/sip/channel/${deviceId}/${channelId}/stop`)
+}
+
+// 播放录像回放
+export function playback(deviceId: string, channelId: string, params: { start: number; end: number }): Promise<ApiResponse<PlayResponse>> {
+  return get(`/iot/sip/channel/${deviceId}/${channelId}/playback`, params)
+}
+
+// 停止回放
+export function playbackStop(deviceId: string, channelId: string): Promise<ApiResponse<null>> {
+  return get(`/iot/sip/channel/${deviceId}/${channelId}/playbackStop`)
+}
+
+// 暂停回放
+export function playbackPause(deviceId: string, channelId: string): Promise<ApiResponse<null>> {
+  return get(`/iot/sip/channel/${deviceId}/${channelId}/playbackPause`)
+}
+
+// 恢复回放
+export function playbackReplay(deviceId: string, channelId: string): Promise<ApiResponse<null>> {
+  return get(`/iot/sip/channel/${deviceId}/${channelId}/playbackReplay`)
+}
+
+// 录像跳转
+export function playbackSeek(deviceId: string, channelId: string, params: { seek: number }): Promise<ApiResponse<null>> {
+  return get(`/iot/sip/channel/${deviceId}/${channelId}/playbackSeek`, params)
+}
+
+// 回放倍速
+export function playbackSpeed(deviceId: string, channelId: string, speed: number): Promise<ApiResponse<null>> {
+  return get(`/iot/sip/channel/${deviceId}/${channelId}/playbackSpeed/${speed}`)
+}
+
+// 获取设备录像
+export function getDevRecord(deviceId: string, channelId: string, params: { start: number; end: number }): Promise<ApiResponse<{ recordItems: RecordItem[] }>> {
+  return get(`/iot/sip/channel/${deviceId}/${channelId}/devRecord`, params)
+}
+
+// 云台控制
+export function ptzControl(deviceId: string, channelId: string, command: string, horizonSpeed?: number, verticalSpeed?: number, zoomSpeed?: number): Promise<ApiResponse<null>> {
+  return get(`/iot/sip/channel/${deviceId}/${channelId}/ptz/${command}/${horizonSpeed || 50}/${verticalSpeed || 50}/${zoomSpeed || 50}`)
+}

+ 22 - 0
src/api/login.ts

@@ -0,0 +1,22 @@
+import { get, post } from '@/utils/request'
+import type { LoginParams, ApiResponse, UserInfo } from '@/types'
+
+// 登录
+export function login(data: LoginParams): Promise<ApiResponse<{ token: string }>> {
+  return post('/login', data)
+}
+
+// 获取用户信息
+export function getInfo(): Promise<ApiResponse<UserInfo>> {
+  return get('/getInfo')
+}
+
+// 退出登录
+export function logout(): Promise<ApiResponse<null>> {
+  return post('/logout')
+}
+
+// 获取验证码
+export function getCodeImg(): Promise<ApiResponse<{ captchaEnabled: boolean; uuid: string; img: string }>> {
+  return get('/captchaImage')
+}

+ 6 - 0
src/assets/logo.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+  <rect width="32" height="32" rx="4" fill="#409eff"/>
+  <circle cx="16" cy="14" r="6" fill="none" stroke="#fff" stroke-width="2"/>
+  <circle cx="16" cy="14" r="2" fill="#fff"/>
+  <rect x="11" y="22" width="10" height="4" rx="1" fill="#fff"/>
+</svg>

+ 89 - 0
src/assets/styles/index.scss

@@ -0,0 +1,89 @@
+@use './variables.scss' as *;
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+html, body, #app {
+  width: 100%;
+  height: 100%;
+  font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
+  font-size: 14px;
+  color: $text-color;
+  background-color: $bg-color;
+}
+
+a {
+  text-decoration: none;
+  color: inherit;
+}
+
+ul, ol {
+  list-style: none;
+}
+
+// 滚动条样式
+::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+
+::-webkit-scrollbar-thumb {
+  background-color: #c1c1c1;
+  border-radius: 3px;
+}
+
+::-webkit-scrollbar-track {
+  background-color: #f1f1f1;
+}
+
+// 通用类
+.flex {
+  display: flex;
+}
+
+.flex-center {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.flex-between {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.text-ellipsis {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.clearfix::after {
+  content: '';
+  display: table;
+  clear: both;
+}
+
+// 页面容器
+.page-container {
+  padding: 20px;
+  background-color: #fff;
+  border-radius: 4px;
+}
+
+// 搜索表单
+.search-form {
+  margin-bottom: 20px;
+  padding: 20px;
+  background-color: #fff;
+  border-radius: 4px;
+}
+
+// 表格操作按钮
+.table-actions {
+  margin-bottom: 15px;
+}

+ 28 - 0
src/assets/styles/variables.scss

@@ -0,0 +1,28 @@
+// 主题色
+$primary-color: #409eff;
+$success-color: #67c23a;
+$warning-color: #e6a23c;
+$danger-color: #f56c6c;
+$info-color: #909399;
+
+// 背景色
+$bg-color: #f5f7fa;
+$bg-color-dark: #304156;
+
+// 边框色
+$border-color: #dcdfe6;
+$border-color-light: #e4e7ed;
+
+// 文字颜色
+$text-color: #303133;
+$text-color-regular: #606266;
+$text-color-secondary: #909399;
+$text-color-placeholder: #c0c4cc;
+
+// 布局
+$sidebar-width: 210px;
+$sidebar-width-collapsed: 64px;
+$header-height: 60px;
+
+// 动画
+$transition-duration: 0.3s;

+ 198 - 0
src/layout/index.vue

@@ -0,0 +1,198 @@
+<template>
+  <el-container class="app-wrapper">
+    <el-aside :width="sidebarOpened ? '210px' : '64px'" class="sidebar-container">
+      <div class="logo">
+        <img src="@/assets/logo.svg" alt="logo" />
+        <h1 v-show="sidebarOpened">摄像头管理</h1>
+      </div>
+      <el-menu
+        :default-active="activeMenu"
+        :collapse="!sidebarOpened"
+        :collapse-transition="false"
+        background-color="#304156"
+        text-color="#bfcbd9"
+        active-text-color="#409eff"
+        router
+      >
+        <el-menu-item index="/camera">
+          <el-icon><VideoCamera /></el-icon>
+          <template #title>摄像头管理</template>
+        </el-menu-item>
+      </el-menu>
+    </el-aside>
+
+    <el-container class="main-container">
+      <el-header class="app-header">
+        <div class="header-left">
+          <el-icon class="collapse-btn" @click="toggleSidebar">
+            <Fold v-if="sidebarOpened" />
+            <Expand v-else />
+          </el-icon>
+          <el-breadcrumb separator="/">
+            <el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
+              {{ item.meta?.title }}
+            </el-breadcrumb-item>
+          </el-breadcrumb>
+        </div>
+        <div class="header-right">
+          <el-dropdown @command="handleCommand">
+            <span class="user-info">
+              <el-avatar :size="32" :src="userInfo?.avatar" />
+              <span class="username">{{ userInfo?.nickName || '用户' }}</span>
+              <el-icon><ArrowDown /></el-icon>
+            </span>
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item command="logout">退出登录</el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+        </div>
+      </el-header>
+
+      <el-main class="app-main">
+        <router-view v-slot="{ Component }">
+          <transition name="fade" mode="out-in">
+            <component :is="Component" />
+          </transition>
+        </router-view>
+      </el-main>
+    </el-container>
+  </el-container>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { VideoCamera, Fold, Expand, ArrowDown } from '@element-plus/icons-vue'
+import { useAppStore } from '@/store/app'
+import { useUserStore } from '@/store/user'
+
+const route = useRoute()
+const router = useRouter()
+const appStore = useAppStore()
+const userStore = useUserStore()
+
+const sidebarOpened = computed(() => appStore.sidebarOpened)
+const userInfo = computed(() => userStore.userInfo)
+
+const activeMenu = computed(() => {
+  const { path } = route
+  return path
+})
+
+const breadcrumbs = computed(() => {
+  return route.matched.filter(item => item.meta && item.meta.title && !item.meta.hidden)
+})
+
+function toggleSidebar() {
+  appStore.toggleSidebar()
+}
+
+async function handleCommand(command: string) {
+  if (command === 'logout') {
+    await userStore.logoutAction()
+    router.push('/login')
+  }
+}
+
+onMounted(() => {
+  userStore.getUserInfo()
+})
+</script>
+
+<style lang="scss" scoped>
+.app-wrapper {
+  height: 100%;
+}
+
+.sidebar-container {
+  background-color: #304156;
+  transition: width 0.3s;
+  overflow: hidden;
+
+  .logo {
+    height: 60px;
+    display: flex;
+    align-items: center;
+    padding: 0 15px;
+    background-color: #2b3a4a;
+
+    img {
+      width: 32px;
+      height: 32px;
+    }
+
+    h1 {
+      margin-left: 12px;
+      font-size: 16px;
+      font-weight: 600;
+      color: #fff;
+      white-space: nowrap;
+    }
+  }
+
+  .el-menu {
+    border-right: none;
+  }
+}
+
+.main-container {
+  flex-direction: column;
+  min-height: 100%;
+  overflow: hidden;
+}
+
+.app-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  height: 60px;
+  background-color: #fff;
+  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
+
+  .header-left {
+    display: flex;
+    align-items: center;
+
+    .collapse-btn {
+      font-size: 20px;
+      cursor: pointer;
+      margin-right: 15px;
+      color: #5a5e66;
+
+      &:hover {
+        color: #409eff;
+      }
+    }
+  }
+
+  .header-right {
+    .user-info {
+      display: flex;
+      align-items: center;
+      cursor: pointer;
+
+      .username {
+        margin: 0 8px;
+        color: #5a5e66;
+      }
+    }
+  }
+}
+
+.app-main {
+  background-color: #f5f7fa;
+  overflow: auto;
+}
+
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.2s ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
+</style>

+ 20 - 2
src/main.ts

@@ -1,5 +1,23 @@
 import { createApp } from 'vue'
-import './style.css'
+import ElementPlus from 'element-plus'
+import * as ElementPlusIconsVue from '@element-plus/icons-vue'
+import 'element-plus/dist/index.css'
+import zhCn from 'element-plus/es/locale/lang/zh-cn'
+
 import App from './App.vue'
+import router from './router'
+import pinia from './store'
+import '@/assets/styles/index.scss'
+
+const app = createApp(App)
+
+// 注册所有 Element Plus 图标
+for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+  app.component(key, component)
+}
+
+app.use(ElementPlus, { locale: zhCn })
+app.use(router)
+app.use(pinia)
 
-createApp(App).mount('#app')
+app.mount('#app')

+ 72 - 0
src/router/index.ts

@@ -0,0 +1,72 @@
+import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
+import { getToken } from '@/utils/auth'
+
+const Layout = () => import('@/layout/index.vue')
+
+const routes: RouteRecordRaw[] = [
+  {
+    path: '/login',
+    name: 'Login',
+    component: () => import('@/views/login/index.vue'),
+    meta: { title: '登录', hidden: true }
+  },
+  {
+    path: '/',
+    component: Layout,
+    redirect: '/camera',
+    children: [
+      {
+        path: 'camera',
+        name: 'Camera',
+        component: () => import('@/views/camera/index.vue'),
+        meta: { title: '摄像头管理', icon: 'VideoCamera' }
+      },
+      {
+        path: 'camera/channel/:deviceId',
+        name: 'CameraChannel',
+        component: () => import('@/views/camera/channel.vue'),
+        meta: { title: '通道列表', hidden: true }
+      },
+      {
+        path: 'camera/video/:deviceId/:channelId',
+        name: 'CameraVideo',
+        component: () => import('@/views/camera/video.vue'),
+        meta: { title: '视频播放', hidden: true }
+      }
+    ]
+  },
+  {
+    path: '/:pathMatch(.*)*',
+    redirect: '/'
+  }
+]
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes
+})
+
+// 白名单
+const whiteList = ['/login']
+
+router.beforeEach((to, _from, next) => {
+  document.title = (to.meta?.title as string) || '摄像头管理系统'
+
+  const hasToken = getToken()
+
+  if (hasToken) {
+    if (to.path === '/login') {
+      next({ path: '/' })
+    } else {
+      next()
+    }
+  } else {
+    if (whiteList.includes(to.path)) {
+      next()
+    } else {
+      next(`/login?redirect=${to.fullPath}`)
+    }
+  }
+})
+
+export default router

+ 22 - 0
src/store/app.ts

@@ -0,0 +1,22 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+export const useAppStore = defineStore('app', () => {
+  const sidebarOpened = ref(true)
+  const loading = ref(false)
+
+  function toggleSidebar() {
+    sidebarOpened.value = !sidebarOpened.value
+  }
+
+  function setLoading(value: boolean) {
+    loading.value = value
+  }
+
+  return {
+    sidebarOpened,
+    loading,
+    toggleSidebar,
+    setLoading
+  }
+})

+ 5 - 0
src/store/index.ts

@@ -0,0 +1,5 @@
+import { createPinia } from 'pinia'
+
+const pinia = createPinia()
+
+export default pinia

+ 54 - 0
src/store/user.ts

@@ -0,0 +1,54 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import type { UserInfo } from '@/types'
+import { getToken, setToken, removeToken } from '@/utils/auth'
+import { login, logout, getInfo } from '@/api/login'
+import type { LoginParams } from '@/types'
+
+export const useUserStore = defineStore('user', () => {
+  const token = ref<string>(getToken() || '')
+  const userInfo = ref<UserInfo | null>(null)
+
+  async function loginAction(loginForm: LoginParams) {
+    const res = await login(loginForm)
+    if (res.code === 200) {
+      const newToken = res.data?.token || res.token || ''
+      token.value = newToken
+      setToken(newToken)
+    }
+    return res
+  }
+
+  async function getUserInfo() {
+    const res = await getInfo()
+    if (res.code === 200) {
+      userInfo.value = res.data || res.user
+    }
+    return res
+  }
+
+  async function logoutAction() {
+    try {
+      await logout()
+    } finally {
+      token.value = ''
+      userInfo.value = null
+      removeToken()
+    }
+  }
+
+  function resetToken() {
+    token.value = ''
+    userInfo.value = null
+    removeToken()
+  }
+
+  return {
+    token,
+    userInfo,
+    loginAction,
+    getUserInfo,
+    logoutAction,
+    resetToken
+  }
+})

+ 0 - 79
src/style.css

@@ -1,79 +0,0 @@
-:root {
-  font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
-  line-height: 1.5;
-  font-weight: 400;
-
-  color-scheme: light dark;
-  color: rgba(255, 255, 255, 0.87);
-  background-color: #242424;
-
-  font-synthesis: none;
-  text-rendering: optimizeLegibility;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-}
-
-a {
-  font-weight: 500;
-  color: #646cff;
-  text-decoration: inherit;
-}
-a:hover {
-  color: #535bf2;
-}
-
-body {
-  margin: 0;
-  display: flex;
-  place-items: center;
-  min-width: 320px;
-  min-height: 100vh;
-}
-
-h1 {
-  font-size: 3.2em;
-  line-height: 1.1;
-}
-
-button {
-  border-radius: 8px;
-  border: 1px solid transparent;
-  padding: 0.6em 1.2em;
-  font-size: 1em;
-  font-weight: 500;
-  font-family: inherit;
-  background-color: #1a1a1a;
-  cursor: pointer;
-  transition: border-color 0.25s;
-}
-button:hover {
-  border-color: #646cff;
-}
-button:focus,
-button:focus-visible {
-  outline: 4px auto -webkit-focus-ring-color;
-}
-
-.card {
-  padding: 2em;
-}
-
-#app {
-  max-width: 1280px;
-  margin: 0 auto;
-  padding: 2rem;
-  text-align: center;
-}
-
-@media (prefers-color-scheme: light) {
-  :root {
-    color: #213547;
-    background-color: #ffffff;
-  }
-  a:hover {
-    color: #747bff;
-  }
-  button {
-    background-color: #f9f9f9;
-  }
-}

+ 132 - 0
src/types/index.ts

@@ -0,0 +1,132 @@
+// API 响应类型 - 兼容 RuoYi 后端格式
+export interface ApiResponse<T = any> {
+  code: number
+  msg: string
+  data: T
+  // RuoYi 特殊字段(有时候数据直接在顶层)
+  token?: string
+  user?: any
+  rows?: any[]
+  total?: number
+}
+
+// 分页参数
+export interface PageParams {
+  pageNum: number
+  pageSize: number
+}
+
+// 分页响应
+export interface PageResult<T = any> {
+  total: number
+  rows: T[]
+}
+
+// 用户信息
+export interface UserInfo {
+  userId: number
+  userName: string
+  nickName: string
+  avatar: string
+  token: string
+}
+
+// 登录参数
+export interface LoginParams {
+  username: string
+  password: string
+  code?: string
+  uuid?: string
+}
+
+// 摄像头设备
+export interface CameraDevice {
+  deviceId: string
+  deviceName: string
+  manufacturer?: string
+  model?: string
+  firmware?: string
+  transport?: string
+  streamMode?: string
+  online: boolean
+  registerTime?: string
+  keepaliveTime?: string
+  ip?: string
+  port?: number
+  hostAddress?: string
+  charset?: string
+  subscribeCycleForCatalog?: number
+  subscribeCycleForMobilePosition?: number
+  mobilePositionSubmissionInterval?: number
+  subscribeCycleForAlarm?: number
+  ssrcCheck?: boolean
+  geoCoordSys?: string
+  treeType?: string
+  password?: string
+  asMessageChannel?: boolean
+  broadcastPushAfterAck?: boolean
+  createTime?: string
+  updateTime?: string
+  channelCount?: number
+}
+
+// 摄像头通道
+export interface CameraChannel {
+  channelId: string
+  deviceId: string
+  name: string
+  manufacturer?: string
+  model?: string
+  owner?: string
+  civilCode?: string
+  block?: string
+  address?: string
+  parental?: number
+  parentId?: string
+  safetyWay?: number
+  registerWay?: number
+  certNum?: string
+  certifiable?: number
+  errCode?: number
+  endTime?: string
+  secrecy?: number
+  ipAddress?: string
+  port?: number
+  password?: string
+  ptzType?: number
+  status?: boolean
+  longitude?: number
+  latitude?: number
+  longitudeGcj02?: number
+  latitudeGcj02?: number
+  longitudeWgs84?: number
+  latitudeWgs84?: number
+  hasAudio?: boolean
+  createTime?: string
+  updateTime?: string
+  businessGroupId?: string
+  gpsTime?: string
+}
+
+// 录像记录
+export interface RecordItem {
+  name?: string
+  start: number
+  end: number
+  secrecy?: number
+  type?: string
+}
+
+// 播放响应
+export interface PlayResponse {
+  streamId: string
+  flv: string
+  ws_flv?: string
+  rtsp?: string
+  rtmp?: string
+  hls?: string
+  rtc?: string
+  mediaServerId?: string
+  deviceId?: string
+  channelId?: string
+}

+ 13 - 0
src/utils/auth.ts

@@ -0,0 +1,13 @@
+const TOKEN_KEY = 'Admin-Token'
+
+export function getToken(): string | null {
+  return localStorage.getItem(TOKEN_KEY)
+}
+
+export function setToken(token: string): void {
+  localStorage.setItem(TOKEN_KEY, token)
+}
+
+export function removeToken(): void {
+  localStorage.removeItem(TOKEN_KEY)
+}

+ 96 - 0
src/utils/request.ts

@@ -0,0 +1,96 @@
+import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { getToken } from './auth'
+import { useUserStore } from '@/store/user'
+import type { ApiResponse } from '@/types'
+
+const service: AxiosInstance = axios.create({
+  baseURL: import.meta.env.VITE_APP_BASE_API || '/api',
+  timeout: 30000,
+  headers: {
+    'Content-Type': 'application/json;charset=utf-8'
+  }
+})
+
+// 请求拦截器
+service.interceptors.request.use(
+  (config) => {
+    const token = getToken()
+    if (token) {
+      config.headers['Authorization'] = 'Bearer ' + token
+    }
+    return config
+  },
+  (error) => {
+    console.error('Request error:', error)
+    return Promise.reject(error)
+  }
+)
+
+// 响应拦截器
+service.interceptors.response.use(
+  (response: AxiosResponse<ApiResponse>) => {
+    const res = response.data
+    const code = res.code || 200
+
+    // 二进制数据直接返回
+    if (response.request.responseType === 'blob' || response.request.responseType === 'arraybuffer') {
+      return response.data
+    }
+
+    if (code === 401) {
+      ElMessageBox.confirm('登录状态已过期,请重新登录', '系统提示', {
+        confirmButtonText: '重新登录',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        const userStore = useUserStore()
+        userStore.resetToken()
+        location.href = '/login'
+      })
+      return Promise.reject(new Error('登录已过期'))
+    }
+
+    if (code === 500) {
+      ElMessage.error(res.msg || '服务器错误')
+      return Promise.reject(new Error(res.msg || '服务器错误'))
+    }
+
+    if (code !== 200) {
+      ElMessage.error(res.msg || '请求失败')
+      return Promise.reject(new Error(res.msg || '请求失败'))
+    }
+
+    return res as any
+  },
+  (error) => {
+    let message = error.message
+    if (message === 'Network Error') {
+      message = '网络连接异常'
+    } else if (message.includes('timeout')) {
+      message = '请求超时'
+    } else if (message.includes('Request failed with status code')) {
+      message = '接口' + message.substr(message.length - 3) + '异常'
+    }
+    ElMessage.error(message)
+    return Promise.reject(error)
+  }
+)
+
+export function get<T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
+  return service.get(url, { params, ...config })
+}
+
+export function post<T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
+  return service.post(url, data, config)
+}
+
+export function put<T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
+  return service.put(url, data, config)
+}
+
+export function del<T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
+  return service.delete(url, { params, ...config })
+}
+
+export default service

+ 179 - 0
src/views/camera/channel.vue

@@ -0,0 +1,179 @@
+<template>
+  <div class="page-container">
+    <!-- 返回按钮 -->
+    <div class="page-header">
+      <el-button :icon="ArrowLeft" @click="goBack">返回</el-button>
+      <span class="title">设备通道列表 - {{ deviceId }}</span>
+    </div>
+
+    <!-- 搜索区域 -->
+    <div class="search-form">
+      <el-form :model="queryParams" inline>
+        <el-form-item label="通道名称">
+          <el-input v-model="queryParams.name" placeholder="请输入通道名称" clearable @keyup.enter="handleQuery" />
+        </el-form-item>
+        <el-form-item label="在线状态">
+          <el-select v-model="queryParams.status" placeholder="请选择" clearable>
+            <el-option label="在线" :value="true" />
+            <el-option label="离线" :value="false" />
+          </el-select>
+        </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>
+    </div>
+
+    <!-- 数据表格 -->
+    <el-table v-loading="loading" :data="channelList" border>
+      <el-table-column type="index" label="序号" width="60" align="center" />
+      <el-table-column prop="channelId" label="通道ID" min-width="180" show-overflow-tooltip />
+      <el-table-column prop="name" label="通道名称" min-width="150" show-overflow-tooltip />
+      <el-table-column prop="manufacturer" label="厂商" min-width="120" show-overflow-tooltip />
+      <el-table-column prop="address" label="地址" min-width="150" show-overflow-tooltip />
+      <el-table-column prop="ptzType" label="云台类型" width="100" align="center">
+        <template #default="{ row }">
+          <span>{{ getPtzType(row.ptzType) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="status" label="状态" width="80" align="center">
+        <template #default="{ row }">
+          <el-tag :type="row.status ? 'success' : 'danger'">
+            {{ row.status ? '在线' : '离线' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="200" align="center" fixed="right">
+        <template #default="{ row }">
+          <el-button type="primary" link :icon="VideoPlay" @click="handlePlay(row)" :disabled="!row.status">
+            实时播放
+          </el-button>
+          <el-button type="success" link :icon="VideoCamera" @click="handlePlayback(row)" :disabled="!row.status">
+            录像回放
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 分页 -->
+    <el-pagination
+      v-model:current-page="queryParams.pageNum"
+      v-model:page-size="queryParams.pageSize"
+      :page-sizes="[10, 20, 50, 100]"
+      :total="total"
+      layout="total, sizes, prev, pager, next, jumper"
+      class="pagination"
+      @size-change="getList"
+      @current-change="getList"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { Search, Refresh, ArrowLeft, VideoPlay, VideoCamera } from '@element-plus/icons-vue'
+import { listChannel } from '@/api/camera'
+import type { CameraChannel } from '@/types'
+
+const route = useRoute()
+const router = useRouter()
+
+const deviceId = route.params.deviceId as string
+const loading = ref(false)
+const channelList = ref<CameraChannel[]>([])
+const total = ref(0)
+
+const queryParams = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  name: '',
+  status: undefined as boolean | undefined
+})
+
+function getPtzType(type: number | undefined): string {
+  const types: Record<number, string> = {
+    0: '未知',
+    1: '球机',
+    2: '半球',
+    3: '固定枪机',
+    4: '遥控枪机'
+  }
+  return types[type || 0] || '未知'
+}
+
+async function getList() {
+  loading.value = true
+  try {
+    const res = await listChannel(deviceId, queryParams)
+    if (res.code === 200) {
+      channelList.value = res.data.rows || res.rows || []
+      total.value = res.data.total || res.total || 0
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleQuery() {
+  queryParams.pageNum = 1
+  getList()
+}
+
+function resetQuery() {
+  queryParams.pageNum = 1
+  queryParams.name = ''
+  queryParams.status = undefined
+  getList()
+}
+
+function goBack() {
+  router.push('/camera')
+}
+
+function handlePlay(row: CameraChannel) {
+  router.push(`/camera/video/${deviceId}/${row.channelId}?mode=live`)
+}
+
+function handlePlayback(row: CameraChannel) {
+  router.push(`/camera/video/${deviceId}/${row.channelId}?mode=playback`)
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  padding: 20px;
+}
+
+.page-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+  padding: 15px 20px;
+  background-color: #fff;
+  border-radius: 4px;
+
+  .title {
+    margin-left: 15px;
+    font-size: 16px;
+    font-weight: 600;
+  }
+}
+
+.search-form {
+  margin-bottom: 20px;
+  padding: 20px;
+  background-color: #fff;
+  border-radius: 4px;
+}
+
+.pagination {
+  margin-top: 20px;
+  justify-content: flex-end;
+}
+</style>

+ 266 - 0
src/views/camera/index.vue

@@ -0,0 +1,266 @@
+<template>
+  <div class="page-container">
+    <!-- 搜索区域 -->
+    <div class="search-form">
+      <el-form :model="queryParams" inline>
+        <el-form-item label="设备ID">
+          <el-input v-model="queryParams.deviceId" placeholder="请输入设备ID" clearable @keyup.enter="handleQuery" />
+        </el-form-item>
+        <el-form-item label="设备名称">
+          <el-input v-model="queryParams.deviceName" placeholder="请输入设备名称" clearable @keyup.enter="handleQuery" />
+        </el-form-item>
+        <el-form-item label="在线状态">
+          <el-select v-model="queryParams.online" placeholder="请选择" clearable>
+            <el-option label="在线" :value="true" />
+            <el-option label="离线" :value="false" />
+          </el-select>
+        </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>
+    </div>
+
+    <!-- 操作按钮 -->
+    <div class="table-actions">
+      <el-button type="primary" :icon="Plus" @click="handleAdd">新增设备</el-button>
+      <el-button type="success" :icon="Refresh" @click="getList">刷新列表</el-button>
+    </div>
+
+    <!-- 数据表格 -->
+    <el-table v-loading="loading" :data="deviceList" border>
+      <el-table-column type="index" label="序号" width="60" align="center" />
+      <el-table-column prop="deviceId" label="设备ID" min-width="180" show-overflow-tooltip />
+      <el-table-column prop="deviceName" label="设备名称" min-width="150" show-overflow-tooltip />
+      <el-table-column prop="manufacturer" label="厂商" min-width="120" show-overflow-tooltip />
+      <el-table-column prop="hostAddress" label="地址" min-width="150" show-overflow-tooltip />
+      <el-table-column prop="channelCount" label="通道数" width="80" align="center" />
+      <el-table-column prop="online" label="状态" width="80" align="center">
+        <template #default="{ row }">
+          <el-tag :type="row.online ? 'success' : 'danger'">
+            {{ row.online ? '在线' : '离线' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="registerTime" label="注册时间" width="160" align="center" />
+      <el-table-column label="操作" width="280" align="center" fixed="right">
+        <template #default="{ row }">
+          <el-button type="primary" link :icon="View" @click="handleChannel(row)">通道</el-button>
+          <el-button type="primary" link :icon="Refresh" @click="handleSync(row)">同步</el-button>
+          <el-button type="primary" link :icon="Edit" @click="handleEdit(row)">编辑</el-button>
+          <el-button type="danger" link :icon="Delete" @click="handleDelete(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 分页 -->
+    <el-pagination
+      v-model:current-page="queryParams.pageNum"
+      v-model:page-size="queryParams.pageSize"
+      :page-sizes="[10, 20, 50, 100]"
+      :total="total"
+      layout="total, sizes, prev, pager, next, jumper"
+      class="pagination"
+      @size-change="getList"
+      @current-change="getList"
+    />
+
+    <!-- 新增/编辑弹窗 -->
+    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close>
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
+        <el-form-item label="设备ID" prop="deviceId">
+          <el-input v-model="form.deviceId" placeholder="请输入设备ID" :disabled="!!form.id" />
+        </el-form-item>
+        <el-form-item label="设备名称" prop="deviceName">
+          <el-input v-model="form.deviceName" placeholder="请输入设备名称" />
+        </el-form-item>
+        <el-form-item label="密码" prop="password">
+          <el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
+        </el-form-item>
+        <el-form-item label="传输协议" prop="transport">
+          <el-select v-model="form.transport" placeholder="请选择">
+            <el-option label="UDP" value="UDP" />
+            <el-option label="TCP-PASSIVE" value="TCP-PASSIVE" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="字符集" prop="charset">
+          <el-select v-model="form.charset" placeholder="请选择">
+            <el-option label="GB2312" value="GB2312" />
+            <el-option label="UTF-8" value="UTF-8" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
+import { Search, Refresh, Plus, View, Edit, Delete } from '@element-plus/icons-vue'
+import { listDevice, addDevice, updateDevice, delDevice, syncChannel } from '@/api/camera'
+import type { CameraDevice } from '@/types'
+
+const router = useRouter()
+
+const loading = ref(false)
+const submitLoading = ref(false)
+const deviceList = ref<CameraDevice[]>([])
+const total = ref(0)
+const dialogVisible = ref(false)
+const formRef = ref<FormInstance>()
+
+const queryParams = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  deviceId: '',
+  deviceName: '',
+  online: undefined as boolean | undefined
+})
+
+const form = reactive<Partial<CameraDevice> & { id?: string }>({
+  deviceId: '',
+  deviceName: '',
+  password: '',
+  transport: 'UDP',
+  charset: 'GB2312'
+})
+
+const dialogTitle = computed(() => form.id ? '编辑设备' : '新增设备')
+
+const rules: FormRules = {
+  deviceId: [{ required: true, message: '请输入设备ID', trigger: 'blur' }],
+  deviceName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }]
+}
+
+async function getList() {
+  loading.value = true
+  try {
+    const res = await listDevice(queryParams)
+    if (res.code === 200) {
+      deviceList.value = res.data.rows || res.rows || []
+      total.value = res.data.total || res.total || 0
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleQuery() {
+  queryParams.pageNum = 1
+  getList()
+}
+
+function resetQuery() {
+  queryParams.pageNum = 1
+  queryParams.deviceId = ''
+  queryParams.deviceName = ''
+  queryParams.online = undefined
+  getList()
+}
+
+function handleAdd() {
+  Object.assign(form, {
+    id: undefined,
+    deviceId: '',
+    deviceName: '',
+    password: '',
+    transport: 'UDP',
+    charset: 'GB2312'
+  })
+  dialogVisible.value = true
+}
+
+function handleEdit(row: CameraDevice) {
+  Object.assign(form, {
+    id: row.deviceId,
+    ...row
+  })
+  dialogVisible.value = true
+}
+
+function handleChannel(row: CameraDevice) {
+  router.push(`/camera/channel/${row.deviceId}`)
+}
+
+async function handleSync(row: CameraDevice) {
+  try {
+    const res = await syncChannel(row.deviceId)
+    if (res.code === 200) {
+      ElMessage.success('同步成功')
+      getList()
+    }
+  } catch (error) {
+    console.error('同步失败', error)
+  }
+}
+
+async function handleDelete(row: CameraDevice) {
+  try {
+    await ElMessageBox.confirm(`确定要删除设备 "${row.deviceName}" 吗?`, '提示', {
+      type: 'warning'
+    })
+    const res = await delDevice(row.deviceId)
+    if (res.code === 200) {
+      ElMessage.success('删除成功')
+      getList()
+    }
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error('删除失败', error)
+    }
+  }
+}
+
+async function handleSubmit() {
+  if (!formRef.value) return
+
+  await formRef.value.validate(async (valid) => {
+    if (valid) {
+      submitLoading.value = true
+      try {
+        const res = form.id ? await updateDevice(form) : await addDevice(form)
+        if (res.code === 200) {
+          ElMessage.success(form.id ? '修改成功' : '新增成功')
+          dialogVisible.value = false
+          getList()
+        }
+      } finally {
+        submitLoading.value = false
+      }
+    }
+  })
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  padding: 20px;
+}
+
+.search-form {
+  margin-bottom: 20px;
+  padding: 20px;
+  background-color: #fff;
+  border-radius: 4px;
+}
+
+.table-actions {
+  margin-bottom: 15px;
+}
+
+.pagination {
+  margin-top: 20px;
+  justify-content: flex-end;
+}
+</style>

+ 463 - 0
src/views/camera/video.vue

@@ -0,0 +1,463 @@
+<template>
+  <div class="page-container">
+    <!-- 返回按钮 -->
+    <div class="page-header">
+      <el-button :icon="ArrowLeft" @click="goBack">返回</el-button>
+      <span class="title">{{ isPlayback ? '录像回放' : '实时播放' }} - {{ channelId }}</span>
+    </div>
+
+    <!-- 录像日期选择 -->
+    <div v-if="isPlayback" class="date-picker">
+      <span>选择录像日期:</span>
+      <el-date-picker
+        v-model="queryDate"
+        type="date"
+        placeholder="选择日期"
+        value-format="YYYY-MM-DD"
+        @change="loadDevRecord"
+      />
+    </div>
+
+    <!-- 播放器区域 -->
+    <div class="player-wrapper" v-loading="playerLoading">
+      <div ref="playerContainer" class="player-container"></div>
+    </div>
+
+    <!-- 控制按钮 -->
+    <div class="control-bar">
+      <div class="control-left">
+        <el-button v-if="!playing" type="primary" :icon="VideoPlay" @click="handlePlay">播放</el-button>
+        <el-button v-else type="danger" :icon="VideoPause" @click="handleStop">停止</el-button>
+
+        <el-button v-if="!muted" type="info" :icon="Mute" @click="handleMute">静音</el-button>
+        <el-button v-else type="warning" :icon="Microphone" @click="handleUnmute">放音</el-button>
+
+        <el-slider
+          v-model="volume"
+          :disabled="muted"
+          :max="100"
+          :format-tooltip="(val: number) => `音量: ${val}%`"
+          class="volume-slider"
+          @change="handleVolumeChange"
+        />
+      </div>
+
+      <div class="control-right">
+        <el-button :icon="Camera" @click="handleScreenshot">截图</el-button>
+        <el-button :icon="FullScreen" @click="handleFullscreen">全屏</el-button>
+
+        <template v-if="isPlayback && playing">
+          <el-button v-if="!paused" type="primary" :icon="VideoPause" @click="handlePause">暂停</el-button>
+          <el-button v-else type="success" :icon="VideoPlay" @click="handleResume">恢复</el-button>
+        </template>
+      </div>
+    </div>
+
+    <!-- 云台控制(仅实时播放) -->
+    <div v-if="!isPlayback" class="ptz-control">
+      <h4>云台控制</h4>
+      <div class="ptz-grid">
+        <div class="ptz-row">
+          <el-button @click="ptzControl('upleft')">↖</el-button>
+          <el-button @click="ptzControl('up')">↑</el-button>
+          <el-button @click="ptzControl('upright')">↗</el-button>
+        </div>
+        <div class="ptz-row">
+          <el-button @click="ptzControl('left')">←</el-button>
+          <el-button type="danger" @click="ptzControl('stop')">停</el-button>
+          <el-button @click="ptzControl('right')">→</el-button>
+        </div>
+        <div class="ptz-row">
+          <el-button @click="ptzControl('downleft')">↙</el-button>
+          <el-button @click="ptzControl('down')">↓</el-button>
+          <el-button @click="ptzControl('downright')">↘</el-button>
+        </div>
+        <div class="ptz-row ptz-zoom">
+          <el-button @click="ptzControl('zoomin')">放大</el-button>
+          <el-button @click="ptzControl('zoomout')">缩小</el-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import {
+  ArrowLeft,
+  VideoPlay,
+  VideoPause,
+  Mute,
+  Microphone,
+  Camera,
+  FullScreen
+} from '@element-plus/icons-vue'
+import {
+  play,
+  stopPlay,
+  playback,
+  playbackStop,
+  playbackPause,
+  playbackReplay,
+  getDevRecord,
+  ptzControl as ptzControlApi
+} from '@/api/camera'
+
+declare global {
+  interface Window {
+    Jessibuca: any
+  }
+}
+
+const route = useRoute()
+const router = useRouter()
+
+const deviceId = route.params.deviceId as string
+const channelId = route.params.channelId as string
+const isPlayback = computed(() => route.query.mode === 'playback')
+
+const playerContainer = ref<HTMLElement>()
+const playerLoading = ref(false)
+const playing = ref(false)
+const muted = ref(true)
+const paused = ref(false)
+const volume = ref(100)
+const queryDate = ref('')
+
+let jessibuca: any = null
+let streamInfo = {
+  ssrc: '',
+  flv: ''
+}
+
+function initPlayer() {
+  if (!playerContainer.value) return
+
+  jessibuca = new window.Jessibuca({
+    container: playerContainer.value,
+    videoBuffer: 0.2,
+    decoder: '/js/jessibuca/decoder.js',
+    timeout: 20,
+    debug: false,
+    isResize: false,
+    loadingText: '加载中...',
+    isFlv: true,
+    showBandwidth: true,
+    supportDblclickFullscreen: true,
+    operateBtns: {
+      fullscreen: true,
+      screenshot: false,
+      play: false,
+      audio: false
+    },
+    forceNoOffscreen: true,
+    isNotMute: false
+  })
+
+  jessibuca.on('error', (error: any) => {
+    console.error('Player error:', error)
+    destroyPlayer()
+  })
+
+  jessibuca.on('timeout', () => {
+    console.log('Player timeout')
+    destroyPlayer()
+  })
+}
+
+function destroyPlayer() {
+  if (jessibuca) {
+    jessibuca.destroy()
+    jessibuca = null
+  }
+  initPlayer()
+}
+
+async function handlePlay() {
+  if (isPlayback.value) {
+    loadDevRecord()
+  } else {
+    playLive()
+  }
+}
+
+async function playLive() {
+  playerLoading.value = true
+  try {
+    const res = await play(deviceId, channelId)
+    if (res.code === 200 && res.data) {
+      streamInfo.ssrc = res.data.streamId
+      streamInfo.flv = res.data.flv
+      playing.value = true
+
+      if (jessibuca && streamInfo.flv) {
+        jessibuca.play(streamInfo.flv)
+      }
+    }
+  } catch (error) {
+    console.error('播放失败', error)
+  } finally {
+    playerLoading.value = false
+  }
+}
+
+async function loadDevRecord() {
+  if (!queryDate.value) {
+    const today = new Date()
+    queryDate.value = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
+  }
+
+  playerLoading.value = true
+  try {
+    const date = new Date(queryDate.value).getTime()
+    const start = Math.floor(date / 1000)
+    const end = Math.floor((date + 24 * 60 * 60 * 1000 - 1) / 1000)
+
+    const res = await getDevRecord(deviceId, channelId, { start, end })
+
+    if (res.code === 200 && res.data?.recordItems && res.data.recordItems.length > 0) {
+      const records = res.data.recordItems
+      const firstRecord = records[0]
+      if (firstRecord) {
+        await playRecordback(firstRecord.start, end)
+      }
+    } else {
+      ElMessage.warning('当前通道没有录像')
+    }
+  } catch (error) {
+    console.error('加载录像失败', error)
+  } finally {
+    playerLoading.value = false
+  }
+}
+
+async function playRecordback(start: number, end: number) {
+  try {
+    const res = await playback(deviceId, channelId, { start, end })
+    if (res.code === 200 && res.data) {
+      streamInfo.ssrc = res.data.streamId
+      streamInfo.flv = res.data.flv
+      playing.value = true
+
+      if (jessibuca && streamInfo.flv) {
+        jessibuca.play(streamInfo.flv)
+      }
+    }
+  } catch (error) {
+    console.error('回放失败', error)
+  }
+}
+
+async function handleStop() {
+  playerLoading.value = true
+  try {
+    if (isPlayback.value) {
+      await playbackStop(deviceId, channelId)
+    } else {
+      await stopPlay(deviceId, channelId)
+    }
+    playing.value = false
+    paused.value = false
+    streamInfo.ssrc = ''
+    streamInfo.flv = ''
+    destroyPlayer()
+  } finally {
+    playerLoading.value = false
+  }
+}
+
+async function handlePause() {
+  if (!playing.value || !isPlayback.value) return
+
+  try {
+    const res = await playbackPause(deviceId, channelId)
+    if (res.code === 200) {
+      paused.value = true
+      jessibuca?.pause()
+    }
+  } catch (error) {
+    console.error('暂停失败', error)
+  }
+}
+
+async function handleResume() {
+  if (!paused.value || !isPlayback.value) return
+
+  try {
+    const res = await playbackReplay(deviceId, channelId)
+    if (res.code === 200) {
+      paused.value = false
+      jessibuca?.play()
+    }
+  } catch (error) {
+    console.error('恢复失败', error)
+  }
+}
+
+function handleMute() {
+  jessibuca?.mute()
+  muted.value = true
+}
+
+function handleUnmute() {
+  jessibuca?.cancelMute()
+  muted.value = false
+}
+
+function handleVolumeChange(val: number) {
+  jessibuca?.setVolume(val / 100)
+}
+
+function handleScreenshot() {
+  if (playing.value) {
+    jessibuca?.screenshot()
+  }
+}
+
+function handleFullscreen() {
+  if (playing.value) {
+    jessibuca?.setFullscreen(true)
+  }
+}
+
+async function ptzControl(command: string) {
+  try {
+    await ptzControlApi(deviceId, channelId, command)
+  } catch (error) {
+    console.error('云台控制失败', error)
+  }
+}
+
+function goBack() {
+  router.push(`/camera/channel/${deviceId}`)
+}
+
+onMounted(() => {
+  // 等待 Jessibuca 脚本加载
+  const checkJessibuca = () => {
+    if (window.Jessibuca) {
+      initPlayer()
+    } else {
+      setTimeout(checkJessibuca, 100)
+    }
+  }
+  checkJessibuca()
+})
+
+onBeforeUnmount(() => {
+  if (playing.value) {
+    handleStop()
+  }
+  if (jessibuca) {
+    jessibuca.destroy()
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  padding: 20px;
+}
+
+.page-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+  padding: 15px 20px;
+  background-color: #fff;
+  border-radius: 4px;
+
+  .title {
+    margin-left: 15px;
+    font-size: 16px;
+    font-weight: 600;
+  }
+}
+
+.date-picker {
+  margin-bottom: 20px;
+  padding: 15px 20px;
+  background-color: #fff;
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+
+  span {
+    margin-right: 10px;
+  }
+}
+
+.player-wrapper {
+  background-color: #000;
+  border-radius: 4px;
+  overflow: hidden;
+
+  .player-container {
+    width: 100%;
+    height: 500px;
+  }
+}
+
+.control-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 20px;
+  padding: 15px 20px;
+  background-color: #fff;
+  border-radius: 4px;
+
+  .control-left,
+  .control-right {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+  }
+
+  .volume-slider {
+    width: 100px;
+    margin-left: 10px;
+  }
+}
+
+.ptz-control {
+  margin-top: 20px;
+  padding: 20px;
+  background-color: #fff;
+  border-radius: 4px;
+
+  h4 {
+    margin-bottom: 15px;
+    font-size: 14px;
+    color: #303133;
+  }
+
+  .ptz-grid {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 5px;
+  }
+
+  .ptz-row {
+    display: flex;
+    gap: 5px;
+
+    .el-button {
+      width: 50px;
+      height: 40px;
+      padding: 0;
+    }
+  }
+
+  .ptz-zoom {
+    margin-top: 10px;
+
+    .el-button {
+      width: 80px;
+    }
+  }
+}
+</style>

+ 186 - 0
src/views/login/index.vue

@@ -0,0 +1,186 @@
+<template>
+  <div class="login-container">
+    <div class="login-box">
+      <h2 class="title">摄像头管理系统</h2>
+      <el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="login-form">
+        <el-form-item prop="username">
+          <el-input
+            v-model="loginForm.username"
+            placeholder="请输入用户名"
+            size="large"
+            :prefix-icon="User"
+          />
+        </el-form-item>
+        <el-form-item prop="password">
+          <el-input
+            v-model="loginForm.password"
+            type="password"
+            placeholder="请输入密码"
+            size="large"
+            :prefix-icon="Lock"
+            show-password
+            @keyup.enter="handleLogin"
+          />
+        </el-form-item>
+        <el-form-item v-if="captchaEnabled" prop="code">
+          <div class="captcha-wrapper">
+            <el-input
+              v-model="loginForm.code"
+              placeholder="请输入验证码"
+              size="large"
+              :prefix-icon="Key"
+              @keyup.enter="handleLogin"
+            />
+            <img :src="codeUrl" class="captcha-img" @click="getCode" />
+          </div>
+        </el-form-item>
+        <el-form-item>
+          <el-button
+            type="primary"
+            size="large"
+            :loading="loading"
+            class="login-btn"
+            @click="handleLogin"
+          >
+            登 录
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
+import { User, Lock, Key } from '@element-plus/icons-vue'
+import { useUserStore } from '@/store/user'
+import { getCodeImg } from '@/api/login'
+
+const router = useRouter()
+const route = useRoute()
+const userStore = useUserStore()
+
+const loginFormRef = ref<FormInstance>()
+const loading = ref(false)
+const captchaEnabled = ref(false)
+const codeUrl = ref('')
+
+const loginForm = reactive({
+  username: '',
+  password: '',
+  code: '',
+  uuid: ''
+})
+
+const loginRules: FormRules = {
+  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
+  password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
+  code: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
+}
+
+async function getCode() {
+  try {
+    const res = await getCodeImg()
+    if (res.code === 200) {
+      captchaEnabled.value = res.data.captchaEnabled
+      if (captchaEnabled.value) {
+        codeUrl.value = 'data:image/gif;base64,' + res.data.img
+        loginForm.uuid = res.data.uuid
+      }
+    }
+  } catch (error) {
+    console.error('获取验证码失败', error)
+  }
+}
+
+async function handleLogin() {
+  if (!loginFormRef.value) return
+
+  await loginFormRef.value.validate(async (valid) => {
+    if (valid) {
+      loading.value = true
+      try {
+        const res = await userStore.loginAction(loginForm)
+        if (res.code === 200) {
+          ElMessage.success('登录成功')
+          const redirect = (route.query.redirect as string) || '/'
+          router.push(redirect)
+        } else {
+          ElMessage.error(res.msg || '登录失败')
+          if (captchaEnabled.value) {
+            getCode()
+          }
+        }
+      } catch (error) {
+        if (captchaEnabled.value) {
+          getCode()
+        }
+      } finally {
+        loading.value = false
+      }
+    }
+  })
+}
+
+onMounted(() => {
+  getCode()
+})
+</script>
+
+<style lang="scss" scoped>
+.login-container {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 100vh;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+}
+
+.login-box {
+  width: 400px;
+  padding: 40px;
+  background-color: #fff;
+  border-radius: 8px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
+
+  .title {
+    text-align: center;
+    font-size: 24px;
+    font-weight: 600;
+    color: #303133;
+    margin-bottom: 30px;
+  }
+}
+
+.login-form {
+  .el-input {
+    height: 44px;
+  }
+
+  .captcha-wrapper {
+    display: flex;
+    width: 100%;
+
+    .el-input {
+      flex: 1;
+    }
+
+    .captcha-img {
+      width: 100px;
+      height: 44px;
+      margin-left: 10px;
+      cursor: pointer;
+      border: 1px solid #dcdfe6;
+      border-radius: 4px;
+    }
+  }
+
+  .login-btn {
+    width: 100%;
+    height: 44px;
+    font-size: 16px;
+  }
+}
+</style>

+ 4 - 3
tsconfig.app.json

@@ -3,12 +3,13 @@
   "compilerOptions": {
     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
     "types": ["vite/client"],
-
-    /* Linting */
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    },
     "strict": true,
     "noUnusedLocals": true,
     "noUnusedParameters": true,
-    "erasableSyntaxOnly": true,
     "noFallthroughCasesInSwitch": true,
     "noUncheckedSideEffectImports": true
   },

+ 36 - 1
vite.config.ts

@@ -1,7 +1,42 @@
 import { defineConfig } from 'vite'
 import vue from '@vitejs/plugin-vue'
+import { resolve } from 'path'
 
-// https://vite.dev/config/
 export default defineConfig({
   plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': resolve(__dirname, 'src')
+    }
+  },
+  server: {
+    host: '0.0.0.0',
+    port: 3000,
+    open: true,
+    proxy: {
+      '/api': {
+        target: 'http://localhost:8080',
+        changeOrigin: true,
+        rewrite: (path) => path.replace(/^\/api/, '')
+      }
+    }
+  },
+  css: {
+    preprocessorOptions: {
+      scss: {
+        additionalData: `@use "@/assets/styles/variables.scss" as *;`
+      }
+    }
+  },
+  build: {
+    chunkSizeWarningLimit: 1500,
+    rollupOptions: {
+      output: {
+        manualChunks: {
+          'element-plus': ['element-plus', '@element-plus/icons-vue'],
+          'vue-vendor': ['vue', 'vue-router', 'pinia']
+        }
+      }
+    }
+  }
 })