Przeglądaj źródła

feat(stream): add WHIP support and enhance camera streaming scripts

- Introduced new scripts for pushing camera streams to Cloudflare using WHIP protocol.
- Updated existing `push_cloudflare.sh` script to support RTSP input and WHIP output with enhanced encoding settings.
- Added `push_camera.sh` for direct camera streaming configuration.
- Implemented a new `whip.sh` script for compiling FFmpeg with WHIP support, including dependency checks and installation instructions.
- Enhanced the camera management interface in Vue components with improved search functionality and pagination support.
- Updated UI elements for better user experience and accessibility.
yb 1 tydzień temu
rodzic
commit
9b05b5b926

+ 15 - 0
go_stream/push_camera.sh

@@ -0,0 +1,15 @@
+/usr/local/ffmpeg-whip/bin/ffmpeg \
+    -rtsp_transport tcp \
+    -i "rtsp://admin:Wxc767718929@192.168.0.64:554/Streaming/Channels/101" \
+    -map 0:v:0 -map "0:a?" \
+    -c:v libx264 \
+    -preset veryfast \
+    -tune zerolatency \
+    -profile:v baseline \
+    -level 3.1 \
+    -pix_fmt yuv420p \
+    -g 30 -keyint_min 30 \
+    -b:v 2500k -maxrate 2500k -bufsize 5000k \
+    -c:a opus -strict -2 -ar 48000 -b:a 128k \
+    -f whip "https://customer-pj89kn2ke2tcuh19.cloudflarestream.com/8c108b4025d3278b188b443e8a6c5503kb51e49994b6fd9e56b6f1fdfcd339fe6/webRTC/publish" \
+    -headers "Authorization: Bearer ZrpMoQ15dCCe6rX0pKINzcb0eNdakSih-TmQrbq-"

+ 45 - 7
go_stream/push_cloudflare.sh

@@ -1,16 +1,54 @@
 #!/bin/bash
 # 推流到 Cloudflare Stream
-
 RTSP_SOURCE="rtsp://localhost:8554/camera1"
+VIDEO_SOURCE="$(dirname "$0")/videos/3.mp4"
 CLOUDFLARE_URL="rtmps://live.cloudflare.com:443/live/0a37e82197ac851a92dc5451dfe78485kb51e49994b6fd9e56b6f1fdfcd339fe6"
 
 echo "开始推流到 Cloudflare Stream..."
-echo "源: $RTSP_SOURCE"
+echo "源: $VIDEO_SOURCE"
 echo "按 Ctrl+C 停止推流"
 echo ""
 
-ffmpeg -i "$RTSP_SOURCE" \
-  -c:v copy \
-  -c:a aac \
-  -f flv \
-  "$CLOUDFLARE_URL"
+ffmpeg \
+  -rtsp_transport tcp \
+  -i "rtsp://admin:Wxc767718929@192.168.0.64:554/Streaming/Channels/101" \
+  -map 0:v:0 -map 0:a? \
+  -c:v libx264 \
+  -preset veryfast \
+  -tune zerolatency \
+  -profile:v baseline \
+  -level 3.1 \
+  -pix_fmt yuv420p \
+  -g 30 -keyint_min 30 \
+  -b:v 2500k -maxrate 2500k -bufsize 5000k \
+  -c:a opus -strict -2 -ar 48000 -b:a 128k \
+  -f whip "https://customer-pj89kn2ke2tcuh19.cloudflarestream.com/8c108b4025d3278b188b443e8a6c5503kb51e49994b6fd9e56b6f1fdfcd339fe6/webRTC/publish" \
+  -headers "Authorization: Bearer ZrpMoQ15dCCe6rX0pKINzcb0eNdakSih-TmQrbq-"
+
+# 使用本地视频文件推流(循环播放)
+# ffmpeg -re -stream_loop -1 \
+#   -i "$VIDEO_SOURCE" \
+#   -c:v libx264 \
+#   -preset veryfast \
+#   -tune zerolatency \
+#   -profile:v baseline \
+#   -level 3.1 \
+#   -g 60 -keyint_min 60 \
+#   -pix_fmt yuv420p \
+#   -c:a aac -ar 48000 -b:a 128k \
+#   -f flv \
+#   "$CLOUDFLARE_URL"
+
+# 方案2: 使用 WHIP (需要 FFmpeg 6.1+ 且编译了 WHIP 支持)
+# ffmpeg -rtsp_transport tcp \
+#   -i "$RTSP_SOURCE" \
+#   -c:v libx264 \
+#   -preset veryfast \
+#   -tune zerolatency \
+#   -profile:v baseline \
+#   -level 3.1 \
+#   -g 30 -keyint_min 30 \
+#   -pix_fmt yuv420p \
+#   -c:a aac -ar 48000 -b:a 96k \
+#   -f whip "https://customer-pj89kn2ke2tcuh19.cloudflarestream.com/8c108b4025d3278b188b443e8a6c5503kb51e49994b6fd9e56b6f1fdfcd339fe6/webRTC/publish" \
+#   -headers "Authorization: Bearer ZrpMoQ15dCCe6rX0pKINzcb0eNdakSih-TmQrbq-"

+ 337 - 0
go_stream/whip.sh

