Преглед на файлове

feat: introduce CodeEditor component for enhanced code editing

- Added a new CodeEditor component to support Bash and JSON editing with syntax highlighting, formatting, and clipboard functionality.
- Replaced the deprecated JsonEditor component in various views with the new CodeEditor for improved user experience.
- Updated global components declaration to include CodeEditor for accessibility across the application.
- Enhanced live-stream and LSS views to utilize CodeEditor for parameter configuration and command template editing.
yb преди 1 седмица
родител
ревизия
f39df7b22d
променени са 8 файла, в които са добавени 742 реда и са изтрити 228 реда
  1. 1 0
      src/components.d.ts
  2. 141 25
      src/components/CodeEditor.vue
  3. 0 193
      src/components/JsonEditor.vue
  4. 4 0
      src/types/index.ts
  5. 11 6
      src/views/live-stream/index.vue
  6. 15 4
      src/views/lss/index.vue
  7. 264 0
      tests/e2e/live-stream.spec.ts
  8. 306 0
      tests/e2e/lss.spec.ts

+ 1 - 0
src/components.d.ts

@@ -9,6 +9,7 @@ declare module 'vue' {
   export interface GlobalComponents {
     BashEditor: typeof import('./components/BashEditor.vue')['default']
     CameraSelector: typeof import('./components/monitor/CameraSelector.vue')['default']
+    CodeEditor: typeof import('./components/CodeEditor.vue')['default']
     ElAlert: typeof import('element-plus/es')['ElAlert']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElCard: typeof import('element-plus/es')['ElCard']

+ 141 - 25
src/components/BashEditor.vue → src/components/CodeEditor.vue

@@ -1,14 +1,27 @@
 <template>
-  <div class="bash-editor" :style="{ height: height }">
+  <div class="code-editor" :style="{ height: height }">
     <div class="editor-wrapper">
       <div class="editor-header">
         <span class="file-type">
-          <el-icon><Monitor /></el-icon>
-          Bash Script
+          <el-icon :class="iconClass">
+            <component :is="iconComponent" />
+          </el-icon>
+          {{ languageLabel }}
         </span>
-        <el-button class="copy-btn" size="small" :icon="DocumentCopy" @click="handleCopy">
-          {{ t('复制') }}
-        </el-button>
+        <div class="header-actions">
+          <el-button size="small" :icon="DocumentCopy" @click="handleCopy">
+            {{ t('复制') }}
+          </el-button>
+          <el-button
+            v-if="language === 'json'"
+            size="small"
+            :icon="MagicStick"
+            @click="handleFormat"
+            :disabled="!isValidJson"
+          >
+            {{ t('格式化') }}
+          </el-button>
+        </div>
       </div>
       <Codemirror
         v-model="localValue"
@@ -20,22 +33,32 @@
         :placeholder="placeholder"
         @change="handleChange"
       />
+      <div v-if="language === 'json'" v-show="!isValidJson && modelValue" class="validation-error">
+        <el-icon>
+          <WarningFilled />
+        </el-icon>
+        JSON 格式错误
+      </div>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, computed, watch } from 'vue'
+import { ref, computed, watch, markRaw, type Component } from 'vue'
 import { Codemirror } from 'vue-codemirror'
+import { json } from '@codemirror/lang-json'
 import { EditorView } from '@codemirror/view'
-import { DocumentCopy, Monitor } from '@element-plus/icons-vue'
+import { Document, DocumentCopy, MagicStick, Monitor, WarningFilled } from '@element-plus/icons-vue'
 import { ElMessage } from 'element-plus'
 import { useI18n } from 'vue-i18n'
 
 const { t } = useI18n({ useScope: 'global' })
 
+type LanguageType = 'json' | 'bash'
+
 interface Props {
   modelValue: string
+  language?: LanguageType
   height?: string
   autofocus?: boolean
   placeholder?: string
@@ -44,9 +67,10 @@ interface Props {
 
 const props = withDefaults(defineProps<Props>(), {
   modelValue: '',
-  height: '400px',
+  language: 'json',
+  height: '200px',
   autofocus: false,
-  placeholder: '#!/bin/bash\n# Enter your script here...',
+  placeholder: '',
   readonly: false
 })
 
@@ -56,10 +80,31 @@ const emit = defineEmits<{
 
 const localValue = ref(props.modelValue)
 
-// Calculate editor height (minus header)
+// Language-specific configurations
+const languageConfig = computed(() => {
+  const configs: Record<LanguageType, { label: string; icon: Component; iconColor: string }> = {
+    json: {
+      label: 'JSON',
+      icon: markRaw(Document),
+      iconColor: '#e6a23c' // orange
+    },
+    bash: {
+      label: 'Bash Script',
+      icon: markRaw(Monitor),
+      iconColor: '#67c23a' // green
+    }
+  }
+  return configs[props.language]
+})
+
+const languageLabel = computed(() => languageConfig.value.label)
+const iconComponent = computed(() => languageConfig.value.icon)
+const iconClass = computed(() => `icon-${props.language}`)
+
+// Calculate editor height (minus header and potential error)
 const editorHeight = computed(() => {
   const totalHeight = parseInt(props.height)
-  return `${totalHeight - 40}px`
+  return `${totalHeight - 32}px`
 })
 
 // Watch for external changes
@@ -72,15 +117,16 @@ watch(
   }
 )
 
-// Editor extensions with light theme (similar to JsonEditor)
+// Editor extensions
 const extensions = computed(() => {
-  const exts = [
+  const exts: any[] = [
     EditorView.lineWrapping,
     EditorView.theme({
       '&': {
         fontSize: '13px',
         border: '1px solid #dcdfe6',
-        borderRadius: '4px'
+        borderRadius: '0 0 4px 4px',
+        borderTop: 'none'
       },
       '.cm-content': {
         fontFamily: "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace",
@@ -110,6 +156,12 @@ const extensions = computed(() => {
     })
   ]
 
+  // Add language-specific syntax highlighting
+  if (props.language === 'json') {
+    exts.push(json())
+  }
+
+  // Add readonly mode
   if (props.readonly) {
     exts.push(EditorView.editable.of(false))
   }
@@ -117,6 +169,20 @@ const extensions = computed(() => {
   return exts
 })
 
+// Check if current value is valid JSON (only for JSON mode)
+const isValidJson = computed(() => {
+  if (props.language !== 'json') return true
+  if (!localValue.value || !localValue.value.trim()) {
+    return true // Empty is considered valid
+  }
+  try {
+    JSON.parse(localValue.value)
+    return true
+  } catch {
+    return false
+  }
+})
+
 // Handle value change
 function handleChange(value: string) {
   localValue.value = value
@@ -135,8 +201,25 @@ async function handleCopy() {
   }
 }
 
+// Format JSON (only for JSON mode)
+function handleFormat() {
+  if (props.language !== 'json') return
+  if (!localValue.value || !localValue.value.trim()) return
+
+  try {
+    const parsed = JSON.parse(localValue.value)
+    const formatted = JSON.stringify(parsed, null, 2)
+    localValue.value = formatted
+    emit('update:modelValue', formatted)
+  } catch {
+    // Already showing error, do nothing
+  }
+}
+
 // Expose methods
 defineExpose({
+  format: handleFormat,
+  isValid: isValidJson,
   getValue: () => localValue.value,
   setValue: (val: string) => {
     localValue.value = val
@@ -146,7 +229,7 @@ defineExpose({
 </script>
 
 <style lang="scss" scoped>
-.bash-editor {
+.code-editor {
   width: 100%;
   border-radius: 4px;
   overflow: hidden;
@@ -182,26 +265,59 @@ defineExpose({
 
     .el-icon {
       font-size: 14px;
+    }
+
+    .icon-json {
+      color: #e6a23c;
+    }
+
+    .icon-bash {
       color: #67c23a;
     }
   }
 
-  .copy-btn {
-    background-color: transparent;
-    border-color: #dcdfe6;
-    color: #606266;
+  .header-actions {
+    display: flex;
+    gap: 8px;
+
+    .el-button {
+      background-color: transparent;
+      border-color: #dcdfe6;
+      color: #606266;
+
+      &:hover:not(:disabled) {
+        background-color: #ecf5ff;
+        border-color: #c6e2ff;
+        color: #409eff;
+      }
 
-    &:hover {
-      background-color: #ecf5ff;
-      border-color: #c6e2ff;
-      color: #409eff;
+      &:disabled {
+        opacity: 0.5;
+      }
     }
   }
 }
 
+.validation-error {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  padding: 6px 8px;
+  color: #f56c6c;
+  font-size: 12px;
+  background-color: #fef0f0;
+  border: 1px solid #dcdfe6;
+  border-top: none;
+  border-radius: 0 0 4px 4px;
+
+  .el-icon {
+    font-size: 14px;
+  }
+}
+
 :deep(.cm-editor) {
-  height: 100%;
   flex: 1;
+  min-height: 0;
 }
 
 :deep(.cm-scroller) {

+ 0 - 193
src/components/JsonEditor.vue

@@ -1,193 +0,0 @@
-<template>
-  <div class="json-editor" :style="{ height: height }">
-    <div class="editor-wrapper">
-      <el-button class="format-btn" size="small" :icon="DocumentCopy" @click="handleFormat" :disabled="!isValidJson">
-        格式化
-      </el-button>
-      <Codemirror
-        v-model="localValue"
-        :height="`${parseInt(height) - 24}`"
-        :extensions="extensions"
-        :autofocus="autofocus"
-        :indent-with-tab="true"
-        :tab-size="2"
-        @change="handleChange"
-      />
-      <div v-show="!isValidJson && modelValue" class="json-error">
-        <el-icon>
-          <WarningFilled />
-        </el-icon>
-        JSON 格式错误
-      </div>
-    </div>
-  </div>
-</template>
-
-<script setup lang="ts">
-import { ref, computed, watch } from 'vue'
-import { Codemirror } from 'vue-codemirror'
-import { json } from '@codemirror/lang-json'
-import { oneDark } from '@codemirror/theme-one-dark'
-import { EditorView } from '@codemirror/view'
-import { DocumentCopy, WarningFilled } from '@element-plus/icons-vue'
-
-interface Props {
-  modelValue: string
-  height?: string
-  dark?: boolean
-  autofocus?: boolean
-  placeholder?: string
-}
-
-const props = withDefaults(defineProps<Props>(), {
-  modelValue: '',
-  height: '200px',
-  dark: false,
-  autofocus: false,
-  placeholder: ''
-})
-
-const emit = defineEmits<{
-  (e: 'update:modelValue', value: string): void
-}>()
-
-const localValue = ref(props.modelValue)
-
-// Watch for external changes
-watch(
-  () => props.modelValue,
-  (newVal) => {
-    if (newVal !== localValue.value) {
-      localValue.value = newVal
-    }
-  }
-)
-
-// Editor extensions
-const extensions = computed(() => {
-  const exts = [
-    json(),
-    EditorView.lineWrapping,
-    EditorView.theme({
-      '&': {
-        fontSize: '13px',
-        border: '1px solid #dcdfe6',
-        borderRadius: '4px'
-      },
-      '.cm-content': {
-        fontFamily: 'Menlo, Monaco, Consolas, "Courier New", monospace',
-        padding: '8px 0'
-      },
-      '.cm-gutters': {
-        backgroundColor: '#f5f7fa',
-        color: '#909399',
-        border: 'none',
-        borderRight: '1px solid #e4e7ed'
-      },
-      '.cm-activeLineGutter': {
-        backgroundColor: '#e8eaed'
-      },
-      '.cm-activeLine': {
-        backgroundColor: '#f5f7fa'
-      },
-      '&.cm-focused': {
-        outline: 'none'
-      },
-      '&.cm-focused .cm-selectionBackground, ::selection': {
-        backgroundColor: '#d7d4f0 !important'
-      }
-    })
-  ]
-
-  if (props.dark) {
-    exts.push(oneDark)
-  }
-
-  return exts
-})
-
-// Check if current value is valid JSON
-const isValidJson = computed(() => {
-  if (!localValue.value || !localValue.value.trim()) {
-    return true // Empty is considered valid
-  }
-  try {
-    JSON.parse(localValue.value)
-    return true
-  } catch {
-    return false
-  }
-})
-
-// Handle value change
-function handleChange(value: string) {
-  localValue.value = value
-  emit('update:modelValue', value)
-}
-
-// Format JSON
-function handleFormat() {
-  if (!localValue.value || !localValue.value.trim()) return
-
-  try {
-    const parsed = JSON.parse(localValue.value)
-    const formatted = JSON.stringify(parsed, null, 2)
-    localValue.value = formatted
-    emit('update:modelValue', formatted)
-  } catch {
-    // Already showing error, do nothing
-  }
-}
-
-// Expose format method
-defineExpose({
-  format: handleFormat,
-  isValid: isValidJson
-})
-</script>
-
-<style lang="scss" scoped>
-.json-editor {
-  width: 100%;
-  height: 100%;
-}
-
-.editor-wrapper {
-  position: relative;
-  height: 100%;
-}
-
-.format-btn {
-  position: absolute;
-  top: 6px;
-  right: 6px;
-  z-index: 10;
-  opacity: 0.85;
-
-  &:hover {
-    opacity: 1;
-  }
-}
-
-.json-error {
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  margin-top: 6px;
-  height: 24px;
-  color: #f56c6c;
-  font-size: 12px;
-
-  .el-icon {
-    font-size: 14px;
-  }
-}
-
-:deep(.cm-editor) {
-  height: 100%;
-}
-
-:deep(.cm-scroller) {
-  overflow: auto;
-}
-</style>

+ 4 - 0
src/types/index.ts

@@ -544,6 +544,10 @@ export interface LiveStreamDTO {
   status: '1' | '0' // 开启或暂停
   enabled: boolean
   remark?: string
+  /**推流开始时间 */
+  startedAt?: string
+  /**推流结束时间 上一次结束时间 */
+  stoppedAt?: string
   createdAt: string
   updatedAt: string
   taskStreamSn?: string

+ 11 - 6
src/views/live-stream/index.vue

@@ -77,14 +77,14 @@
             />
           </template>
         </el-table-column>
-        <el-table-column prop="streamStartTime" :label="t('启动时间')" width="160" align="center">
+        <el-table-column prop="startedAt" :label="t('启动时间')" width="160" align="center">
           <template #default="{ row }">
-            {{ formatDateTime(row.streamStartTime) }}
+            {{ formatDateTime(row.startedAt) }}
           </template>
         </el-table-column>
-        <el-table-column prop="streamEndTime" :label="t('关闭时间')" width="160" align="center">
+        <el-table-column prop="stoppedAt" :label="t('关闭时间')" width="160" align="center">
           <template #default="{ row }">
-            {{ formatDateTime(row.streamEndTime) }}
+            {{ formatDateTime(row.stoppedAt) }}
           </template>
         </el-table-column>
         <el-table-column :label="t('操作')" align="center" fixed="right">
@@ -188,7 +188,12 @@
 
     <!-- 命令模板查看/编辑弹窗 -->
     <el-dialog v-model="commandDialogVisible" :title="t('命令模板')" width="800px" destroy-on-close>
-      <BashEditor v-model="currentCommandTemplate" height="450px" placeholder="#!/bin/bash&#10;# FFmpeg 推流命令模板" />
+      <CodeEditor
+        v-model="currentCommandTemplate"
+        language="bash"
+        height="450px"
+        placeholder="#!/bin/bash&#10;# FFmpeg 推流命令模板"
+      />
       <template #footer>
         <el-button @click="commandDialogVisible = false">{{ t('关闭') }}</el-button>
         <el-button type="primary" :loading="commandUpdateLoading" @click="handleUpdateCommandTemplate">
@@ -406,7 +411,7 @@ import { adminListCameras } from '@/api/camera'
 
 import { startStreamTask, stopStreamTask, getStreamPlayback } from '@/api/stream-push'
 import VideoPlayer from '@/components/VideoPlayer.vue'
-import BashEditor from '@/components/BashEditor.vue'
+import CodeEditor from '@/components/CodeEditor.vue'
 import { ptzStart, ptzStop, getPresets, gotoPreset, type PresetInfo } from '@/api/camera'
 import type { LiveStreamDTO, LiveStreamStatus, LssNodeDTO, CameraInfoDTO, StreamChannelDTO } from '@/types'
 import dayjs from 'dayjs'

+ 15 - 4
src/views/lss/index.vue

@@ -479,11 +479,21 @@
           <el-input v-model="cameraForm.password" type="password" show-password placeholder="请输入密码" />
         </el-form-item> -->
         <el-form-item label="参数配置">
-          <JsonEditor v-model="cameraForm.paramConfig" placeholder="请输入参数配置 (JSON)" />
+          <CodeEditor
+            v-model="cameraForm.paramConfig"
+            language="json"
+            height="200px"
+            placeholder="请输入参数配置 (JSON)"
+          />
         </el-form-item>
         <br />
         <el-form-item label="设备运行参数">
-          <JsonEditor v-model="cameraForm.runtimeParams" placeholder="设备运行参数 (JSON)" />
+          <CodeEditor
+            v-model="cameraForm.runtimeParams"
+            language="json"
+            height="200px"
+            placeholder="设备运行参数 (JSON)"
+          />
         </el-form-item>
       </el-form>
       <template #footer>
@@ -504,8 +514,9 @@
       destroy-on-close
       class="params-drawer"
     >
-      <JsonEditor
+      <CodeEditor
         v-model="paramsContent"
+        language="json"
         height="500px"
         :placeholder="paramsDialogType === 'config' ? '请输入参数配置(JSON 格式)' : '请输入运行参数(JSON 格式)'"
       />
@@ -541,7 +552,7 @@ import { listLssNodes, deleteLssNode, setLssNodeEnabled, updateLssNode } from '@
 import { adminListCameras, adminAddCamera, adminUpdateCamera, adminDeleteCamera, adminGetCamera } from '@/api/camera'
 import { listCameraVendors } from '@/api/camera-vendor'
 import { Icon } from '@iconify/vue'
-import JsonEditor from '@/components/JsonEditor.vue'
+import CodeEditor from '@/components/CodeEditor.vue'
 import type {
   LssNodeDTO,
   LssNodeStatus,

+ 264 - 0
tests/e2e/live-stream.spec.ts

@@ -332,3 +332,267 @@ test.describe('LiveStream 管理 - 页面功能测试', () => {
     await expect(page.locator('text=LiveStream 管理')).toBeVisible()
   })
 })
+
+test.describe('LiveStream 管理 - CodeEditor 组件测试 (Bash模式)', () => {
+  /**
+   * 测试 CodeEditor Bash 模式 - 头部显示
+   */
+  test('CodeEditor Bash模式 - 验证头部显示Bash Script标签和复制按钮', async ({ page }) => {
+    await login(page)
+    await page.goto('/live-stream')
+
+    // 等待表格加载
+    await page.waitForSelector('tbody tr', { timeout: 10000 })
+
+    // 点击命令模板列的"查看"链接
+    const viewLink = page.locator('tbody tr').first().locator('a:has-text("查看"), a:has-text("View")')
+    await expect(viewLink).toBeVisible({ timeout: 5000 })
+    await viewLink.click()
+
+    // 等待命令模板弹窗打开
+    const dialog = page.locator('.el-dialog').filter({ hasText: '命令模板' })
+    await expect(dialog).toBeVisible({ timeout: 5000 })
+
+    // 验证 CodeEditor 头部显示 Bash Script 标签
+    const codeEditor = dialog.locator('.code-editor')
+    await expect(codeEditor).toBeVisible()
+    await expect(codeEditor.locator('.editor-header')).toBeVisible()
+    await expect(codeEditor.locator('.file-type')).toContainText('Bash Script')
+
+    // 验证 Copy 按钮存在
+    await expect(codeEditor.locator('button:has-text("复制"), button:has-text("Copy")')).toBeVisible()
+
+    // 验证 Bash 模式没有格式化按钮
+    await expect(codeEditor.locator('button:has-text("格式化")')).not.toBeVisible()
+  })
+
+  /**
+   * 测试 CodeEditor Bash 模式 - 复制功能
+   */
+  test('CodeEditor Bash模式 - 复制按钮功能', async ({ page }) => {
+    await login(page)
+    await page.goto('/live-stream')
+
+    // 等待表格加载
+    await page.waitForSelector('tbody tr', { timeout: 10000 })
+
+    // 点击命令模板列的"查看"链接
+    const viewLink = page.locator('tbody tr').first().locator('a:has-text("查看"), a:has-text("View")')
+    await viewLink.click()
+
+    // 等待命令模板弹窗打开
+    const dialog = page.locator('.el-dialog').filter({ hasText: '命令模板' })
+    await expect(dialog).toBeVisible({ timeout: 5000 })
+
+    // 点击复制按钮
+    const copyButton = dialog.locator('.code-editor button:has-text("复制"), .code-editor button:has-text("Copy")')
+    await copyButton.click()
+
+    // 验证复制成功提示
+    await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 3000 })
+  })
+
+  /**
+   * 测试 CodeEditor Bash 模式 - 编辑器可编辑
+   */
+  test('CodeEditor Bash模式 - 编辑器可编辑', async ({ page }) => {
+    await login(page)
+    await page.goto('/live-stream')
+
+    // 等待表格加载
+    await page.waitForSelector('tbody tr', { timeout: 10000 })
+
+    // 点击命令模板列的"查看"链接
+    const viewLink = page.locator('tbody tr').first().locator('a:has-text("查看"), a:has-text("View")')
+    await viewLink.click()
+
+    // 等待命令模板弹窗打开
+    const dialog = page.locator('.el-dialog').filter({ hasText: '命令模板' })
+    await expect(dialog).toBeVisible({ timeout: 5000 })
+
+    // 验证编辑器存在并可以编辑
+    const codeEditor = dialog.locator('.code-editor')
+    const editorContent = codeEditor.locator('.cm-content')
+    await expect(editorContent).toBeVisible()
+
+    // 尝试在编辑器中输入内容
+    await editorContent.click()
+    await page.keyboard.press('End')
+    await page.keyboard.type('\n# Test comment')
+
+    // 验证内容已添加
+    await expect(editorContent).toContainText('# Test comment')
+  })
+
+  /**
+   * 测试 CodeEditor Bash 模式 - 更新按钮存在
+   */
+  test('CodeEditor Bash模式 - 弹窗包含关闭和更新按钮', async ({ page }) => {
+    await login(page)
+    await page.goto('/live-stream')
+
+    // 等待表格加载
+    await page.waitForSelector('tbody tr', { timeout: 10000 })
+
+    // 点击命令模板列的"查看"链接
+    const viewLink = page.locator('tbody tr').first().locator('a:has-text("查看"), a:has-text("View")')
+    await viewLink.click()
+
+    // 等待命令模板弹窗打开
+    const dialog = page.locator('.el-dialog').filter({ hasText: '命令模板' })
+    await expect(dialog).toBeVisible({ timeout: 5000 })
+
+    // 验证关闭和更新按钮存在
+    await expect(dialog.locator('button:has-text("关闭"), button:has-text("Close")')).toBeVisible()
+    await expect(dialog.locator('button:has-text("更新"), button:has-text("Update")')).toBeVisible()
+
+    // 点击关闭按钮
+    await dialog.locator('button:has-text("关闭"), button:has-text("Close")').click()
+    await expect(dialog).not.toBeVisible({ timeout: 5000 })
+  })
+
+  /**
+   * 测试 CodeEditor Bash 模式 - 图标颜色正确(绿色)
+   */
+  test('CodeEditor Bash模式 - 图标显示为绿色', async ({ page }) => {
+    await login(page)
+    await page.goto('/live-stream')
+
+    // 等待表格加载
+    await page.waitForSelector('tbody tr', { timeout: 10000 })
+
+    // 点击命令模板列的"查看"链接
+    const viewLink = page.locator('tbody tr').first().locator('a:has-text("查看"), a:has-text("View")')
+    await viewLink.click()
+
+    // 等待命令模板弹窗打开
+    const dialog = page.locator('.el-dialog').filter({ hasText: '命令模板' })
+    await expect(dialog).toBeVisible({ timeout: 5000 })
+
+    // 验证图标有 icon-bash 类(对应绿色)
+    const codeEditor = dialog.locator('.code-editor')
+    await expect(codeEditor.locator('.icon-bash')).toBeVisible()
+  })
+
+  /**
+   * 测试 CodeEditor Bash 模式 - 更新命令模板并验证保存成功
+   */
+  test('CodeEditor Bash模式 - 更新命令模板并验证保存成功', async ({ page }) => {
+    await login(page)
+    await page.goto('/live-stream')
+
+    // 等待表格加载
+    await page.waitForSelector('tbody tr', { timeout: 10000 })
+
+    // 点击命令模板列的"查看"链接
+    const viewLink = page.locator('tbody tr').first().locator('a:has-text("查看"), a:has-text("View")')
+    await viewLink.click()
+
+    // 等待命令模板弹窗打开
+    const dialog = page.locator('.el-dialog').filter({ hasText: '命令模板' })
+    await expect(dialog).toBeVisible({ timeout: 5000 })
+
+    // 获取编辑器
+    const codeEditor = dialog.locator('.code-editor')
+    const editorContent = codeEditor.locator('.cm-content')
+
+    // 生成唯一标识用于验证更新
+    const timestamp = Date.now()
+    const testComment = `# Test update at ${timestamp}`
+
+    // 在编辑器末尾添加测试注释
+    await editorContent.click()
+    await page.keyboard.press('Meta+End')
+    await page.keyboard.type(`\n${testComment}`)
+
+    // 点击更新按钮
+    const updateButton = dialog.locator('button:has-text("更新"), button:has-text("Update")')
+    await updateButton.click()
+
+    // 等待更新成功提示
+    await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 })
+
+    // 等待弹窗关闭
+    await expect(dialog).not.toBeVisible({ timeout: 5000 })
+
+    // 重新打开命令模板弹窗验证内容已保存
+    await page.waitForTimeout(500)
+    await viewLink.click()
+
+    // 等待弹窗重新打开
+    const dialogReopened = page.locator('.el-dialog').filter({ hasText: '命令模板' })
+    await expect(dialogReopened).toBeVisible({ timeout: 5000 })
+
+    // 验证内容包含我们添加的测试注释
+    const editorContentReopened = dialogReopened.locator('.cm-content')
+    await expect(editorContentReopened).toContainText(timestamp.toString())
+  })
+
+  /**
+   * 测试 CodeEditor Bash 模式 - 修改全部内容并验证保存
+   */
+  test('CodeEditor Bash模式 - 替换全部命令模板并验证保存', async ({ page }) => {
+    await login(page)
+    await page.goto('/live-stream')
+
+    // 等待表格加载
+    await page.waitForSelector('tbody tr', { timeout: 10000 })
+
+    // 点击命令模板列的"查看"链接
+    const viewLink = page.locator('tbody tr').first().locator('a:has-text("查看"), a:has-text("View")')
+    await viewLink.click()
+
+    // 等待命令模板弹窗打开
+    const dialog = page.locator('.el-dialog').filter({ hasText: '命令模板' })
+    await expect(dialog).toBeVisible({ timeout: 5000 })
+
+    // 获取编辑器
+    const codeEditor = dialog.locator('.code-editor')
+    const editorContent = codeEditor.locator('.cm-content')
+
+    // 保存原始内容以便恢复
+    const originalContent = await editorContent.textContent()
+
+    // 生成唯一标识用于验证更新
+    const timestamp = Date.now()
+    const newScript = `#!/bin/bash
+# Updated script at ${timestamp}
+echo "Test script"
+ffmpeg -i {RTSP_URL} -c copy output.mp4`
+
+    // 全选并替换内容
+    await editorContent.click()
+    await page.keyboard.press('Meta+a')
+    await page.keyboard.type(newScript)
+
+    // 点击更新按钮
+    const updateButton = dialog.locator('button:has-text("更新"), button:has-text("Update")')
+    await updateButton.click()
+
+    // 等待更新成功提示
+    await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 })
+
+    // 等待弹窗关闭
+    await expect(dialog).not.toBeVisible({ timeout: 5000 })
+
+    // 重新打开验证内容
+    await page.waitForTimeout(500)
+    await viewLink.click()
+
+    const dialogReopened = page.locator('.el-dialog').filter({ hasText: '命令模板' })
+    await expect(dialogReopened).toBeVisible({ timeout: 5000 })
+
+    const editorContentReopened = dialogReopened.locator('.cm-content')
+    await expect(editorContentReopened).toContainText(`Updated script at ${timestamp}`)
+    await expect(editorContentReopened).toContainText('echo "Test script"')
+
+    // 恢复原始内容
+    if (originalContent) {
+      await editorContentReopened.click()
+      await page.keyboard.press('Meta+a')
+      await page.keyboard.type(originalContent)
+      await dialogReopened.locator('button:has-text("更新"), button:has-text("Update")').click()
+      await page.waitForTimeout(1000)
+    }
+  })
+})

+ 306 - 0
tests/e2e/lss.spec.ts

@@ -179,3 +179,309 @@ test.describe('LSS管理 CRUD 测试', () => {
     await expect(page.locator('text=LSS 管理')).toBeVisible()
   })
 })
+
+test.describe('LSS管理 - CodeEditor 组件测试 (JSON模式)', () => {
+  // 登录辅助函数
+  async function login(page: Page) {
+    await page.goto('/login')
+    await page.evaluate(() => {
+      localStorage.clear()
+      document.cookie.split(';').forEach((c) => {
+        document.cookie = c.replace(/^ +/, '').replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/')
+      })
+    })
+    await page.reload()
+
+    await page.getByPlaceholder('用户名').fill(TEST_USERNAME)
+    await page.getByPlaceholder('密码').fill(TEST_PASSWORD)
+    await page.getByRole('button', { name: '登录' }).click()
+    await expect(page).not.toHaveURL(/\/login/, { timeout: 15000 })
+  }
+
+  /**
+   * 测试 CodeEditor JSON 模式 - 头部显示
+   */
+  test('CodeEditor JSON模式 - 验证头部显示JSON标签和按钮', async ({ page }) => {
+    await login(page)
+    await page.goto('/lss')
+
+    // 等待表格加载
+    await page.waitForTimeout(1000)
+
+    // 点击 Device List 按钮打开设备列表
+    const deviceListButton = page.locator('tbody tr').first().locator('button').first()
+    await expect(deviceListButton).toBeVisible({ timeout: 10000 })
+    await deviceListButton.click()
+
+    // 等待设备列表面板打开
+    const devicePanel = page.locator('.el-drawer, .el-dialog').filter({ hasText: 'Camera List' })
+    await expect(devicePanel).toBeVisible({ timeout: 5000 })
+
+    // 点击 Parameter Configuration 的 View 按钮
+    const viewButton = devicePanel.locator('tbody tr').first().locator('button:has-text("View")').first()
+    await expect(viewButton).toBeVisible({ timeout: 5000 })
+    await viewButton.click()
+
+    // 等待参数配置抽屉打开
+    const paramsDrawer = page.locator('.el-drawer').filter({ hasText: '参数配置' })
+    await expect(paramsDrawer).toBeVisible({ timeout: 5000 })
+
+    // 验证 CodeEditor 头部显示 JSON 标签
+    const codeEditor = paramsDrawer.locator('.code-editor')
+    await expect(codeEditor).toBeVisible()
+    await expect(codeEditor.locator('.editor-header')).toBeVisible()
+    await expect(codeEditor.locator('.file-type')).toContainText('JSON')
+
+    // 验证 Copy 按钮存在
+    await expect(codeEditor.locator('button:has-text("复制"), button:has-text("Copy")')).toBeVisible()
+
+    // 验证 格式化 按钮存在 (JSON模式特有)
+    await expect(codeEditor.locator('button:has-text("格式化")')).toBeVisible()
+  })
+
+  /**
+   * 测试 CodeEditor JSON 模式 - 复制功能
+   */
+  test('CodeEditor JSON模式 - 复制按钮功能', async ({ page }) => {
+    await login(page)
+    await page.goto('/lss')
+
+    // 等待表格加载
+    await page.waitForTimeout(1000)
+
+    // 点击 Device List 按钮
+    const deviceListButton = page.locator('tbody tr').first().locator('button').first()
+    await deviceListButton.click()
+
+    // 等待设备列表面板打开
+    const devicePanel = page.locator('.el-drawer, .el-dialog').filter({ hasText: 'Camera List' })
+    await expect(devicePanel).toBeVisible({ timeout: 5000 })
+
+    // 点击 Parameter Configuration 的 View 按钮
+    const viewButton = devicePanel.locator('tbody tr').first().locator('button:has-text("View")').first()
+    await viewButton.click()
+
+    // 等待参数配置抽屉打开
+    const paramsDrawer = page.locator('.el-drawer').filter({ hasText: '参数配置' })
+    await expect(paramsDrawer).toBeVisible({ timeout: 5000 })
+
+    // 点击复制按钮
+    const copyButton = paramsDrawer.locator(
+      '.code-editor button:has-text("复制"), .code-editor button:has-text("Copy")'
+    )
+    await copyButton.click()
+
+    // 验证复制成功提示
+    await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 3000 })
+  })
+
+  /**
+   * 测试 CodeEditor JSON 模式 - 格式化功能
+   */
+  test('CodeEditor JSON模式 - 格式化按钮功能', async ({ page }) => {
+    await login(page)
+    await page.goto('/lss')
+
+    // 等待表格加载
+    await page.waitForTimeout(1000)
+
+    // 点击 Device List 按钮
+    const deviceListButton = page.locator('tbody tr').first().locator('button').first()
+    await deviceListButton.click()
+
+    // 等待设备列表面板打开
+    const devicePanel = page.locator('.el-drawer, .el-dialog').filter({ hasText: 'Camera List' })
+    await expect(devicePanel).toBeVisible({ timeout: 5000 })
+
+    // 点击 Parameter Configuration 的 View 按钮
+    const viewButton = devicePanel.locator('tbody tr').first().locator('button:has-text("View")').first()
+    await viewButton.click()
+
+    // 等待参数配置抽屉打开
+    const paramsDrawer = page.locator('.el-drawer').filter({ hasText: '参数配置' })
+    await expect(paramsDrawer).toBeVisible({ timeout: 5000 })
+
+    // 验证格式化按钮可用(如果内容是有效JSON)
+    const formatButton = paramsDrawer.locator('.code-editor button:has-text("格式化")')
+    await expect(formatButton).toBeVisible()
+
+    // 点击格式化按钮(如果按钮未禁用)
+    const isDisabled = await formatButton.isDisabled()
+    if (!isDisabled) {
+      await formatButton.click()
+      // 格式化后内容应该保持有效
+      await page.waitForTimeout(300)
+    }
+  })
+
+  /**
+   * 测试 CodeEditor JSON 模式 - 无效JSON显示错误提示
+   */
+  test('CodeEditor JSON模式 - 无效JSON显示错误提示', async ({ page }) => {
+    await login(page)
+    await page.goto('/lss')
+
+    // 等待表格加载
+    await page.waitForTimeout(1000)
+
+    // 点击 Device List 按钮
+    const deviceListButton = page.locator('tbody tr').first().locator('button').first()
+    await deviceListButton.click()
+
+    // 等待设备列表面板打开
+    const devicePanel = page.locator('.el-drawer, .el-dialog').filter({ hasText: 'Camera List' })
+    await expect(devicePanel).toBeVisible({ timeout: 5000 })
+
+    // 点击 Parameter Configuration 的 View 按钮
+    const viewButton = devicePanel.locator('tbody tr').first().locator('button:has-text("View")').first()
+    await viewButton.click()
+
+    // 等待参数配置抽屉打开
+    const paramsDrawer = page.locator('.el-drawer').filter({ hasText: '参数配置' })
+    await expect(paramsDrawer).toBeVisible({ timeout: 5000 })
+
+    // 获取编辑器并输入无效JSON
+    const codeEditor = paramsDrawer.locator('.code-editor')
+    const editorContent = codeEditor.locator('.cm-content')
+
+    // 清空并输入无效JSON
+    await editorContent.click()
+    await page.keyboard.press('Meta+a')
+    await page.keyboard.type('{ invalid json }')
+
+    // 验证错误提示显示
+    await expect(codeEditor.locator('.validation-error')).toBeVisible({ timeout: 3000 })
+    await expect(codeEditor.locator('.validation-error')).toContainText('JSON 格式错误')
+
+    // 验证格式化按钮被禁用
+    const formatButton = codeEditor.locator('button:has-text("格式化")')
+    await expect(formatButton).toBeDisabled()
+  })
+
+  /**
+   * 测试 CodeEditor JSON 模式 - 更新内容并验证保存成功
+   */
+  test('CodeEditor JSON模式 - 更新参数配置并验证保存成功', async ({ page }) => {
+    await login(page)
+    await page.goto('/lss')
+
+    // 等待表格加载
+    await page.waitForTimeout(1000)
+
+    // 点击 Device List 按钮
+    const deviceListButton = page.locator('tbody tr').first().locator('button').first()
+    await deviceListButton.click()
+
+    // 等待设备列表面板打开
+    const devicePanel = page.locator('.el-drawer, .el-dialog').filter({ hasText: 'Camera List' })
+    await expect(devicePanel).toBeVisible({ timeout: 5000 })
+
+    // 点击 Parameter Configuration 的 View 按钮
+    const viewButton = devicePanel.locator('tbody tr').first().locator('button:has-text("View")').first()
+    await viewButton.click()
+
+    // 等待参数配置抽屉打开
+    const paramsDrawer = page.locator('.el-drawer').filter({ hasText: '参数配置' })
+    await expect(paramsDrawer).toBeVisible({ timeout: 5000 })
+
+    // 获取当前编辑器内容
+    const codeEditor = paramsDrawer.locator('.code-editor')
+    const editorContent = codeEditor.locator('.cm-content')
+
+    // 生成唯一标识用于验证更新
+    const timestamp = Date.now()
+    const testValue = `"testUpdate": "${timestamp}"`
+
+    // 修改JSON内容 - 在现有JSON中添加测试字段
+    await editorContent.click()
+    await page.keyboard.press('Meta+a')
+    // 输入新的有效JSON内容
+    await page.keyboard.type(`{\n  ${testValue},\n  "ip": "192.168.0.64"\n}`)
+
+    // 点击更新按钮
+    const updateButton = paramsDrawer.locator('button:has-text("更新"), button:has-text("Update")')
+    await updateButton.click()
+
+    // 等待更新成功提示
+    await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 })
+
+    // 等待抽屉关闭
+    await expect(paramsDrawer).not.toBeVisible({ timeout: 5000 })
+
+    // 重新打开参数配置抽屉验证内容已保存
+    await page.waitForTimeout(500)
+    await viewButton.click()
+
+    // 等待抽屉重新打开
+    const paramsDrawerReopened = page.locator('.el-drawer').filter({ hasText: '参数配置' })
+    await expect(paramsDrawerReopened).toBeVisible({ timeout: 5000 })
+
+    // 验证内容包含我们添加的测试字段
+    const editorContentReopened = paramsDrawerReopened.locator('.cm-content')
+    await expect(editorContentReopened).toContainText(timestamp.toString())
+  })
+
+  /**
+   * 测试 CodeEditor JSON 模式 - 运行参数更新并验证保存成功
+   */
+  test('CodeEditor JSON模式 - 更新运行参数并验证保存成功', async ({ page }) => {
+    await login(page)
+    await page.goto('/lss')
+
+    // 等待表格加载
+    await page.waitForTimeout(1000)
+
+    // 点击 Device List 按钮
+    const deviceListButton = page.locator('tbody tr').first().locator('button').first()
+    await deviceListButton.click()
+
+    // 等待设备列表面板打开
+    const devicePanel = page.locator('.el-drawer, .el-dialog').filter({ hasText: 'Camera List' })
+    await expect(devicePanel).toBeVisible({ timeout: 5000 })
+
+    // 点击 Run Parameters 的 View 按钮(第二个 View 按钮)
+    const viewButtons = devicePanel.locator('tbody tr').first().locator('button:has-text("View")')
+    const runParamsViewButton = viewButtons.nth(1)
+    await expect(runParamsViewButton).toBeVisible({ timeout: 5000 })
+    await runParamsViewButton.click()
+
+    // 等待运行参数抽屉打开
+    const paramsDrawer = page.locator('.el-drawer').filter({ hasText: '运行参数' })
+    await expect(paramsDrawer).toBeVisible({ timeout: 5000 })
+
+    // 获取编辑器
+    const codeEditor = paramsDrawer.locator('.code-editor')
+    const editorContent = codeEditor.locator('.cm-content')
+
+    // 生成唯一标识用于验证更新
+    const timestamp = Date.now()
+    const testValue = `"runtimeTest": "${timestamp}"`
+
+    // 修改JSON内容
+    await editorContent.click()
+    await page.keyboard.press('Meta+a')
+    await page.keyboard.type(`{\n  ${testValue}\n}`)
+
+    // 点击更新按钮
+    const updateButton = paramsDrawer.locator('button:has-text("更新"), button:has-text("Update")')
+    await updateButton.click()
+
+    // 等待更新成功提示
+    await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 })
+
+    // 等待抽屉关闭
+    await expect(paramsDrawer).not.toBeVisible({ timeout: 5000 })
+
+    // 重新打开运行参数抽屉验证内容已保存
+    await page.waitForTimeout(500)
+    await runParamsViewButton.click()
+
+    // 等待抽屉重新打开
+    const paramsDrawerReopened = page.locator('.el-drawer').filter({ hasText: '运行参数' })
+    await expect(paramsDrawerReopened).toBeVisible({ timeout: 5000 })
+
+    // 验证内容包含我们添加的测试字段
+    const editorContentReopened = paramsDrawerReopened.locator('.cm-content')
+    await expect(editorContentReopened).toContainText(timestamp.toString())
+  })
+})