Преглед изворни кода

feat(camera-scan): implement camera scanning and credential management API

- Added new API functions for scanning devices, retrieving discovered devices, and managing camera credentials.
- Introduced types for scan results, discovered devices, and credential requests to enhance type safety.
- Updated English and Chinese locale files with new terms related to scanning and credential management.
- Enhanced the LSS view with a new drawer for scanning devices and managing credentials, improving user interaction.
yb пре 19 часа
родитељ
комит
9b99d8f1e4
5 измењених фајлова са 752 додато и 5 уклоњено
  1. 74 0
      src/api/camera-scan.ts
  2. 41 1
      src/locales/en.json
  3. 41 1
      src/locales/zh-cn.json
  4. 96 0
      src/types/index.ts
  5. 500 3
      src/views/lss/index.vue

+ 74 - 0
src/api/camera-scan.ts

@@ -0,0 +1,74 @@
+import { get, post } from '@/utils/request'
+import type {
+  IBaseResponse,
+  IListResponse,
+  BaseResponse,
+  ScanResultDTO,
+  DiscoveredCameraDTO,
+  MatchResultDTO,
+  MatchRequest,
+  BindDeviceRequest,
+  CameraCredentialDTO,
+  CredentialAddRequest,
+  CredentialUpdateRequest,
+  CameraInfoDTO
+} from '@/types'
+
+// ==================== 扫描相关 ====================
+
+// 触发 ONVIF 设备扫描
+export function scanDevices(lssId: string): Promise<IBaseResponse<ScanResultDTO>> {
+  return post(`/admin/lss/${lssId}/scan`)
+}
+
+// 获取发现设备列表
+export function getDiscoveredDevices(lssId: string): Promise<IListResponse<DiscoveredCameraDTO>> {
+  return get(`/admin/lss/${lssId}/discovered`)
+}
+
+// 获取发现设备详情
+export function getDiscoveredDevice(id: number): Promise<IBaseResponse<DiscoveredCameraDTO>> {
+  return get(`/admin/discovered/${id}`)
+}
+
+// 删除发现设备
+export function deleteDiscoveredDevice(id: number): Promise<BaseResponse> {
+  return post(`/admin/discovered/${id}/delete`)
+}
+
+// 触发凭证匹配
+export function triggerMatch(lssId: string, data?: MatchRequest): Promise<IBaseResponse<MatchResultDTO>> {
+  return post(`/admin/lss/${lssId}/match`, data)
+}
+
+// 重新匹配单个设备
+export function retryMatch(id: number): Promise<IBaseResponse<MatchResultDTO>> {
+  return post(`/admin/discovered/${id}/retry`)
+}
+
+// 绑定设备到现有摄像头
+export function bindDevice(data: BindDeviceRequest): Promise<IBaseResponse<CameraInfoDTO>> {
+  return post('/admin/scan/bind', data)
+}
+
+// ==================== 凭证相关 ====================
+
+// 获取凭证列表
+export function getCredentials(): Promise<IListResponse<CameraCredentialDTO>> {
+  return get('/admin/credentials')
+}
+
+// 添加凭证
+export function addCredential(data: CredentialAddRequest): Promise<IBaseResponse<CameraCredentialDTO>> {
+  return post('/admin/credentials', data)
+}
+
+// 更新凭证
+export function updateCredential(data: CredentialUpdateRequest): Promise<IBaseResponse<CameraCredentialDTO>> {
+  return post('/admin/credentials/update', data)
+}
+
+// 删除凭证
+export function deleteCredential(id: number): Promise<BaseResponse> {
+  return post(`/admin/credentials/${id}/delete`)
+}

+ 41 - 1
src/locales/en.json

@@ -395,5 +395,45 @@
   "预置位设置成功": "Preset set successfully",
   "默认分辨率": "Default Resolution",
   "默认端口": "Default Port",