@@ -0,0 +1,337 @@
+#!/bin/bash
+# ============================================
+# FFmpeg WHIP 支持编译脚本
+# 智能检测:已安装则跳过,未安装则编译
+# ============================================
+
+set -e
+
+# 配置
+BUILD_DIR="$HOME/ffmpeg-whip-build"
+INSTALL_PREFIX="/usr/local/ffmpeg-whip"
+NPROC=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
+
+echo ""
+echo "╔══════════════════════════════════════════════════════════════╗"
+echo "║         FFmpeg WHIP 支持编译脚本 (智能检测版)                  ║"
+echo "║         编译目录: $BUILD_DIR"
+echo "║         安装目录: $INSTALL_PREFIX"
+echo "║         CPU 核心: $NPROC"
+echo "╚══════════════════════════════════════════════════════════════╝"
+echo ""
+
+# ==================== 检查是否已安装 ====================
+echo "┌──────────────────────────────────────────────────────────────┐"
+echo "│ 预检查: FFmpeg WHIP 支持                                      │"
+echo "└──────────────────────────────────────────────────────────────┘"
+
+if [ -x "$INSTALL_PREFIX/bin/ffmpeg" ]; then
+    WHIP_CHECK=$("$INSTALL_PREFIX/bin/ffmpeg" -formats 2>/dev/null | grep -c "whip" || echo 0)
+    if [ "$WHIP_CHECK" -gt 0 ]; then
+        echo "✅ FFmpeg WHIP 已安装,无需重新编译"
+        "$INSTALL_PREFIX/bin/ffmpeg" -version | head -1
+        echo ""
+        echo "如需强制重新编译,请删除后再运行:"
+        echo "  sudo rm -rf $INSTALL_PREFIX"
+        echo "  ./scripts/build-ffmpeg-whip.sh"
+        exit 0
+    fi
+fi
+
+echo "未检测到支持 WHIP 的 FFmpeg,开始编译..."
+echo ""
+
+# 创建目录
+mkdir -p "$BUILD_DIR"
+cd "$BUILD_DIR"
+
+# ==================== 步骤 1: 安装依赖 ====================
+echo "┌──────────────────────────────────────────────────────────────┐"
+echo "│ 步骤 1/3: 检查并安装依赖                                      │"
+echo "└──────────────────────────────────────────────────────────────┘"
+
+install_deps_centos() {
+    echo "检测到 CentOS/RHEL,检查依赖..."
+
+    # 检查 Development Tools
+    if ! rpm -q gcc &>/dev/null; then
+        echo "安装 Development Tools..."
+        sudo yum groupinstall -y "Development Tools"
+    else
+        echo "✓ Development Tools 已安装"
+    fi
+
+    # 尝试安装 EPEL (需要先安装,因为 x264 在 EPEL 里)
+    if ! rpm -q epel-release &>/dev/null; then
+        echo "安装 EPEL..."
+        sudo yum install -y epel-release || true
+    fi
+
+    # 检查其他依赖
+    DEPS="cmake git openssl-devel nasm x264-devel opus-devel"
+    for dep in $DEPS; do
+        if ! rpm -q $dep &>/dev/null; then
+            echo "安装 $dep..."
+            sudo yum install -y $dep || true
+        else
+            echo "✓ $dep 已安装"
+        fi
+    done
+}
+
+install_deps_ubuntu() {
+    echo "检测到 Debian/Ubuntu,检查依赖..."
+
+    DEPS="build-essential cmake git pkg-config libssl-dev nasm libx264-dev libopus-dev"
+    MISSING=""
+
+    for dep in $DEPS; do
+        if ! dpkg -s $dep &>/dev/null; then
+            MISSING="$MISSING $dep"
+        else
+            echo "✓ $dep 已安装"
+        fi
+    done
+
+    if [ -n "$MISSING" ]; then
+        echo "安装缺失依赖:$MISSING"
+        sudo apt-get update
+        sudo apt-get install -y $MISSING
+    fi
+}
+
+install_deps_macos() {
+    echo "检测到 macOS,检查依赖..."
+
+    DEPS="cmake openssl nasm pkg-config x264 opus"
+    for dep in $DEPS; do
+        if ! brew list $dep &>/dev/null; then
+            echo "安装 $dep..."
+            brew install $dep || true
+        else
+            echo "✓ $dep 已安装"
+        fi
+    done
+}
+
+# 根据系统选择安装方式
+if [[ "$OSTYPE" == "darwin"* ]]; then
+    install_deps_macos
+elif command -v yum &>/dev/null; then
+    install_deps_centos
+elif command -v apt-get &>/dev/null; then
+    install_deps_ubuntu
+elif command -v dnf &>/dev/null; then
+    echo "检测到 Fedora/RHEL 8+,使用 dnf..."
+    sudo dnf groupinstall -y "Development Tools"
+    sudo dnf install -y epel-release || true
+    sudo dnf install -y cmake git openssl-devel nasm x264-devel opus-devel
+else
+    echo "⚠️ 未知系统,请手动安装: cmake git openssl-devel nasm"
+fi
+
+echo "✅ 依赖检查完成"
+echo ""
+
+# ==================== 步骤 2: 编译 FFmpeg ====================
+echo "┌──────────────────────────────────────────────────────────────┐"
+echo "│ 步骤 2/3: 编译 FFmpeg (支持 WHIP)                             │"
+echo "└──────────────────────────────────────────────────────────────┘"
+
+cd "$BUILD_DIR"
+
+if [ ! -d "ffmpeg-git" ]; then
+    echo "下载 FFmpeg 源码..."
+    git clone --depth 1 https://git.ffmpeg.org/ffmpeg.git ffmpeg-git
+else
+    echo "FFmpeg 源码已存在,重置到最新版本..."
+    cd ffmpeg-git
+    git fetch origin
+    git reset --hard origin/master
+    cd ..
+fi
+
+cd ffmpeg-git
+
+# ==================== 应用 WHIP Bug 修复 ====================
+echo ""
+echo "应用 WHIP muxer 补丁..."
+
+WHIP_FILE="libavformat/whip.c"
+if [ -f "$WHIP_FILE" ]; then
+    # 跨平台 sed 函数 (兼容 macOS 和 Linux)
+    sed_inplace() {
+        if [[ "$OSTYPE" == "darwin"* ]]; then
+            sed -i '' "$@"
+        else
+            sed -i "$@"
+        fi
+    }
+
+    # Bug Fix #1: rtcp-fb 缺少冒号
+    if grep -q '"a=rtcp-fb%u' "$WHIP_FILE"; then
+        echo "  修复 Bug #1: rtcp-fb 缺少冒号..."
+        sed_inplace 's/"a=rtcp-fb%u/"a=rtcp-fb:%u/g' "$WHIP_FILE"
+        echo "  ✓ Bug #1 已修复"
+    else
+        echo "  ✓ Bug #1 已经修复或不存在"
+    fi
+
+    # Bug Fix #2: BUNDLE 和 mid 硬编码问题 (针对 video-only 模式)
+    if grep -q 'BUNDLE 0 1' "$WHIP_FILE"; then
+        echo "  修复 Bug #2: BUNDLE/mid 硬编码问题..."
+        cp "$WHIP_FILE" "${WHIP_FILE}.orig"
+        sed_inplace 's/BUNDLE 0 1/BUNDLE 0/g' "$WHIP_FILE"
+        sed_inplace 's/"a=mid:1\\r\\n"/"a=mid:0\\r\\n"/g' "$WHIP_FILE"
+        if ! grep -q 'BUNDLE 0 1' "$WHIP_FILE"; then
+            echo "  ✓ Bug #2 已修复 (BUNDLE 改为 0, 支持 video-only 模式)"
+        else
+            echo "  ⚠ BUNDLE 修复可能未完全生效"
+        fi
+    else
+        echo "  ✓ Bug #2 已经修复或不存在"
+    fi
+    echo "✅ WHIP 补丁应用完成"
+else
+    echo "⚠️ 未找到 WHIP 源文件,跳过补丁"
+fi
+echo ""
+
+echo "配置 FFmpeg..."
+
+# 清理之前的编译
+make distclean 2>/dev/null || true
+
+# 设置编译参数
+EXTRA_CFLAGS=""
+EXTRA_LDFLAGS=""
+
+# macOS 特殊处理: OpenSSL, x264, opus 路径
+if [[ "$OSTYPE" == "darwin"* ]]; then
+    OPENSSL_PREFIX=$(brew --prefix openssl)
+    X264_PREFIX=$(brew --prefix x264)
+    OPUS_PREFIX=$(brew --prefix opus)
+    export PKG_CONFIG_PATH="$OPENSSL_PREFIX/lib/pkgconfig:$X264_PREFIX/lib/pkgconfig:$OPUS_PREFIX/lib/pkgconfig:$PKG_CONFIG_PATH"
+    EXTRA_CFLAGS="-I$OPENSSL_PREFIX/include -I$X264_PREFIX/include -I$OPUS_PREFIX/include"
+    EXTRA_LDFLAGS="-L$OPENSSL_PREFIX/lib -L$X264_PREFIX/lib -L$OPUS_PREFIX/lib"
+    echo "macOS 库路径:"
+    echo "  OpenSSL: $OPENSSL_PREFIX"
+    echo "  x264: $X264_PREFIX"
+    echo "  opus: $OPUS_PREFIX"
+fi
+
+echo "PKG_CONFIG_PATH: $PKG_CONFIG_PATH"
+echo ""
+
+# 配置 FFmpeg
+# WHIP muxer 依赖: dtls_protocol -> openssl
+# 视频编码: libx264 (支持 -preset)
+# 音频编码: libopus
+./configure \
+    --prefix="$INSTALL_PREFIX" \
+    --enable-gpl \
+    --enable-nonfree \
+    --enable-openssl \
+    --enable-libx264 \
+    --enable-libopus \
+    --extra-cflags="$EXTRA_CFLAGS" \
+    --extra-ldflags="$EXTRA_LDFLAGS"
+
+# 检查关键功能是否被启用
+echo ""
+echo "检查编译配置..."
+
+CONFIG_OK=true
+
+# 检查 WHIP
+if grep -q "CONFIG_WHIP_MUXER=yes" ffbuild/config.mak 2>/dev/null; then
+    echo "✅ WHIP muxer 已启用"
+else
+    echo "❌ WHIP muxer 未启用"
+    CONFIG_OK=false
+fi
+
+# 检查 libx264
+if grep -q "CONFIG_LIBX264_ENCODER=yes" ffbuild/config.mak 2>/dev/null; then
+    echo "✅ libx264 编码器已启用"
+else
+    echo "❌ libx264 编码器未启用"
+    CONFIG_OK=false
+fi
+
+# 检查 libopus
+if grep -q "CONFIG_LIBOPUS_ENCODER=yes" ffbuild/config.mak 2>/dev/null; then
+    echo "✅ libopus 编码器已启用"
+else
+    echo "❌ libopus 编码器未启用"
+    CONFIG_OK=false
+fi
+
+if [ "$CONFIG_OK" = false ]; then
+    echo ""
+    echo "调试信息:"
+    grep -E "(openssl|dtls|whip|x264|opus)" ffbuild/config.mak 2>/dev/null | head -20 || true
+    echo ""
+    echo "可能原因:"
+    echo "  1. 依赖库未正确安装"
+    echo "  2. 库路径未正确配置"
+    echo ""
+    exit 1
+fi
+
+echo ""
+echo "编译 FFmpeg (这可能需要 10-20 分钟)..."
+make -j$NPROC
+
+echo "安装 FFmpeg..."
+sudo make install
+
+echo "✅ FFmpeg 编译完成"
+echo ""
+
+# ==================== 步骤 3: 验证 ====================
+echo "┌──────────────────────────────────────────────────────────────┐"
+echo "│ 步骤 3/3: 验证安装                                            │"
+echo "└──────────────────────────────────────────────────────────────┘"
+
+echo "FFmpeg 版本:"
+"$INSTALL_PREFIX/bin/ffmpeg" -version | head -3
+
+echo ""
+echo "功能检查:"
+
+# WHIP 支持
+if "$INSTALL_PREFIX/bin/ffmpeg" -formats 2>/dev/null | grep -q whip; then
+    echo "✅ WHIP 格式支持已启用"
+    "$INSTALL_PREFIX/bin/ffmpeg" -formats 2>/dev/null | grep whip
+else
+    echo "❌ WHIP 格式支持未找到"
+    exit 1
+fi
+
+# libx264 编码器
+if "$INSTALL_PREFIX/bin/ffmpeg" -encoders 2>/dev/null | grep -q libx264; then
+    echo "✅ libx264 编码器已启用"
+else
+    echo "❌ libx264 编码器未找到"
+    exit 1
+fi
+
+# libopus 编码器
+if "$INSTALL_PREFIX/bin/ffmpeg" -encoders 2>/dev/null | grep -q libopus; then
+    echo "✅ libopus 编码器已启用"
+else
+    echo "❌ libopus 编码器未找到"
+    exit 1
+fi
+
+echo ""
+echo "╔══════════════════════════════════════════════════════════════╗"
+echo "║                    ✅ 编译完成!                               ║"
+echo "╠══════════════════════════════════════════════════════════════╣"
+echo "║  FFmpeg 路径: $INSTALL_PREFIX/bin/ffmpeg"
+echo "║                                                              ║"
+echo "║  部署应用:                                                    ║"
+echo "║    cd /home/fyd/tp/tg-live-game-service                      ║"
+echo "║    ./scripts/deploy.sh                                       ║"
+echo "╚══════════════════════════════════════════════════════════════╝"

+ 516 - 133
src/views/camera/index.vue

@@ -1,16 +1,29 @@
 <template>
   <div class="page-container">
-    <!-- 搜索区域 -->
+    <!-- 搜索表单 -->
     <div class="search-form">
-      <el-form :model="queryParams" inline>
-        <el-form-item label="机器">
-          <el-select
-            v-model="queryParams.machineId"
-            placeholder="请选择机器"
-            style="width: 120px"
+      <el-form :model="searchForm" inline data-id="search-form">
+        <el-form-item :label="t('摄像头ID')">
+          <el-input
+            v-model.trim="searchForm.cameraId"
+            placeholder="请输入摄像头ID"
             clearable
-            @change="handleQuery"
-          >
+            data-id="search-camera-id"
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item :label="t('名称')">
+          <el-input
+            v-model.trim="searchForm.name"
+            placeholder="请输入名称"
+            clearable
+            data-id="search-name"
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item :label="t('所属机器')">
+          <el-select v-model="searchForm.machineId" placeholder="全部" clearable data-id="search-machine">
+            <el-option label="全部" value="" />
             <el-option
               v-for="machine in machineList"
               :key="machine.machineId"
@@ -19,108 +32,204 @@
             />
           </el-select>
         </el-form-item>
-        <el-form-item label="状态">
-          <el-select
-            v-model="queryParams.status"
-            placeholder="请选择"
-            style="width: 120px"
-            clearable
-            @change="handleQuery"
-          >
+        <el-form-item :label="t('状态')">
+          <el-select v-model="searchForm.status" placeholder="全部" clearable data-id="search-status">
+            <el-option label="全部" value="" />
             <el-option label="在线" value="ONLINE" />
             <el-option label="离线" value="OFFLINE" />
           </el-select>
         </el-form-item>