-  "默认视角": "Default View"
+  "默认视角": "Default View",
+  "扫描": "Scan",
+  "扫描设备": "Scan Devices",
+  "再次扫描": "Scan Again",
+  "再次匹配": "Re-match",
+  "账号配置": "Credential Config",
+  "匹配": "Match",
+  "匹配状态": "Match Status",
+  "已匹配": "Matched",
+  "未匹配": "Unmatched",
+  "匹配中": "Matching",
+  "待匹配": "Pending",
+  "完成": "Done",
+  "绑定": "Bind",
+  "发现设备": "Discovered Devices",
+  "暂无发现设备": "No discovered devices",
+  "扫描中...": "Scanning...",
+  "匹配中...": "Matching...",
+  "扫描完成": "Scan completed",
+  "匹配完成": "Match completed",
+  "扫描失败": "Scan failed",
+  "匹配失败": "Match failed",
+  "绑定成功": "Bindsuccessfully",
+  "绑定失败": "Bind failed",
+  "凭证名称": "Credential Name",
+  "凭证列表": "Credential List",
+  "新增凭证": "Add Credential",
+  "编辑凭证": "Edit Credential",
+  "优先级": "Priority",
+  "备注": "Remark",
+  "成功次数": "Success Count",
+  "失败次数": "Fail Count",
+  "请输入凭证名称": "Please enter credential name",
+  "请输入优先级": "Please enter priority",
+  "请输入备注": "Please enter remark",
+  "确定要删除该凭证吗?": "Are you sure you want to delete this credential?",
+  "删除凭证成功": "Credential deleted",
+  "获取凭证列表失败": "Failed to get credentials",
+  "获取发现设备失败": "Failed to get discovered devices",
+  "当前 LSS 节点尚未扫描设备,是否开启扫描?": "This LSS node has not scanned devices yet. Start scanning?",
+  "开始扫描": "Start Scan"
 }

+ 41 - 1
src/locales/zh-cn.json

@@ -395,5 +395,45 @@
   "预置位设置成功": "预置位设置成功",
   "默认分辨率": "默认分辨率",
   "默认端口": "默认端口",
-  "默认视角": "默认视角"
+  "默认视角": "默认视角",
+  "扫描": "扫描",
+  "扫描设备": "扫描设备",
+  "再次扫描": "再次扫描",
+  "再次匹配": "再次匹配",
+  "账号配置": "账号配置",
+  "匹配": "匹配",
+  "匹配状态": "匹配状态",
+  "已匹配": "已匹配",
+  "未匹配": "未匹配",
+  "匹配中": "匹配中",
+  "待匹配": "待匹配",
+  "完成": "完成",
+  "绑定": "绑定",
+  "发现设备": "发现设备",
+  "暂无发现设备": "暂无发现设备",
+  "扫描中...": "扫描中...",
+  "匹配中...": "匹配中...",
+  "扫描完成": "扫描完成",
+  "匹配完成": "匹配完成",
+  "扫描失败": "扫描失败",
+  "匹配失败": "匹配失败",
+  "绑定成功": "绑定成功",
+  "绑定失败": "绑定失败",
+  "凭证名称": "凭证名称",
+  "凭证列表": "凭证列表",
+  "新增凭证": "新增凭证",
+  "编辑凭证": "编辑凭证",
+  "优先级": "优先级",
+  "备注": "备注",
+  "成功次数": "成功次数",
+  "失败次数": "失败次数",
+  "请输入凭证名称": "请输入凭证名称",
+  "请输入优先级": "请输入优先级",
+  "请输入备注": "请输入备注",
+  "确定要删除该凭证吗?": "确定要删除该凭证吗?",
+  "删除凭证成功": "删除凭证成功",
+  "获取凭证列表失败": "获取凭证列表失败",
+  "获取发现设备失败": "获取发现设备失败",
+  "当前 LSS 节点尚未扫描设备,是否开启扫描?": "当前 LSS 节点尚未扫描设备,是否开启扫描?",
+  "开始扫描": "开始扫描"
 }

+ 96 - 0
src/types/index.ts

@@ -739,6 +739,102 @@ export interface StreamPushChannelDTO {
   recordingEnabled: boolean
 }
 