+        <el-form-item :label="t('启用状态')">
+          <el-select v-model="searchForm.enabled" placeholder="全部" clearable data-id="search-enabled">
+            <el-option label="全部" value="" />
+            <el-option label="已启用" :value="true" />
+            <el-option label="已禁用" :value="false" />
+          </el-select>
+        </el-form-item>
+        <el-form-item :label="t('创建时间')">
+          <el-date-picker
+            v-model="searchForm.dateRange"
+            type="daterange"
+            range-separator="至"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            value-format="YYYY-MM-DD"
+            data-id="search-date-range"
+          />
+        </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-button type="primary" :icon="Search" data-id="btn-search" @click="handleSearch">
+            {{ t('查询') }}
+          </el-button>
+          <el-button :icon="RefreshRight" data-id="btn-reset" @click="handleReset">{{ t('重置') }}</el-button>
+          <el-button type="primary" :icon="Plus" data-id="btn-add-camera" @click="handleAdd">
+            {{ t('新增') }}
+          </el-button>
         </el-form-item>
       </el-form>
     </div>
 
-    <!-- 操作按钮 -->
-    <div class="table-actions">
-      <el-button type="primary" :icon="Plus" @click="handleAdd">新增摄像头</el-button>
-      <el-button plain :icon="Refresh" @click="getList">刷新列表</el-button>
+    <!-- 数据表格 -->
+    <div class="table-wrapper">
+      <el-table
+        ref="tableRef"
+        v-loading="loading"
+        :data="sortedList"
+        stripe
+        size="default"
+        data-id="camera-table"
+        height="100%"
+        @sort-change="handleSortChange"
+      >
+        <el-table-column
+          prop="cameraId"
+          :label="t('摄像头ID')"
+          min-width="120"
+          sortable="custom"
+          show-overflow-tooltip
+        />
+        <el-table-column prop="name" :label="t('名称')" min-width="120" sortable="custom" show-overflow-tooltip>
+          <template #default="{ row }">
+            <el-link type="primary" :data-id="`link-edit-${row.cameraId}`" @click="handleEdit(row)">
+              {{ row.name }}
+            </el-link>
+          </template>
+        </el-table-column>
+        <el-table-column prop="ip" :label="t('IP地址')" min-width="130" sortable="custom" show-overflow-tooltip />
+        <el-table-column prop="port" :label="t('端口')" width="80" sortable="custom" align="center" />
+        <el-table-column prop="brand" :label="t('品牌')" min-width="100" sortable="custom" show-overflow-tooltip>
+          <template #default="{ row }">
+            {{ getBrandLabel(row.brand) }}
+          </template>
+        </el-table-column>
+        <el-table-column
+          prop="machineName"
+          :label="t('所属机器')"
+          min-width="100"
+          sortable="custom"
+          show-overflow-tooltip
+        />
+        <el-table-column prop="capability" :label="t('能力')" width="100" sortable="custom" align="center">
+          <template #default="{ row }">
+            <el-tag :type="row.capability === 'ptz_enabled' ? 'success' : 'info'">
+              {{ row.capability === 'ptz_enabled' ? 'PTZ' : t('仅切换') }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="status" :label="t('状态')" width="80" sortable="custom" align="center">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 'ONLINE' ? 'success' : 'danger'">
+              {{ row.status === 'ONLINE' ? t('在线') : t('离线') }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="enabled" :label="t('启用')" width="80" sortable="custom" align="center">
+          <template #default="{ row }">
+            <el-tag :type="row.enabled ? 'success' : 'info'">
+              {{ row.enabled ? t('是') : t('否') }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" :label="t('创建时间')" width="160" sortable="custom" align="center">
+          <template #default="{ row }">
+            {{ formatDateTime(row.createdAt) }}
+          </template>
+        </el-table-column>
+        <el-table-column :label="t('操作')" min-width="200" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button
+              type="primary"
+              link
+              :icon="View"
+              :data-id="`btn-channel-${row.cameraId}`"
+              @click="handleChannel(row)"
+            >
+              {{ t('通道') }}
+            </el-button>
+            <el-button
+              type="success"
+              link
+              :icon="Connection"
+              :data-id="`btn-check-${row.cameraId}`"
+              @click="handleCheck(row)"
+            >
+              {{ t('检测') }}
+            </el-button>
+            <el-button type="primary" link :icon="Edit" :data-id="`btn-edit-${row.cameraId}`" @click="handleEdit(row)">
+              {{ t('编辑') }}
+            </el-button>
+            <el-button
+              type="danger"
+              link
+              :icon="Delete"
+              :disabled="deleteLoading"
+              :data-id="`btn-delete-${row.cameraId}`"
+              @click="handleDelete(row)"
+            >
+              {{ t('删除') }}
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
     </div>
 
-    <!-- 数据表格 -->
-    <el-table v-loading="loading" :data="filteredList" border>
-      <el-table-column type="index" label="序号" width="60" align="center" />
-      <el-table-column prop="cameraId" label="摄像头ID" min-width="120" show-overflow-tooltip />
-      <el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
-      <el-table-column prop="ip" label="IP地址" min-width="130" show-overflow-tooltip />
-      <el-table-column prop="port" label="端口" width="80" align="center" />
-      <el-table-column prop="brand" label="品牌" min-width="100" show-overflow-tooltip />
-      <el-table-column prop="machineName" label="所属机器" min-width="100" show-overflow-tooltip />
-      <el-table-column prop="capability" label="能力" width="100" align="center">
-        <template #default="{ row }">
-          <el-tag :type="row.capability === 'ptz_enabled' ? 'success' : 'info'">
-            {{ row.capability === 'ptz_enabled' ? 'PTZ' : '仅切换' }}
-          </el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column prop="status" label="状态" width="80" align="center">
-        <template #default="{ row }">
-          <el-tag :type="row.status === 'ONLINE' ? 'success' : 'danger'">
-            {{ row.status === 'ONLINE' ? '在线' : '离线' }}
-          </el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column prop="enabled" label="启用" width="80" align="center">
-        <template #default="{ row }">
-          <el-tag :type="row.enabled ? 'success' : 'info'">
-            {{ row.enabled ? '是' : '否' }}
-          </el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column label="操作" width="260" align="center" fixed="right">
-        <template #default="{ row }">
-          <el-button type="primary" link :icon="View" @click="handleChannel(row)">通道</el-button>
-          <el-button type="success" link :icon="Connection" @click="handleCheck(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>
+    <!-- 分页 -->
+    <div class="pagination-container">
+      <el-pagination
+        v-model:current-page="currentPage"
+        v-model:page-size="pageSize"
+        :page-sizes="[10, 20, 50, 100]"
+        :total="total"
+        layout="total, sizes, prev, pager, next, jumper"
+        background
+        @size-change="handleSizeChange"
+        @current-change="handleCurrentChange"
+      />
+    </div>
 
     <!-- 新增/编辑弹窗 -->
-    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="650px" destroy-on-close>
-      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
-        <el-form-item label="摄像头ID" prop="cameraId">
-          <el-input v-model="form.cameraId" placeholder="请输入摄像头ID" :disabled="isEdit" />
+    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="650px" destroy-on-close data-id="dialog-camera">
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px" data-id="form-camera">
+        <el-form-item :label="t('摄像头ID')" prop="cameraId">
+          <el-input v-model="form.cameraId" placeholder="请输入摄像头ID" :disabled="isEdit" data-id="input-camera-id" />
         </el-form-item>
-        <el-form-item label="名称" prop="name">
-          <el-input v-model="form.name" placeholder="请输入名称" />
+        <el-form-item :label="t('名称')" prop="name">
+          <el-input v-model="form.name" placeholder="请输入名称" data-id="input-name" />
         </el-form-item>
         <el-row :gutter="20">
           <el-col :span="12">
-            <el-form-item label="IP地址" prop="ip">
-              <el-input v-model="form.ip" placeholder="请输入IP地址" />
+            <el-form-item :label="t('IP地址')" prop="ip">
+              <el-input v-model="form.ip" placeholder="请输入IP地址" data-id="input-ip" />
             </el-form-item>
           </el-col>
           <el-col :span="12">
-            <el-form-item label="端口" prop="port">
-              <el-input-number v-model="form.port" :min="1" :max="65535" />
+            <el-form-item :label="t('端口')" prop="port">
+              <el-input-number v-model="form.port" :min="1" :max="65535" data-id="input-port" />
             </el-form-item>
           </el-col>
         </el-row>
         <el-row :gutter="20">
           <el-col :span="12">
-            <el-form-item label="用户名" prop="username">
-              <el-input v-model="form.username" placeholder="请输入用户名" />
+            <el-form-item :label="t('用户名')" prop="username">
+              <el-input v-model="form.username" placeholder="请输入用户名" data-id="input-username" />
             </el-form-item>
           </el-col>
           <el-col :span="12">
-            <el-form-item label="密码" prop="password">
-              <el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
+            <el-form-item :label="t('密码')" prop="password">
+              <el-input
+                v-model="form.password"
+                type="password"
+                placeholder="请输入密码"
+                show-password
+                data-id="input-password"
+              />
             </el-form-item>
           </el-col>
         </el-row>
         <el-row :gutter="20">
           <el-col :span="12">
-            <el-form-item label="品牌" prop="brand">
-              <el-select v-model="form.brand" placeholder="请选择">
+            <el-form-item :label="t('品牌')" prop="brand">
+              <el-select v-model="form.brand" placeholder="请选择" data-id="select-brand">
                 <el-option label="海康威视" value="hikvision" />
                 <el-option label="大华" value="dahua" />
                 <el-option label="其他" value="other" />
@@ -128,16 +237,16 @@
             </el-form-item>
           </el-col>
           <el-col :span="12">
-            <el-form-item label="能力" prop="capability">
-              <el-select v-model="form.capability" placeholder="请选择">
+            <el-form-item :label="t('能力')" prop="capability">
+              <el-select v-model="form.capability" placeholder="请选择" data-id="select-capability">
                 <el-option label="仅切换" value="switch_only" />
                 <el-option label="支持PTZ" value="ptz_enabled" />
               </el-select>
             </el-form-item>
           </el-col>
         </el-row>
-        <el-form-item label="所属机器" prop="machineId">
-          <el-select v-model="form.machineId" placeholder="请选择机器" clearable>
+        <el-form-item :label="t('所属机器')" prop="machineId">
+          <el-select v-model="form.machineId" placeholder="请选择机器" clearable data-id="select-machine">
             <el-option
               v-for="machine in machineList"
               :key="machine.machineId"
@@ -146,33 +255,41 @@
             />
           </el-select>
         </el-form-item>
-        <el-form-item v-if="isEdit" label="启用状态">
-          <el-switch v-model="form.enabled" />
+        <el-form-item v-if="isEdit" :label="t('启用状态')">
+          <el-switch v-model="form.enabled" data-id="switch-enabled" />
         </el-form-item>
       </el-form>
       <template #footer>
-        <el-button @click="dialogVisible = false">取消</el-button>
-        <el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
+        <el-button data-id="btn-cancel" @click="dialogVisible = false">{{ t('取消') }}</el-button>
+        <el-button type="primary" :loading="submitLoading" data-id="btn-submit" @click="handleSubmit">
+          {{ t('确定') }}
+        </el-button>
       </template>
     </el-dialog>
 
     <!-- 通道弹窗 -->
-    <el-dialog v-model="channelDialogVisible" title="通道列表" width="700px" destroy-on-close>
-      <el-table :data="currentChannels" border>
-        <el-table-column prop="channelId" label="通道ID" min-width="100" />
-        <el-table-column prop="name" label="名称" min-width="100" />
-        <el-table-column prop="rtspUrl" label="RTSP地址" min-width="200" show-overflow-tooltip />
-        <el-table-column prop="defaultView" label="默认视角" width="90" align="center">
+    <el-dialog
+      v-model="channelDialogVisible"
+      :title="t('通道列表')"
+      width="700px"
+      destroy-on-close
+      data-id="dialog-channel"
+    >
+      <el-table :data="currentChannels" border stripe>
+        <el-table-column prop="channelId" :label="t('通道ID')" min-width="100" />
+        <el-table-column prop="name" :label="t('名称')" min-width="100" />
+        <el-table-column prop="rtspUrl" :label="t('RTSP地址')" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="defaultView" :label="t('默认视角')" width="90" align="center">
           <template #default="{ row }">
             <el-tag :type="row.defaultView ? 'success' : 'info'">
-              {{ row.defaultView ? '是' : '否' }}
+              {{ row.defaultView ? t('是') : t('否') }}
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="status" label="状态" width="80" align="center">
+        <el-table-column prop="status" :label="t('状态')" width="80" align="center">
           <template #default="{ row }">
             <el-tag :type="row.status === 'ONLINE' ? 'success' : 'danger'">
-              {{ row.status === 'ONLINE' ? '在线' : '离线' }}
+              {{ row.status === 'ONLINE' ? t('在线') : t('离线') }}
             </el-tag>
           </template>
         </el-table-column>
@@ -184,13 +301,34 @@
 <script setup lang="ts">
 import { ref, reactive, onMounted, computed } from 'vue'
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
-import { Search, Refresh, Plus, View, Edit, Delete, Connection } from '@element-plus/icons-vue'
+import { Plus, Edit, Delete, Search, RefreshRight, View, Connection } from '@element-plus/icons-vue'
 import { adminListCameras, adminAddCamera, adminUpdateCamera, adminDeleteCamera, adminCheckCamera } from '@/api/camera'
 import { listAllMachines } from '@/api/machine'
 import type { CameraInfoDTO, ChannelInfoDTO, CameraAddRequest, CameraUpdateRequest, MachineDTO } from '@/types'
+import dayjs from 'dayjs'
+import { useI18n } from 'vue-i18n'
+
+const { t } = useI18n({ useScope: 'global' })
+
+// 格式化时间
+function formatDateTime(dateStr: string | undefined): string {
+  if (!dateStr) return '-'
+  return dayjs(dateStr).format('YYYY-MM-DD HH:mm')
+}
+
+// 获取品牌标签
+function getBrandLabel(brand: string): string {
+  const brandMap: Record<string, string> = {
+    hikvision: '海康威视',
+    dahua: '大华',
+    other: '其他'
+  }
+  return brandMap[brand] || brand
+}
 
 const loading = ref(false)
 const submitLoading = ref(false)
+const deleteLoading = ref(false)
 const cameraList = ref<CameraInfoDTO[]>([])
 const machineList = ref<MachineDTO[]>([])
 const dialogVisible = ref(false)
@@ -198,9 +336,64 @@ const channelDialogVisible = ref(false)
 const currentChannels = ref<ChannelInfoDTO[]>([])
 const formRef = ref<FormInstance>()
 
-const queryParams = reactive({
+// 排序状态
+const sortState = reactive<{
+  prop: string
+  order: 'ascending' | 'descending' | null
+}>({
+  prop: '',
+  order: null
+})
+
+// 搜索表单
+const searchForm = reactive<{
+  cameraId: string
+  name: string
+  machineId: string
+  status: '' | 'ONLINE' | 'OFFLINE'
+  enabled: boolean | ''
+  dateRange: [string, string] | null
+}>({
+  cameraId: '',
+  name: '',
   machineId: '',
-  status: '' as '' | 'ONLINE' | 'OFFLINE'
+  status: '',
+  enabled: '',
+  dateRange: null
+})
+
+// 分页相关
+const currentPage = ref(1)
+const pageSize = ref(20)
+const total = ref(0)
+
+// 排序后的数据
+const sortedList = computed(() => {
+  const list = [...cameraList.value]
+  if (sortState.prop && sortState.order) {
+    list.sort((a, b) => {
+      const aVal = a[sortState.prop as keyof CameraInfoDTO]
+      const bVal = b[sortState.prop as keyof CameraInfoDTO]
+
+      // 处理空值
+      if (aVal == null && bVal == null) return 0
+      if (aVal == null) return sortState.order === 'ascending' ? -1 : 1
+      if (bVal == null) return sortState.order === 'ascending' ? 1 : -1
+
+      // 比较
+      let result = 0
+      if (typeof aVal === 'number' && typeof bVal === 'number') {
+        result = aVal - bVal
+      } else if (typeof aVal === 'boolean' && typeof bVal === 'boolean') {
+        result = aVal === bVal ? 0 : aVal ? 1 : -1
+      } else {
+        result = String(aVal).localeCompare(String(bVal))
+      }
+
+      return sortState.order === 'ascending' ? result : -result
+    })
+  }
+  return list
 })
 
 const form = reactive<{
@@ -229,24 +422,16 @@ const form = reactive<{
 })
 
 const isEdit = computed(() => !!form.id)
-const dialogTitle = computed(() => (isEdit.value ? '编辑摄像头' : '新增摄像头'))
-
-const filteredList = computed(() => {
-  let list = cameraList.value
-  if (queryParams.status) {
-    list = list.filter((item) => item.status === queryParams.status)
-  }
-  return list
-})
+const dialogTitle = computed(() => (isEdit.value ? t('编辑摄像头') : t('新增摄像头')))
 
 const rules: FormRules = {
-  cameraId: [{ required: true, message: '请输入摄像头ID', trigger: 'blur' }],
-  name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
+  cameraId: [{ required: true, message: t('请输入摄像头ID'), trigger: 'blur' }],
+  name: [{ required: true, message: t('请输入名称'), trigger: 'blur' }],
   ip: [
-    { required: true, message: '请输入IP地址', trigger: 'blur' },
+    { required: true, message: t('请输入IP地址'), trigger: 'blur' },
     {
       pattern: /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/,
-      message: '请输入正确的IP地址',
+      message: t('请输入正确的IP地址'),
       trigger: 'blur'
     }
   ]
@@ -259,33 +444,73 @@ async function getMachines() {
       machineList.value = res.data
     }
   } catch (error) {
-    console.error('获取机器列表失败', error)
+    console.error(t('获取机器列表失败'), error)
   }
 }
 
 async function getList() {
   loading.value = true
   try {
-    const params = {
-      machineId: queryParams.machineId || undefined,
-      status: queryParams.status || undefined
+    // 构建查询参数
+    const params: Record<string, any> = {
+      page: currentPage.value,
+      size: pageSize.value
+    }
+    // 搜索关键词(摄像头ID或名称)
+    if (searchForm.cameraId || searchForm.name) {
+      params.keyword = searchForm.cameraId || searchForm.name
+    }
+    // 机器过滤
+    if (searchForm.machineId) {
+      params.machineId = searchForm.machineId
     }
+    // 状态过滤
+    if (searchForm.status) {
+      params.status = searchForm.status
+    }
+    // 启用状态过滤
+    if (searchForm.enabled !== '') {
+      params.enabled = searchForm.enabled
+    }
+    // 排序
+    if (sortState.prop && sortState.order) {
+      params.sortBy = sortState.prop
+      params.sortDir = sortState.order === 'ascending' ? 'ASC' : 'DESC'
+    }
+
     const res = await adminListCameras(params)
     if (res.success) {
       cameraList.value = res.data.list
+      total.value = res.data.total || 0
     }
   } finally {
     loading.value = false
   }
 }
 
-function handleQuery() {
+function handleSearch() {
+  currentPage.value = 1 // 搜索时重置到第一页
   getList()
 }
 
-function resetQuery() {
-  queryParams.machineId = ''
-  queryParams.status = ''
+function handleReset() {
+  searchForm.cameraId = ''
+  searchForm.name = ''
+  searchForm.machineId = ''
+  searchForm.status = ''
+  searchForm.enabled = ''
+  searchForm.dateRange = null
+  currentPage.value = 1
+  // 重置排序
+  sortState.prop = ''
+  sortState.order = null
+  getList()
+}
+
+// 排序变化处理
+function handleSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) {
+  sortState.prop = prop || ''
+  sortState.order = order
   getList()
 }
 
@@ -333,30 +558,35 @@ async function handleCheck(row: CameraInfoDTO) {
     const res = await adminCheckCamera(row.id)
     if (res.success) {
       if (res.data) {
-        ElMessage.success('摄像头连接正常')
+        ElMessage.success(t('摄像头连接正常'))
       } else {
-        ElMessage.warning('摄像头连接失败')
+        ElMessage.warning(t('摄像头连接失败'))
       }
     }
   } catch (error) {
-    console.error('检测失败', error)
+    console.error(t('检测失败'), error)
   }
 }
 
 async function handleDelete(row: CameraInfoDTO) {
+  if (deleteLoading.value) return
+
   try {
-    await ElMessageBox.confirm(`确定要删除摄像头 "${row.name}" 吗?`, '提示', {
+    await ElMessageBox.confirm(`${t('确定要删除摄像头')} "${row.name}" ${t('吗?')}`, t('提示'), {
       type: 'warning'
     })
+    deleteLoading.value = true
     const res = await adminDeleteCamera(row.id)
     if (res.success) {
-      ElMessage.success('删除成功')
+      ElMessage.success(t('删除成功'))
       getList()
     }
   } catch (error) {
     if (error !== 'cancel') {
-      console.error('删除失败', error)
+      console.error(t('删除失败'), error)
     }
+  } finally {
+    deleteLoading.value = false
   }
 }
 
@@ -382,7 +612,7 @@ async function handleSubmit() {
           }
           const res = await adminUpdateCamera(updateData)
           if (res.success) {
-            ElMessage.success('修改成功')
+            ElMessage.success(t('修改成功'))
             dialogVisible.value = false
             getList()
           }
@@ -400,7 +630,7 @@ async function handleSubmit() {
           }
           const res = await adminAddCamera(addData)
           if (res.success) {
-            ElMessage.success('新增成功')
+            ElMessage.success(t('新增成功'))
             dialogVisible.value = false
             getList()
           }
@@ -412,6 +642,17 @@ async function handleSubmit() {
   })
 }
 
+function handleSizeChange(val: number) {
+  pageSize.value = val
+  currentPage.value = 1
+  getList()
+}
+
+function handleCurrentChange(val: number) {
+  currentPage.value = val
+  getList()
+}
+
 onMounted(() => {
   getMachines()
   getList()
@@ -420,17 +661,159 @@ onMounted(() => {
 
 <style lang="scss" scoped>
 .page-container {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
   padding: 1rem;
+  box-sizing: border-box;
+  overflow: hidden;
 }
 
 .search-form {
-  background-color: #fff;
-  border-radius: var(--radius-base);
-  padding: 0;
-  margin-bottom: 0;
+  flex-shrink: 0;
+  margin-bottom: 16px;
+  padding: 16px 16px 4px 16px;
+  background: #f5f7fa;
+
+  :deep(.el-form-item) {
+    margin-bottom: 12px;
+    margin-right: 16px;
+  }
+
+  :deep(.el-input),
+  :deep(.el-select) {
+    width: 160px;
+  }
+
+  :deep(.el-date-editor--daterange) {
+    width: 280px;
+
+    .el-range-input {
+      width: 90px;
+    }
+
+    .el-range-separator {
+      width: 30px;
+    }
+  }
+
+  // Indigo 主题按钮
+  :deep(.el-button--primary) {
+    background-color: #4f46e5;
+    border-color: #4f46e5;
+
+    &:hover,
+    &:focus {
+      background-color: #6366f1;
+      border-color: #6366f1;
+    }
+  }
+}
+
+.table-wrapper {
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+}
+
+.pagination-container {
+  flex-shrink: 0;
+  display: flex;
+  justify-content: flex-end;
+  padding-top: 16px;
+
+  // Indigo 主题分页
+  :deep(.el-pagination) {
+    .el-pager li.is-active {
+      background-color: #4f46e5;
+      color: #fff;
+    }
+
+    .el-pager li:not(.is-active):hover {
+      color: #4f46e5;
+    }
+
+    .btn-prev:hover,
+    .btn-next:hover {
+      color: #4f46e5;
+    }
+  }
+}
+
+// 表格样式
+:deep(.el-table) {
+  // 斑马纹对比度
+  --el-table-row-hover-bg-color: #f0f0ff;
+
+  .el-table__row--striped td.el-table__cell {
+    background-color: #f8f9fc;
+  }
+
+  // 表头样式
+  .el-table__header th {
+    background-color: #f5f7fa;
+    color: #333;
+    font-weight: 600;
+  }
+
+  // 链接颜色
+  .el-link--primary {
+    color: #4f46e5;
+
+    &:hover {
+      color: #6366f1;
+    }
+  }
+
+  // 主要按钮颜色
+  .el-button--primary.is-link {
+    color: #4f46e5;
+
+    &:hover {
+      color: #6366f1;
+    }
+  }
+
+  // 排序图标颜色
+  .el-table__column-filter-trigger,
+  .caret-wrapper {
+    .sort-caret.ascending {
+      border-bottom-color: #4f46e5;
+    }
+
+    .sort-caret.descending {
+      border-top-color: #4f46e5;
+    }
+  }
 }
 
-.table-actions {
-  margin-bottom: 15px;
+// 弹窗 Indigo 主题
+:deep(.el-dialog) {
+  .el-dialog__header {
+    border-bottom: 1px solid #e5e7eb;
+    padding-bottom: 16px;
+  }
+
+  .el-dialog__footer {
+    border-top: 1px solid #e5e7eb;
+    padding-top: 16px;
+  }
+
+  .el-button--primary {
+    background-color: #4f46e5;
+    border-color: #4f46e5;
+
+    &:hover,
+    &:focus {
+      background-color: #6366f1;
+      border-color: #6366f1;
+    }
+  }
+
+  // Switch Indigo 主题
+  .el-switch.is-checked .el-switch__core {
+    background-color: #4f46e5;
+    border-color: #4f46e5;
+  }
 }
 </style>

+ 2 - 2
src/views/machine/index.vue

@@ -547,11 +547,11 @@ onMounted(() => {
 .search-form {
   flex-shrink: 0;
   margin-bottom: 16px;
-  padding: 16px;
+  padding: 16px 16px 4px 16px;
   background: #f5f7fa;
 
   :deep(.el-form-item) {
-    margin-bottom: 0;
+    margin-bottom: 12px;
     margin-right: 16px;
   }