+// ==================== 摄像头扫描相关类型 ====================
+
+// 扫描匹配状态
+export type MatchStatus = 'PENDING' | 'MATCHING' | 'MATCHED' | 'UNMATCHED'
+
+// 发现设备 DTO
+export interface DiscoveredCameraDTO {
+  id: number
+  lssId: string
+  ip: string
+  port: number
+  uuid: string
+  deviceName: string
+  vendor: string
+  model: string
+  serialNumber: string
+  rtspUrl: string
+  matchStatus: MatchStatus
+  matchedCredentialId?: number
+  matchAttempts: number
+  lastMatchAttempt?: string
+  matchError?: string
+  deviceInfo: string
+  bound: boolean
+  boundCameraId?: number
+  canMatch: boolean
+  discoveredAt: string
+}
+
+// 扫描结果 DTO
+export interface ScanResultDTO {
+  taskId: string
+  lssId: string
+  status: 'SCANNING' | 'COMPLETED' | 'FAILED'
+  discoveredCount: number
+  newCount: number
+  devices: DiscoveredCameraDTO[]
+  error?: string
+}
+
+// 匹配结果 DTO
+export interface MatchResultDTO {
+  taskId: string
+  lssId: string
+  status: 'MATCHING' | 'COMPLETED' | 'FAILED'
+  totalCount: number
+  successCount: number
+  failCount: number
+  error?: string
+}
+
+// 摄像头凭证 DTO
+export interface CameraCredentialDTO {
+  id: number
+  name: string
+  username: string
+  password: string
+  vendor: string
+  priority: number
+  enabled: boolean
+  remark: string
+  successCount: number
+  failCount: number
+  createdAt: string
+  updatedAt: string
+}
+
+// 添加凭证请求
+export interface CredentialAddRequest {
+  name: string
+  username: string
+  password: string
+  vendor?: string
+  priority?: number
+  enabled?: boolean
+  remark?: string
+}
+
+// 更新凭证请求
+export interface CredentialUpdateRequest extends CredentialAddRequest {
+  id: number
+}
+
+// 匹配请求
+export interface MatchRequest {
+  deviceIds?: number[]
+  credentialIds?: number[]
+  maxRetry?: number
+}
+
+// 绑定设备请求
+export interface BindDeviceRequest {
+  discoveredId: number
+  cameraId: number
+}
+
 // 播放信息 DTO
 export interface PlaybackInfoDTO {
   streamSn: string

+ 500 - 3
src/views/lss/index.vue

@@ -631,6 +631,196 @@
       </template>
     </el-drawer>
 
+    <!-- 扫描设备抽屉 -->
+    <el-drawer
+      v-model="scanDrawerVisible"
+      :title="t('扫描')"
+      direction="rtl"
+      size="50%"
+      destroy-on-close
+      class="scan-drawer"
+    >
+      <div class="scan-drawer-content">
+        <div class="scan-toolbar">
+          <div class="scan-toolbar-left">
+            <el-button v-if="scanMatched" @click="handleRematch">
+              <Icon icon="mdi:refresh" width="16" height="16" style="margin-right: 4px" />
+              {{ t('再次匹配') }}
+            </el-button>
+          </div>
+          <div class="scan-toolbar-right">
+            <el-button @click="credentialDrawerVisible = true">
+              <Icon icon="mdi:key-variant" width="16" height="16" style="margin-right: 4px" />
+              {{ t('账号配置') }}
+            </el-button>
+          </div>
+        </div>
+        <el-table v-loading="scanLoading" :data="discoveredDevices" stripe>
+          <template #empty>
+            <el-empty :description="t('暂无发现设备')" />
+          </template>
+          <el-table-column type="index" :label="t('序号')" width="60" align="center" />
+          <el-table-column prop="ip" label="IP" min-width="120" show-overflow-tooltip />
+          <el-table-column prop="port" :label="t('端口')" width="80" align="center" />
+          <el-table-column prop="deviceName" :label="t('设备名称')" min-width="140" show-overflow-tooltip />
+          <el-table-column v-if="scanMatched" :label="t('匹配状态')" width="100" align="center">
+            <template #default="{ row }">
+              <Icon
+                v-if="row.matchStatus === 'MATCHED'"
+                icon="mdi:check-circle"
+                width="20"
+                height="20"
+                style="color: #67c23a"
+              />
+              <Icon
+                v-else-if="row.matchStatus === 'UNMATCHED'"
+                icon="mdi:close-circle"
+                width="20"
+                height="20"
+                style="color: #f56c6c"
+              />
+              <Icon
+                v-else-if="row.matchStatus === 'MATCHING'"
+                icon="mdi:progress-clock"
+                width="20"
+                height="20"
+                style="color: #e6a23c"
+              />
+              <span v-else style="color: #909399">{{ t('待匹配') }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column v-if="scanMatched" :label="t('操作')" width="80" align="center">
+            <template #default="{ row }">
+              <el-button
+                v-if="row.matchStatus === 'MATCHED' && !row.bound"
+                type="primary"
+                link
+                @click="handleBindDevice(row)"
+              >
+                {{ t('添加') }}
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+      <template #footer>
+        <div class="drawer-footer">
+          <el-button @click="scanDrawerVisible = false">{{ t('取消') }}</el-button>
+          <el-button v-if="!scanMatched" type="primary" :loading="matchLoading" @click="handleTriggerMatch">
+            {{ t('匹配') }}
+          </el-button>
+          <el-button v-else type="primary" @click="scanDrawerVisible = false">
+            {{ t('完成') }}
+          </el-button>
+        </div>
+      </template>
+    </el-drawer>
+
+    <!-- 账号配置抽屉(第二层) -->
+    <el-drawer
+      v-model="credentialDrawerVisible"
+      :title="t('账号配置')"
+      direction="rtl"
+      size="50%"
+      destroy-on-close
+      :append-to-body="true"
+      class="credential-drawer"
+    >
+      <div class="credential-content">
+        <div class="credential-toolbar">
+          <el-form :model="credentialSearchForm" inline>
+            <el-form-item>
+              <el-input
+                v-model.trim="credentialSearchForm.username"
+                :placeholder="t('用户名')"
+                clearable
+                style="width: 150px"
+              />
+            </el-form-item>
+            <el-form-item>
+              <el-input
+                v-model.trim="credentialSearchForm.password"
+                :placeholder="t('密码')"
+                clearable
+                style="width: 150px"
+              />
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" @click="loadCredentials">
+                <Icon icon="mdi:magnify" width="16" height="16" style="margin-right: 4px" />
+                {{ t('查询') }}
+              </el-button>
+              <el-button type="info" @click="handleCredentialReset">
+                <Icon icon="mdi:refresh" width="16" height="16" style="margin-right: 4px" />
+                {{ t('重置') }}
+              </el-button>
+              <el-button type="primary" @click="handleAddCredential">
+                <Icon icon="mdi:plus" width="16" height="16" style="margin-right: 4px" />
+                {{ t('新增') }}
+              </el-button>
+            </el-form-item>
+          </el-form>
+        </div>
+        <el-table v-loading="credentialLoading" :data="filteredCredentials" stripe>
+          <template #empty>
+            <el-empty :description="t('暂无发现设备')" />
+          </template>
+          <el-table-column prop="username" :label="t('用户名')" min-width="120" show-overflow-tooltip />
+          <el-table-column prop="password" :label="t('密码')" min-width="120" show-overflow-tooltip />
+          <el-table-column :label="t('设备控制')" width="100" align="center">
+            <template #default="{ row }">
+              <el-button type="primary" link @click="handleEditCredential(row)">
+                <Icon icon="mdi:note-edit-outline" width="20" height="20" />
+              </el-button>
+              <el-button type="danger" link @click="handleDeleteCredential(row)">
+                <Icon icon="mdi:delete" width="20" height="20" />
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-drawer>
+
+    <!-- 凭证编辑对话框 -->
+    <el-dialog
+      v-model="credentialDialogVisible"
+      :title="isEditCredential ? t('编辑凭证') : t('新增凭证')"
+      width="500px"
+      :close-on-click-modal="false"
+      :append-to-body="true"
+      destroy-on-close
+    >
+      <el-form ref="credentialFormRef" :model="credentialForm" :rules="credentialRules" label-width="100px">
+        <el-form-item :label="t('凭证名称')" prop="name">
+          <el-input v-model="credentialForm.name" :placeholder="t('请输入凭证名称')" />
+        </el-form-item>
+        <el-form-item :label="t('用户名')" prop="username">
+          <el-input v-model="credentialForm.username" :placeholder="t('请输入用户名')" />
+        </el-form-item>
+        <el-form-item :label="t('密码')" prop="password">
+          <el-input v-model="credentialForm.password" :placeholder="t('请输入密码')" />
+        </el-form-item>
+        <el-form-item :label="t('厂商')">
+          <el-input v-model="credentialForm.vendor" :placeholder="t('请选择')" />
+        </el-form-item>
+        <el-form-item :label="t('优先级')">
+          <el-input-number v-model="credentialForm.priority" :min="0" :max="999" />
+        </el-form-item>
+        <el-form-item :label="t('启用')">
+          <el-switch v-model="credentialForm.enabled" />
+        </el-form-item>
+        <el-form-item :label="t('备注')">
+          <el-input v-model="credentialForm.remark" type="textarea" :rows="3" :placeholder="t('请输入备注')" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="credentialDialogVisible = false">{{ t('取消') }}</el-button>
+        <el-button type="primary" :loading="credentialSubmitting" @click="handleSubmitCredential">
+          {{ isEditCredential ? t('更新') : t('添加') }}
+        </el-button>
+      </template>
+    </el-dialog>
+
     <!-- 分页 -->
     <div class="pagination-container">
       <el-pagination
@@ -659,6 +849,16 @@ import { useRouter } from 'vue-router'
 import { listLssNodes, deleteLssNode, setLssNodeEnabled, updateLssNode } from '@/api/lss'
 import { adminListCameras, adminAddCamera, adminUpdateCamera, adminDeleteCamera, adminGetCamera } from '@/api/camera'
 import { listCameraVendors } from '@/api/camera-vendor'
+import {
+  scanDevices,
+  getDiscoveredDevices,
+  triggerMatch,
+  bindDevice,
+  getCredentials,
+  addCredential,
+  updateCredential,
+  deleteCredential
+} from '@/api/camera-scan'
 import CodeEditor from '@/components/CodeEditor.vue'
 import type {
   LssNodeDTO,
@@ -670,7 +870,9 @@ import type {
   CameraUpdateRequest,
   CameraVendorDTO,
   IAbly,
-  CameraHeartbeatStatus
+  CameraHeartbeatStatus,
+  DiscoveredCameraDTO,
+  CameraCredentialDTO
 } from '@/types'
 
 const { t } = useI18n({ useScope: 'global' })
@@ -1041,10 +1243,261 @@ function handleViewDetail(row: LssNodeDTO) {
   detailDrawerVisible.value = true
 }
 
-function handleScanDevices(row: LssNodeDTO) {
-  console.log(row)
+// ==================== 扫描设备相关 ====================
+const scanDrawerVisible = ref(false)
+const scanLoading = ref(false)
+const matchLoading = ref(false)
+const scanMatched = ref(false)
+const discoveredDevices = ref<DiscoveredCameraDTO[]>([])
+const scanLssId = ref('')
+
+async function handleScanDevices(row: LssNodeDTO) {
+  scanLssId.value = row.lssId
+  scanMatched.value = false
+
+  // 先尝试获取已有的发现设备
+  try {
+    const res = await getDiscoveredDevices(row.lssId)
+    if (res.success && res.data && res.data.length > 0) {
+      // 已有扫描结果,直接打开抽屉
+      discoveredDevices.value = res.data
+      scanDrawerVisible.value = true
+      return
+    }
+  } catch (error) {
+    console.error('获取发现设备失败', error)
+  }
+
+  // 没有扫描结果,弹确认框询问是否开启扫描
+  try {
+    await ElMessageBox.confirm(t('当前 LSS 节点尚未扫描设备,是否开启扫描?'), t('扫描设备'), {
+      confirmButtonText: t('开始扫描'),
+      cancelButtonText: t('取消'),
+      type: 'info'
+    })
+  } catch {
+    // 用户取消
+    return
+  }
+
+  // 用户确认,触发扫描
+  scanDrawerVisible.value = true
+  scanLoading.value = true
+  try {
+    const res = await scanDevices(row.lssId)
+    if (res.success) {
+      ElMessage.success(t('扫描完成'))
+    } else {
+      ElMessage.error(res.errMessage || t('扫描失败'))
+    }
+    // 刷新列表
+    await loadDiscoveredDevices()
+  } catch (error) {
+    console.error('扫描失败', error)
+    ElMessage.error(t('扫描失败'))
+  } finally {
+    scanLoading.value = false
+  }
+}
+
+async function loadDiscoveredDevices() {
+  if (!scanLssId.value) return
+  scanLoading.value = true
+  try {
+    const res = await getDiscoveredDevices(scanLssId.value)
+    if (res.success) {
+      discoveredDevices.value = res.data || []
+    } else {
+      ElMessage.error(res.errMessage || t('获取发现设备失败'))
+    }
+  } catch (error) {
+    console.error('获取发现设备失败', error)
+  } finally {
+    scanLoading.value = false
+  }
 }
 
+async function handleTriggerMatch() {
+  if (!scanLssId.value) return
+  matchLoading.value = true
+  try {
+    const res = await triggerMatch(scanLssId.value)
+    if (res.success) {
+      ElMessage.success(t('匹配完成'))
+      scanMatched.value = true
+      await loadDiscoveredDevices()
+    } else {
+      ElMessage.error(res.errMessage || t('匹配失败'))
+    }
+  } catch (error) {
+    console.error('匹配失败', error)
+    ElMessage.error(t('匹配失败'))
+  } finally {
+    matchLoading.value = false
+  }
+}
+
+async function handleRematch() {
+  scanMatched.value = false
+  await handleTriggerMatch()
+}
+
+async function handleBindDevice(row: DiscoveredCameraDTO) {
+  // 占位:后续接入真实 bind 接口
+  console.log('bind device', row)
+  ElMessage.info('绑定功能开发中')
+}
+
+// ==================== 凭证管理相关 ====================
+const credentialDrawerVisible = ref(false)
+const credentialLoading = ref(false)
+const credentials = ref<CameraCredentialDTO[]>([])
+const credentialSearchForm = reactive({ username: '', password: '' })
+
+const filteredCredentials = computed(() => {
+  let list = credentials.value
+  if (credentialSearchForm.username) {
+    list = list.filter((c) => c.username.includes(credentialSearchForm.username))
+  }
+  if (credentialSearchForm.password) {
+    list = list.filter((c) => c.password.includes(credentialSearchForm.password))
+  }
+  return list
+})
+
+async function loadCredentials() {
+  credentialLoading.value = true
+  try {
+    const res = await getCredentials()
+    if (res.success) {
+      credentials.value = res.data || []
+    } else {
+      ElMessage.error(res.errMessage || t('获取凭证列表失败'))
+    }
+  } catch (error) {
+    console.error('获取凭证列表失败', error)
+  } finally {
+    credentialLoading.value = false
+  }
+}
+
+function handleCredentialReset() {
+  credentialSearchForm.username = ''
+  credentialSearchForm.password = ''
+}
+
+// 凭证编辑
+const credentialDialogVisible = ref(false)
+const isEditCredential = ref(false)
+const credentialSubmitting = ref(false)
+const currentCredential = ref<CameraCredentialDTO | null>(null)
+const credentialFormRef = ref<FormInstance>()
+const credentialForm = reactive({
+  name: '',
+  username: '',
+  password: '',
+  vendor: '',
+  priority: 0,
+  enabled: true,
+  remark: ''
+})
+
+const credentialRules = computed<FormRules>(() => ({
+  name: [{ required: true, message: t('请输入凭证名称'), trigger: 'blur' }],
+  username: [{ required: true, message: t('请输入用户名'), trigger: 'blur' }],
+  password: [{ required: true, message: t('请输入密码'), trigger: 'blur' }]
+}))
+
+function resetCredentialForm() {
+  credentialForm.name = ''
+  credentialForm.username = ''
+  credentialForm.password = ''
+  credentialForm.vendor = ''
+  credentialForm.priority = 0
+  credentialForm.enabled = true
+  credentialForm.remark = ''
+  credentialFormRef.value?.clearValidate()
+}
+
+function handleAddCredential() {
+  isEditCredential.value = false
+  currentCredential.value = null
+  resetCredentialForm()
+  credentialDialogVisible.value = true
+}
+
+function handleEditCredential(row: CameraCredentialDTO) {
+  isEditCredential.value = true
+  currentCredential.value = row
+  credentialForm.name = row.name
+  credentialForm.username = row.username
+  credentialForm.password = row.password
+  credentialForm.vendor = row.vendor || ''
+  credentialForm.priority = row.priority || 0
+  credentialForm.enabled = row.enabled
+  credentialForm.remark = row.remark || ''
+  credentialDialogVisible.value = true
+}
+
+async function handleSubmitCredential() {
+  if (!credentialFormRef.value) return
+  await credentialFormRef.value.validate(async (valid) => {
+    if (!valid) return
+    credentialSubmitting.value = true
+    try {
+      if (isEditCredential.value && currentCredential.value) {
+        const res = await updateCredential({
+          id: currentCredential.value.id,
+          ...credentialForm
+        })
+        if (res.success) {
+          ElMessage.success(t('更新成功'))
+          credentialDialogVisible.value = false
+          loadCredentials()
+        } else {
+          ElMessage.error(res.errMessage || t('更新失败'))
+        }
+      } else {
+        const res = await addCredential({ ...credentialForm })
+        if (res.success) {
+          ElMessage.success(t('新增成功'))
+          credentialDialogVisible.value = false
+          loadCredentials()
+        } else {
+          ElMessage.error(res.errMessage || t('新增失败'))
+        }
+      }
+    } catch (error) {
+      console.error('保存凭证失败', error)
+      ElMessage.error(t('操作失败'))
+    } finally {
+      credentialSubmitting.value = false
+    }
+  })
+}
+
+async function handleDeleteCredential(row: CameraCredentialDTO) {
+  try {
+    await ElMessageBox.confirm(t('确定要删除该凭证吗?'), t('提示'), { type: 'warning' })
+    const res = await deleteCredential(row.id)
+    if (res.success) {
+      ElMessage.success(t('删除凭证成功'))
+      loadCredentials()
+    } else {
+      ElMessage.error(res.errMessage || t('删除失败'))
+    }
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error('删除凭证失败', error)
+    }
+  }
+}
+
+// 打开凭证抽屉时加载数据
+watch(credentialDrawerVisible, (val) => {
+  if (val) loadCredentials()
+})
+
 function handleEdit(row: LssNodeDTO, tab: 'detail' | 'camera' | 'pusher') {
   currentLss.value = row
   lssEditForm.lssName = row.lssName || ''
@@ -1733,6 +2186,50 @@ onMounted(() => {
   gap: 12px;
 }
 
+// 扫描抽屉样式
+.scan-drawer {
+  :deep(.el-drawer__body) {
+    padding: 0;
+    display: flex;
+    flex-direction: column;
+  }
+}
+
+.scan-drawer-content {
+  flex: 1;
+  overflow-y: auto;
+  padding: 16px;
+}
+
+.scan-toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+// 凭证抽屉样式
+.credential-drawer {
+  :deep(.el-drawer__body) {
+    padding: 0;
+  }
+}
+
+.credential-content {
+  padding: 16px;
+}
+
+.credential-toolbar {
+  margin-bottom: 16px;
+
+  :deep(.el-form) {
+    .el-form-item {
+      margin-bottom: 0;
+      margin-right: 12px;
+    }
+  }
+}
+
 // 表格样式
 :deep(.el-table) {
   --el-table-row-hover-bg-color: #f0f0ff;