소스 검색

chore: add code quality, testing, and auto-import configuration

- Add ESLint + Prettier + EditorConfig for code quality
- Add Husky + CommitLint + lint-staged for Git hooks
- Add Vitest + Playwright for testing framework
- Add unplugin-auto-import + unplugin-vue-components for auto imports
- Add new npm scripts: lint, test, type-check, etc.
- Format existing code with new style rules

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
yb 3 주 전
부모
커밋
14ac49d2bd
55개의 변경된 파일3887개의 추가작업 그리고 3132개의 파일을 삭제
  1. 41 32
      .agent/workflows/ui-ux-pro-max.md
  2. 42 33
      .claude/skills/ui-ux-pro-max/SKILL.md
  3. 41 32
      .cursor/commands/ui-ux-pro-max.md
  4. 16 0
      .editorconfig
  5. 8 0
      .eslintignore
  6. 96 0
      .eslintrc-auto-import.json
  7. 71 0
      .eslintrc.cjs
  8. 41 32
      .github/prompts/ui-ux-pro-max.prompt.md
  9. 1 0
      .husky/commit-msg
  10. 2 0
      .husky/pre-commit
  11. 1 0
      .husky/pre-push
  12. 41 32
      .kiro/steering/ui-ux-pro-max.md
  13. 34 0
      .prettierrc.cjs
  14. 41 32
      .windsurf/workflows/ui-ux-pro-max.md
  15. 25 0
      commitlint.config.cjs
  16. 311 327
      docs/export-1767780590760.md
  17. 44 1
      package.json
  18. 26 0
      playwright.config.ts
  19. 813 136
      pnpm-lock.yaml
  20. 2 4
      src/App.vue
  21. 9 2
      src/api/audit.ts
  22. 54 36
      src/api/cloudflare-stream.ts
  23. 1 7
      src/api/login.ts
  24. 1 6
      src/api/machine.ts
  25. 25 14
      src/api/stream.ts
  26. 5 2
      src/assets/styles/index.scss
  27. 108 0
      src/auto-imports.d.ts
  28. 69 0
      src/components.d.ts
  29. 6 9
      src/components/HelloWorld.vue
  30. 13 10
      src/components/VideoPlayer.vue
  31. 25 33
      src/layout/index.vue
  32. 3 5
      src/router/index.ts
  33. 23 17
      src/store/stream.ts
  34. 2 9
      src/store/user.ts
  35. 4 4
      src/types/cloudflare.ts
  36. 3 3
      src/utils/request.ts
  37. 12 10
      src/views/audit/index.vue
  38. 4 25
      src/views/camera/channel.vue
  39. 27 138
      src/views/camera/index.vue
  40. 13 13
      src/views/camera/stream-test.vue
  41. 7 21
      src/views/dashboard/index.vue
  42. 21 14
      src/views/login/index.vue
  43. 21 25
      src/views/stats/index.vue
  44. 31 15
      src/views/stream/config.vue
  45. 52 30
      src/views/stream/live-list.vue
  46. 25 22
      src/views/stream/video-list.vue
  47. 134 251
      src/views/user/index.vue
  48. 14 0
      tests/e2e/example.spec.ts
  49. 18 0
      tests/unit/example.spec.ts
  50. 1397 1744
      tg-live-game.postman_collection.json
  51. 2 1
      tsconfig.json
  52. 2 1
      tsconfig.node.json
  53. 7 0
      tsconfig.test.json
  54. 28 4
      vite.config.ts
  55. 24 0
      vitest.config.ts

+ 41 - 32
.agent/workflows/ui-ux-pro-max.md

@@ -18,16 +18,19 @@ python3 --version || python --version
 If Python is not installed, install it based on user's OS:
 
 **macOS:**
+
 ```bash
 brew install python3
 ```
 
 **Ubuntu/Debian:**
+
 ```bash
 sudo apt update && sudo apt install python3
 ```
 
 **Windows:**
+
 ```powershell
 winget install Python.Python.3.12
 ```
@@ -41,6 +44,7 @@ When user requests UI/UX work (design, build, create, implement, review, fix, im
 ### Step 1: Analyze User Requirements
 
 Extract key information from user request:
+
 - **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
 - **Style keywords**: minimal, playful, professional, elegant, dark mode, etc.
 - **Industry**: healthcare, fintech, gaming, education, etc.
@@ -81,29 +85,29 @@ Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`
 
 ### Available Domains
 
-| Domain | Use For | Example Keywords |
-|--------|---------|------------------|
-| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service |
-| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism |
-| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern |
-| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service |
-| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof |
-| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie |
-| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading |
-| `prompt` | AI prompts, CSS keywords | (style name) |
+| Domain       | Use For                              | Example Keywords                                         |
+| ------------ | ------------------------------------ | -------------------------------------------------------- |
+| `product`    | Product type recommendations         | SaaS, e-commerce, portfolio, healthcare, beauty, service |
+| `style`      | UI styles, colors, effects           | glassmorphism, minimalism, dark mode, brutalism          |
+| `typography` | Font pairings, Google Fonts          | elegant, playful, professional, modern                   |
+| `color`      | Color palettes by product type       | saas, ecommerce, healthcare, beauty, fintech, service    |
+| `landing`    | Page structure, CTA strategies       | hero, hero-centric, testimonial, pricing, social-proof   |
+| `chart`      | Chart types, library recommendations | trend, comparison, timeline, funnel, pie                 |
+| `ux`         | Best practices, anti-patterns        | animation, accessibility, z-index, loading               |
+| `prompt`     | AI prompts, CSS keywords             | (style name)                                             |
 
 ### Available Stacks
 
-| Stack | Focus |
-|-------|-------|
+| Stack           | Focus                                          |
+| --------------- | ---------------------------------------------- |
 | `html-tailwind` | Tailwind utilities, responsive, a11y (DEFAULT) |
-| `react` | State, hooks, performance, patterns |
-| `nextjs` | SSR, routing, images, API routes |
-| `vue` | Composition API, Pinia, Vue Router |
-| `svelte` | Runes, stores, SvelteKit |
-| `swiftui` | Views, State, Navigation, Animation |
-| `react-native` | Components, Navigation, Lists |
-| `flutter` | Widgets, State, Layout, Theming |
+| `react`         | State, hooks, performance, patterns            |
+| `nextjs`        | SSR, routing, images, API routes               |
+| `vue`           | Composition API, Pinia, Vue Router             |
+| `svelte`        | Runes, stores, SvelteKit                       |
+| `swiftui`       | Views, State, Navigation, Animation            |
+| `react-native`  | Components, Navigation, Lists                  |
+| `flutter`       | Widgets, State, Layout, Theming                |
 
 ---
 
@@ -163,7 +167,7 @@ These are frequently overlooked issues that make UI look unprofessional:
 ### Icons & Visual Elements
 
 | Rule | Do | Don't |
-|------|----|----- |
+| --- | --- | --- |
 | **No emoji icons** | Use SVG icons (Heroicons, Lucide, Simple Icons) | Use emojis like 🎨 🚀 ⚙️ as UI icons |
 | **Stable hover states** | Use color/opacity transitions on hover | Use scale transforms that shift layout |
 | **Correct brand logos** | Research official SVG from Simple Icons | Guess or use incorrect logo paths |
@@ -172,27 +176,27 @@ These are frequently overlooked issues that make UI look unprofessional:
 ### Interaction & Cursor
 
 | Rule | Do | Don't |
-|------|----|----- |
+| --- | --- | --- |
 | **Cursor pointer** | Add `cursor-pointer` to all clickable/hoverable cards | Leave default cursor on interactive elements |
 | **Hover feedback** | Provide visual feedback (color, shadow, border) | No indication element is interactive |
 | **Smooth transitions** | Use `transition-colors duration-200` | Instant state changes or too slow (>500ms) |
 
 ### Light/Dark Mode Contrast
 
-| Rule | Do | Don't |
-|------|----|----- |
-| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent) |
-| **Text contrast light** | Use `#0F172A` (slate-900) for text | Use `#94A3B8` (slate-400) for body text |
-| **Muted text light** | Use `#475569` (slate-600) minimum | Use gray-400 or lighter |
-| **Border visibility** | Use `border-gray-200` in light mode | Use `border-white/10` (invisible) |
+| Rule                      | Do                                  | Don't                                   |
+| ------------------------- | ----------------------------------- | --------------------------------------- |
+| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent)     |
+| **Text contrast light**   | Use `#0F172A` (slate-900) for text  | Use `#94A3B8` (slate-400) for body text |
+| **Muted text light**      | Use `#475569` (slate-600) minimum   | Use gray-400 or lighter                 |
+| **Border visibility**     | Use `border-gray-200` in light mode | Use `border-white/10` (invisible)       |
 
 ### Layout & Spacing
 
-| Rule | Do | Don't |
-|------|----|----- |
-| **Floating navbar** | Add `top-4 left-4 right-4` spacing | Stick navbar to `top-0 left-0 right-0` |
-| **Content padding** | Account for fixed navbar height | Let content hide behind fixed elements |
-| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths |
+| Rule                     | Do                                  | Don't                                  |
+| ------------------------ | ----------------------------------- | -------------------------------------- |
+| **Floating navbar**      | Add `top-4 left-4 right-4` spacing  | Stick navbar to `top-0 left-0 right-0` |
+| **Content padding**      | Account for fixed navbar height     | Let content hide behind fixed elements |
+| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths         |
 
 ---
 
@@ -201,30 +205,35 @@ These are frequently overlooked issues that make UI look unprofessional:
 Before delivering UI code, verify these items:
 
 ### Visual Quality
+
 - [ ] No emojis used as icons (use SVG instead)
 - [ ] All icons from consistent icon set (Heroicons/Lucide)
 - [ ] Brand logos are correct (verified from Simple Icons)
 - [ ] Hover states don't cause layout shift
 
 ### Interaction
+
 - [ ] All clickable elements have `cursor-pointer`
 - [ ] Hover states provide clear visual feedback
 - [ ] Transitions are smooth (150-300ms)
 - [ ] Focus states visible for keyboard navigation
 
 ### Light/Dark Mode
+
 - [ ] Light mode text has sufficient contrast (4.5:1 minimum)
 - [ ] Glass/transparent elements visible in light mode
 - [ ] Borders visible in both modes
 - [ ] Test both modes before delivery
 
 ### Layout
+
 - [ ] Floating elements have proper spacing from edges
 - [ ] No content hidden behind fixed navbars
 - [ ] Responsive at 320px, 768px, 1024px, 1440px
 - [ ] No horizontal scroll on mobile
 
 ### Accessibility
+
 - [ ] All images have alt text
 - [ ] Form inputs have labels
 - [ ] Color is not the only indicator

+ 42 - 33
.claude/skills/ui-ux-pro-max/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: ui-ux-pro-max
-description: "UI/UX design intelligence. 50 styles, 21 palettes, 50 font pairings, 20 charts, 8 stacks (React, Next.js, Vue, Svelte, SwiftUI, React Native, Flutter, Tailwind). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, mobile app, .html, .tsx, .vue, .svelte. Elements: button, modal, navbar, sidebar, card, table, form, chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, flat design. Topics: color palette, accessibility, animation, layout, typography, font pairing, spacing, hover, shadow, gradient."
+description: 'UI/UX design intelligence. 50 styles, 21 palettes, 50 font pairings, 20 charts, 8 stacks (React, Next.js, Vue, Svelte, SwiftUI, React Native, Flutter, Tailwind). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, mobile app, .html, .tsx, .vue, .svelte. Elements: button, modal, navbar, sidebar, card, table, form, chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, flat design. Topics: color palette, accessibility, animation, layout, typography, font pairing, spacing, hover, shadow, gradient.'
 ---
 
 # UI/UX Pro Max - Design Intelligence
@@ -18,16 +18,19 @@ python3 --version || python --version
 If Python is not installed, install it based on user's OS:
 
 **macOS:**
+
 ```bash
 brew install python3
 ```
 
 **Ubuntu/Debian:**
+
 ```bash
 sudo apt update && sudo apt install python3
 ```
 
 **Windows:**
+
 ```powershell
 winget install Python.Python.3.12
 ```
@@ -41,6 +44,7 @@ When user requests UI/UX work (design, build, create, implement, review, fix, im
 ### Step 1: Analyze User Requirements
 
 Extract key information from user request:
+
 - **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
 - **Style keywords**: minimal, playful, professional, elegant, dark mode, etc.
 - **Industry**: healthcare, fintech, gaming, education, etc.
@@ -81,29 +85,29 @@ Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`
 
 ### Available Domains
 
-| Domain | Use For | Example Keywords |
-|--------|---------|------------------|
-| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service |
-| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism |
-| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern |
-| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service |
-| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof |
-| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie |
-| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading |
-| `prompt` | AI prompts, CSS keywords | (style name) |
+| Domain       | Use For                              | Example Keywords                                         |
+| ------------ | ------------------------------------ | -------------------------------------------------------- |
+| `product`    | Product type recommendations         | SaaS, e-commerce, portfolio, healthcare, beauty, service |
+| `style`      | UI styles, colors, effects           | glassmorphism, minimalism, dark mode, brutalism          |
+| `typography` | Font pairings, Google Fonts          | elegant, playful, professional, modern                   |
+| `color`      | Color palettes by product type       | saas, ecommerce, healthcare, beauty, fintech, service    |
+| `landing`    | Page structure, CTA strategies       | hero, hero-centric, testimonial, pricing, social-proof   |
+| `chart`      | Chart types, library recommendations | trend, comparison, timeline, funnel, pie                 |
+| `ux`         | Best practices, anti-patterns        | animation, accessibility, z-index, loading               |
+| `prompt`     | AI prompts, CSS keywords             | (style name)                                             |
 
 ### Available Stacks
 
-| Stack | Focus |
-|-------|-------|
+| Stack           | Focus                                          |
+| --------------- | ---------------------------------------------- |
 | `html-tailwind` | Tailwind utilities, responsive, a11y (DEFAULT) |
-| `react` | State, hooks, performance, patterns |
-| `nextjs` | SSR, routing, images, API routes |
-| `vue` | Composition API, Pinia, Vue Router |
-| `svelte` | Runes, stores, SvelteKit |
-| `swiftui` | Views, State, Navigation, Animation |
-| `react-native` | Components, Navigation, Lists |
-| `flutter` | Widgets, State, Layout, Theming |
+| `react`         | State, hooks, performance, patterns            |
+| `nextjs`        | SSR, routing, images, API routes               |
+| `vue`           | Composition API, Pinia, Vue Router             |
+| `svelte`        | Runes, stores, SvelteKit                       |
+| `swiftui`       | Views, State, Navigation, Animation            |
+| `react-native`  | Components, Navigation, Lists                  |
+| `flutter`       | Widgets, State, Layout, Theming                |
 
 ---
 
@@ -159,7 +163,7 @@ These are frequently overlooked issues that make UI look unprofessional:
 ### Icons & Visual Elements
 
 | Rule | Do | Don't |
-|------|----|----- |
+| --- | --- | --- |
 | **No emoji icons** | Use SVG icons (Heroicons, Lucide, Simple Icons) | Use emojis like 🎨 🚀 ⚙️ as UI icons |
 | **Stable hover states** | Use color/opacity transitions on hover | Use scale transforms that shift layout |
 | **Correct brand logos** | Research official SVG from Simple Icons | Guess or use incorrect logo paths |
@@ -168,27 +172,27 @@ These are frequently overlooked issues that make UI look unprofessional:
 ### Interaction & Cursor
 
 | Rule | Do | Don't |
-|------|----|----- |
+| --- | --- | --- |
 | **Cursor pointer** | Add `cursor-pointer` to all clickable/hoverable cards | Leave default cursor on interactive elements |
 | **Hover feedback** | Provide visual feedback (color, shadow, border) | No indication element is interactive |
 | **Smooth transitions** | Use `transition-colors duration-200` | Instant state changes or too slow (>500ms) |
 
 ### Light/Dark Mode Contrast
 
-| Rule | Do | Don't |
-|------|----|----- |
-| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent) |
-| **Text contrast light** | Use `#0F172A` (slate-900) for text | Use `#94A3B8` (slate-400) for body text |
-| **Muted text light** | Use `#475569` (slate-600) minimum | Use gray-400 or lighter |
-| **Border visibility** | Use `border-gray-200` in light mode | Use `border-white/10` (invisible) |
+| Rule                      | Do                                  | Don't                                   |
+| ------------------------- | ----------------------------------- | --------------------------------------- |
+| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent)     |
+| **Text contrast light**   | Use `#0F172A` (slate-900) for text  | Use `#94A3B8` (slate-400) for body text |
+| **Muted text light**      | Use `#475569` (slate-600) minimum   | Use gray-400 or lighter                 |
+| **Border visibility**     | Use `border-gray-200` in light mode | Use `border-white/10` (invisible)       |
 
 ### Layout & Spacing
 
-| Rule | Do | Don't |
-|------|----|----- |
-| **Floating navbar** | Add `top-4 left-4 right-4` spacing | Stick navbar to `top-0 left-0 right-0` |
-| **Content padding** | Account for fixed navbar height | Let content hide behind fixed elements |
-| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths |
+| Rule                     | Do                                  | Don't                                  |
+| ------------------------ | ----------------------------------- | -------------------------------------- |
+| **Floating navbar**      | Add `top-4 left-4 right-4` spacing  | Stick navbar to `top-0 left-0 right-0` |
+| **Content padding**      | Account for fixed navbar height     | Let content hide behind fixed elements |
+| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths         |
 
 ---
 
@@ -197,6 +201,7 @@ These are frequently overlooked issues that make UI look unprofessional:
 Before delivering UI code, verify these items:
 
 ### Visual Quality
+
 - [ ] No emojis used as icons (use SVG instead)
 - [ ] All icons from consistent icon set (Heroicons/Lucide)
 - [ ] Brand logos are correct (verified from Simple Icons)
@@ -204,24 +209,28 @@ Before delivering UI code, verify these items:
 - [ ] Use theme colors directly (bg-primary) not var() wrapper
 
 ### Interaction
+
 - [ ] All clickable elements have `cursor-pointer`
 - [ ] Hover states provide clear visual feedback
 - [ ] Transitions are smooth (150-300ms)
 - [ ] Focus states visible for keyboard navigation
 
 ### Light/Dark Mode
+
 - [ ] Light mode text has sufficient contrast (4.5:1 minimum)
 - [ ] Glass/transparent elements visible in light mode
 - [ ] Borders visible in both modes
 - [ ] Test both modes before delivery
 
 ### Layout
+
 - [ ] Floating elements have proper spacing from edges
 - [ ] No content hidden behind fixed navbars
 - [ ] Responsive at 320px, 768px, 1024px, 1440px
 - [ ] No horizontal scroll on mobile
 
 ### Accessibility
+
 - [ ] All images have alt text
 - [ ] Form inputs have labels
 - [ ] Color is not the only indicator

+ 41 - 32
.cursor/commands/ui-ux-pro-max.md

@@ -13,16 +13,19 @@ python3 --version || python --version
 If Python is not installed, install it based on user's OS:
 
 **macOS:**
+
 ```bash
 brew install python3
 ```
 
 **Ubuntu/Debian:**
+
 ```bash
 sudo apt update && sudo apt install python3
 ```
 
 **Windows:**
+
 ```powershell
 winget install Python.Python.3.12
 ```
@@ -36,6 +39,7 @@ When user requests UI/UX work (design, build, create, implement, review, fix, im
 ### Step 1: Analyze User Requirements
 
 Extract key information from user request:
+
 - **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
 - **Style keywords**: minimal, playful, professional, elegant, dark mode, etc.
 - **Industry**: healthcare, fintech, gaming, education, etc.
@@ -76,29 +80,29 @@ Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`
 
 ### Available Domains
 
-| Domain | Use For | Example Keywords |
-|--------|---------|------------------|
-| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service |
-| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism |
-| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern |
-| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service |
-| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof |
-| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie |
-| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading |
-| `prompt` | AI prompts, CSS keywords | (style name) |
+| Domain       | Use For                              | Example Keywords                                         |
+| ------------ | ------------------------------------ | -------------------------------------------------------- |
+| `product`    | Product type recommendations         | SaaS, e-commerce, portfolio, healthcare, beauty, service |
+| `style`      | UI styles, colors, effects           | glassmorphism, minimalism, dark mode, brutalism          |
+| `typography` | Font pairings, Google Fonts          | elegant, playful, professional, modern                   |
+| `color`      | Color palettes by product type       | saas, ecommerce, healthcare, beauty, fintech, service    |
+| `landing`    | Page structure, CTA strategies       | hero, hero-centric, testimonial, pricing, social-proof   |
+| `chart`      | Chart types, library recommendations | trend, comparison, timeline, funnel, pie                 |
+| `ux`         | Best practices, anti-patterns        | animation, accessibility, z-index, loading               |
+| `prompt`     | AI prompts, CSS keywords             | (style name)                                             |
 
 ### Available Stacks
 
-| Stack | Focus |
-|-------|-------|
+| Stack           | Focus                                          |
+| --------------- | ---------------------------------------------- |
 | `html-tailwind` | Tailwind utilities, responsive, a11y (DEFAULT) |
-| `react` | State, hooks, performance, patterns |
-| `nextjs` | SSR, routing, images, API routes |
-| `vue` | Composition API, Pinia, Vue Router |
-| `svelte` | Runes, stores, SvelteKit |
-| `swiftui` | Views, State, Navigation, Animation |
-| `react-native` | Components, Navigation, Lists |
-| `flutter` | Widgets, State, Layout, Theming |
+| `react`         | State, hooks, performance, patterns            |
+| `nextjs`        | SSR, routing, images, API routes               |
+| `vue`           | Composition API, Pinia, Vue Router             |
+| `svelte`        | Runes, stores, SvelteKit                       |
+| `swiftui`       | Views, State, Navigation, Animation            |
+| `react-native`  | Components, Navigation, Lists                  |
+| `flutter`       | Widgets, State, Layout, Theming                |
 
 ---
 
@@ -158,7 +162,7 @@ These are frequently overlooked issues that make UI look unprofessional:
 ### Icons & Visual Elements
 
 | Rule | Do | Don't |
-|------|----|----- |
+| --- | --- | --- |
 | **No emoji icons** | Use SVG icons (Heroicons, Lucide, Simple Icons) | Use emojis like 🎨 🚀 ⚙️ as UI icons |
 | **Stable hover states** | Use color/opacity transitions on hover | Use scale transforms that shift layout |
 | **Correct brand logos** | Research official SVG from Simple Icons | Guess or use incorrect logo paths |
@@ -167,27 +171,27 @@ These are frequently overlooked issues that make UI look unprofessional:
 ### Interaction & Cursor
 
 | Rule | Do | Don't |
-|------|----|----- |
+| --- | --- | --- |
 | **Cursor pointer** | Add `cursor-pointer` to all clickable/hoverable cards | Leave default cursor on interactive elements |
 | **Hover feedback** | Provide visual feedback (color, shadow, border) | No indication element is interactive |
 | **Smooth transitions** | Use `transition-colors duration-200` | Instant state changes or too slow (>500ms) |
 
 ### Light/Dark Mode Contrast
 
-| Rule | Do | Don't |
-|------|----|----- |
-| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent) |
-| **Text contrast light** | Use `#0F172A` (slate-900) for text | Use `#94A3B8` (slate-400) for body text |
-| **Muted text light** | Use `#475569` (slate-600) minimum | Use gray-400 or lighter |
-| **Border visibility** | Use `border-gray-200` in light mode | Use `border-white/10` (invisible) |
+| Rule                      | Do                                  | Don't                                   |
+| ------------------------- | ----------------------------------- | --------------------------------------- |
+| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent)     |
+| **Text contrast light**   | Use `#0F172A` (slate-900) for text  | Use `#94A3B8` (slate-400) for body text |
+| **Muted text light**      | Use `#475569` (slate-600) minimum   | Use gray-400 or lighter                 |
+| **Border visibility**     | Use `border-gray-200` in light mode | Use `border-white/10` (invisible)       |
 
 ### Layout & Spacing
 
-| Rule | Do | Don't |
-|------|----|----- |
-| **Floating navbar** | Add `top-4 left-4 right-4` spacing | Stick navbar to `top-0 left-0 right-0` |
-| **Content padding** | Account for fixed navbar height | Let content hide behind fixed elements |
-| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths |
+| Rule                     | Do                                  | Don't                                  |
+| ------------------------ | ----------------------------------- | -------------------------------------- |
+| **Floating navbar**      | Add `top-4 left-4 right-4` spacing  | Stick navbar to `top-0 left-0 right-0` |
+| **Content padding**      | Account for fixed navbar height     | Let content hide behind fixed elements |
+| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths         |
 
 ---
 
@@ -196,30 +200,35 @@ These are frequently overlooked issues that make UI look unprofessional:
 Before delivering UI code, verify these items:
 
 ### Visual Quality
+
 - [ ] No emojis used as icons (use SVG instead)
 - [ ] All icons from consistent icon set (Heroicons/Lucide)
 - [ ] Brand logos are correct (verified from Simple Icons)
 - [ ] Hover states don't cause layout shift
 
 ### Interaction
+
 - [ ] All clickable elements have `cursor-pointer`
 - [ ] Hover states provide clear visual feedback
 - [ ] Transitions are smooth (150-300ms)
 - [ ] Focus states visible for keyboard navigation
 
 ### Light/Dark Mode
+
 - [ ] Light mode text has sufficient contrast (4.5:1 minimum)
 - [ ] Glass/transparent elements visible in light mode
 - [ ] Borders visible in both modes
 - [ ] Test both modes before delivery
 
 ### Layout
+
 - [ ] Floating elements have proper spacing from edges
 - [ ] No content hidden behind fixed navbars
 - [ ] Responsive at 320px, 768px, 1024px, 1440px
 - [ ] No horizontal scroll on mobile
 
 ### Accessibility
+
 - [ ] All images have alt text
 - [ ] Form inputs have labels
 - [ ] Color is not the only indicator

+ 16 - 0
.editorconfig

@@ -0,0 +1,16 @@
+# Editor configuration, see http://editorconfig.org
+
+# 表示最顶层的 EditorConfig 配置文件
+root = true
+
+[*] # 表示所有文件适用
+charset = utf-8 # 设置文件字符集为 utf-8
+indent_style = space # 缩进风格(tab | space)
+indent_size = 2 # 缩进大小
+end_of_line = lf # 控制换行类型(lf | cr | crlf)
+trim_trailing_whitespace = true # 去除行尾的空白字符
+insert_final_newline = true # 始终在文件末尾插入一个新行
+
+[*.md] # 表示仅 md 文件适用以下规则
+max_line_length = off
+trim_trailing_whitespace = false

+ 8 - 0
.eslintignore

@@ -0,0 +1,8 @@
+node_modules
+dist
+*.d.ts
+src/auto-imports.d.ts
+src/components.d.ts
+coverage
+playwright-report
+test-results

+ 96 - 0
.eslintrc-auto-import.json

@@ -0,0 +1,96 @@
+{
+  "globals": {
+    "Component": true,
+    "ComponentPublicInstance": true,
+    "ComputedRef": true,
+    "DirectiveBinding": true,
+    "EffectScope": true,
+    "ExtractDefaultPropTypes": true,
+    "ExtractPropTypes": true,
+    "ExtractPublicPropTypes": true,
+    "InjectionKey": true,
+    "MaybeRef": true,
+    "MaybeRefOrGetter": true,
+    "PropType": true,
+    "Ref": true,
+    "ShallowRef": true,
+    "Slot": true,
+    "Slots": true,
+    "VNode": true,
+    "WritableComputedRef": true,
+    "acceptHMRUpdate": true,
+    "computed": true,
+    "createApp": true,
+    "createPinia": true,
+    "customRef": true,
+    "defineAsyncComponent": true,
+    "defineComponent": true,
+    "defineStore": true,
+    "effectScope": true,
+    "getActivePinia": true,
+    "getCurrentInstance": true,
+    "getCurrentScope": true,
+    "getCurrentWatcher": true,
+    "h": true,
+    "inject": true,
+    "isProxy": true,
+    "isReactive": true,
+    "isReadonly": true,
+    "isRef": true,
+    "isShallow": true,
+    "mapActions": true,
+    "mapGetters": true,
+    "mapState": true,
+    "mapStores": true,
+    "mapWritableState": true,
+    "markRaw": true,
+    "nextTick": true,
+    "onActivated": true,
+    "onBeforeMount": true,
+    "onBeforeRouteLeave": true,
+    "onBeforeRouteUpdate": true,
+    "onBeforeUnmount": true,
+    "onBeforeUpdate": true,
+    "onDeactivated": true,
+    "onErrorCaptured": true,
+    "onMounted": true,
+    "onRenderTracked": true,
+    "onRenderTriggered": true,
+    "onScopeDispose": true,
+    "onServerPrefetch": true,
+    "onUnmounted": true,
+    "onUpdated": true,
+    "onWatcherCleanup": true,
+    "provide": true,
+    "reactive": true,
+    "readonly": true,
+    "ref": true,
+    "resolveComponent": true,
+    "setActivePinia": true,
+    "setMapStoreSuffix": true,
+    "shallowReactive": true,
+    "shallowReadonly": true,
+    "shallowRef": true,
+    "storeToRefs": true,
+    "toRaw": true,
+    "toRef": true,
+    "toRefs": true,
+    "toValue": true,
+    "triggerRef": true,
+    "unref": true,
+    "useAttrs": true,
+    "useCssModule": true,
+    "useCssVars": true,
+    "useId": true,
+    "useLink": true,
+    "useModel": true,
+    "useRoute": true,
+    "useRouter": true,
+    "useSlots": true,
+    "useTemplateRef": true,
+    "watch": true,
+    "watchEffect": true,
+    "watchPostEffect": true,
+    "watchSyncEffect": true
+  }
+}

+ 71 - 0
.eslintrc.cjs

@@ -0,0 +1,71 @@
+/* eslint-env node */
+module.exports = {
+  env: {
+    browser: true,
+    es2021: true,
+    node: true,
+    'vue/setup-compiler-macros': true
+  },
+  extends: ['plugin:vue/vue3-essential', 'airbnb-base', 'plugin:prettier/recommended', './.eslintrc-auto-import.json'],
+  parserOptions: {
+    ecmaVersion: 12,
+    parser: '@typescript-eslint/parser',
+    sourceType: 'module'
+  },
+  plugins: ['vue', '@typescript-eslint'],
+  rules: {
+    'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
+    eqeqeq: ['error', 'always'],
+    'no-empty': 'off',
+    'import/prefer-default-export': 'off',
+    'import/no-unresolved': 'off',
+    'import/extensions': 'off',
+    'import/no-absolute-path': 'off',
+    'import/no-extraneous-dependencies': 'off',
+    'vue/no-multiple-template-root': 'off',
+    'no-console': 'off',
+    'vue/multi-word-component-names': 'off',
+    'no-param-reassign': 'off',
+    'vue/no-v-model-argument': 'off',
+    'consistent-return': 'off',
+    'vue/no-unused-vars': 'off',
+    'vue/no-reserved-keys': 'off',
+    'vue/no-unused-components': 'off',
+    'vue/no-use-v-if-with-v-for': 'off',
+    '@typescript-eslint/no-unused-vars': 'off',
+    'spaced-comment': 'off',
+    // 关闭一些过于严格的规则
+    'no-use-before-define': 'off',
+    'class-methods-use-this': 'off',
+    'no-restricted-syntax': 'off',
+    'no-restricted-globals': 'off',
+    'prefer-destructuring': 'off',
+    'no-plusplus': 'off'
+  },
+  ignorePatterns: [
+    'package.json',
+    'tsconfig.json',
+    'tsconfig.app.json',
+    'tsconfig.node.json',
+    'tsconfig.test.json',
+    'vite.config.ts',
+    'vitest.config.ts',
+    'playwright.config.ts'
+  ],
+  globals: {
+    ref: 'readonly',
+    reactive: 'readonly',
+    computed: 'readonly',
+    watch: 'readonly',
+    watchEffect: 'readonly',
+    onMounted: 'readonly',
+    onUnmounted: 'readonly',
+    defineProps: 'readonly',
+    defineEmits: 'readonly',
+    defineExpose: 'readonly',
+    withDefaults: 'readonly',
+    ElMessage: 'readonly',
+    ElMessageBox: 'readonly',
+    __APP_VERSION__: 'readonly'
+  }
+}

+ 41 - 32
.github/prompts/ui-ux-pro-max.prompt.md

@@ -19,16 +19,19 @@ python3 --version || python --version
 If Python is not installed, install it based on user's OS:
 
 **macOS:**
+
 ```bash
 brew install python3
 ```
 
 **Ubuntu/Debian:**
+
 ```bash
 sudo apt update && sudo apt install python3
 ```
 
 **Windows:**
+
 ```powershell
 winget install Python.Python.3.12
 ```
@@ -42,6 +45,7 @@ When user requests UI/UX work (design, build, create, implement, review, fix, im
 ### Step 1: Analyze User Requirements
 
 Extract key information from user request:
+
 - **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
 - **Style keywords**: minimal, playful, professional, elegant, dark mode, etc.
 - **Industry**: healthcare, fintech, gaming, education, etc.
@@ -82,29 +86,29 @@ Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`
 
 ### Available Domains
 
-| Domain | Use For | Example Keywords |
-|--------|---------|------------------|
-| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service |
-| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism |
-| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern |
-| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service |
-| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof |
-| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie |
-| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading |
-| `prompt` | AI prompts, CSS keywords | (style name) |
+| Domain       | Use For                              | Example Keywords                                         |
+| ------------ | ------------------------------------ | -------------------------------------------------------- |
+| `product`    | Product type recommendations         | SaaS, e-commerce, portfolio, healthcare, beauty, service |
+| `style`      | UI styles, colors, effects           | glassmorphism, minimalism, dark mode, brutalism          |
+| `typography` | Font pairings, Google Fonts          | elegant, playful, professional, modern                   |
+| `color`      | Color palettes by product type       | saas, ecommerce, healthcare, beauty, fintech, service    |
+| `landing`    | Page structure, CTA strategies       | hero, hero-centric, testimonial, pricing, social-proof   |
+| `chart`      | Chart types, library recommendations | trend, comparison, timeline, funnel, pie                 |
+| `ux`         | Best practices, anti-patterns        | animation, accessibility, z-index, loading               |
+| `prompt`     | AI prompts, CSS keywords             | (style name)                                             |
 
 ### Available Stacks
 
-| Stack | Focus |
-|-------|-------|
+| Stack           | Focus                                          |
+| --------------- | ---------------------------------------------- |
 | `html-tailwind` | Tailwind utilities, responsive, a11y (DEFAULT) |
-| `react` | State, hooks, performance, patterns |
-| `nextjs` | SSR, routing, images, API routes |
-| `vue` | Composition API, Pinia, Vue Router |
-| `svelte` | Runes, stores, SvelteKit |
-| `swiftui` | Views, State, Navigation, Animation |
-| `react-native` | Components, Navigation, Lists |
-| `flutter` | Widgets, State, Layout, Theming |
+| `react`         | State, hooks, performance, patterns            |
+| `nextjs`        | SSR, routing, images, API routes               |
+| `vue`           | Composition API, Pinia, Vue Router             |
+| `svelte`        | Runes, stores, SvelteKit                       |
+| `swiftui`       | Views, State, Navigation, Animation            |
+| `react-native`  | Components, Navigation, Lists                  |
+| `flutter`       | Widgets, State, Layout, Theming                |
 
 ---
 
@@ -164,7 +168,7 @@ These are frequently overlooked issues that make UI look unprofessional:
 ### Icons & Visual Elements
 
 | Rule | Do | Don't |
-|------|----|----- |
+| --- | --- | --- |
 | **No emoji icons** | Use SVG icons (Heroicons, Lucide, Simple Icons) | Use emojis like 🎨 🚀 ⚙️ as UI icons |
 | **Stable hover states** | Use color/opacity transitions on hover | Use scale transforms that shift layout |
 | **Correct brand logos** | Research official SVG from Simple Icons | Guess or use incorrect logo paths |
@@ -173,27 +177,27 @@ These are frequently overlooked issues that make UI look unprofessional:
 ### Interaction & Cursor
 
 | Rule | Do | Don't |
-|------|----|----- |
+| --- | --- | --- |
 | **Cursor pointer** | Add `cursor-pointer` to all clickable/hoverable cards | Leave default cursor on interactive elements |
 | **Hover feedback** | Provide visual feedback (color, shadow, border) | No indication element is interactive |
 | **Smooth transitions** | Use `transition-colors duration-200` | Instant state changes or too slow (>500ms) |
 
 ### Light/Dark Mode Contrast
 
-| Rule | Do | Don't |
-|------|----|----- |
-| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent) |
-| **Text contrast light** | Use `#0F172A` (slate-900) for text | Use `#94A3B8` (slate-400) for body text |
-| **Muted text light** | Use `#475569` (slate-600) minimum | Use gray-400 or lighter |
-| **Border visibility** | Use `border-gray-200` in light mode | Use `border-white/10` (invisible) |
+| Rule                      | Do                                  | Don't                                   |
+| ------------------------- | ----------------------------------- | --------------------------------------- |
+| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent)     |
+| **Text contrast light**   | Use `#0F172A` (slate-900) for text  | Use `#94A3B8` (slate-400) for body text |
+| **Muted text light**      | Use `#475569` (slate-600) minimum   | Use gray-400 or lighter                 |
+| **Border visibility**     | Use `border-gray-200` in light mode | Use `border-white/10` (invisible)       |
 
 ### Layout & Spacing
 
-| Rule | Do | Don't |
-|------|----|----- |
-| **Floating navbar** | Add `top-4 left-4 right-4` spacing | Stick navbar to `top-0 left-0 right-0` |
-| **Content padding** | Account for fixed navbar height | Let content hide behind fixed elements |
-| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths |
+| Rule                     | Do                                  | Don't                                  |
+| ------------------------ | ----------------------------------- | -------------------------------------- |
+| **Floating navbar**      | Add `top-4 left-4 right-4` spacing  | Stick navbar to `top-0 left-0 right-0` |
+| **Content padding**      | Account for fixed navbar height     | Let content hide behind fixed elements |
+| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths         |
 
 ---
 
@@ -202,6 +206,7 @@ These are frequently overlooked issues that make UI look unprofessional:
 Before delivering UI code, verify these items:
 
 ### Visual Quality
+
 - [ ] No emojis used as icons (use SVG instead)
 - [ ] All icons from consistent icon set (Heroicons/Lucide)
 - [ ] Brand logos are correct (verified from Simple Icons)
@@ -209,24 +214,28 @@ Before delivering UI code, verify these items:
 - [ ] Use theme colors directly (bg-primary) not var() wrapper
 
 ### Interaction
+
 - [ ] All clickable elements have `cursor-pointer`
 - [ ] Hover states provide clear visual feedback
 - [ ] Transitions are smooth (150-300ms)
 - [ ] Focus states visible for keyboard navigation
 
 ### Light/Dark Mode
+
 - [ ] Light mode text has sufficient contrast (4.5:1 minimum)
 - [ ] Glass/transparent elements visible in light mode
 - [ ] Borders visible in both modes
 - [ ] Test both modes before delivery
 
 ### Layout
+
 - [ ] Floating elements have proper spacing from edges
 - [ ] No content hidden behind fixed navbars
 - [ ] Responsive at 320px, 768px, 1024px, 1440px
 - [ ] No horizontal scroll on mobile
 
 ### Accessibility
+
 - [ ] All images have alt text
 - [ ] Form inputs have labels
 - [ ] Color is not the only indicator

+ 1 - 0
.husky/commit-msg

@@ -0,0 +1 @@
+npx --no -- commitlint --edit $1

+ 2 - 0
.husky/pre-commit

@@ -0,0 +1,2 @@
+pnpm run format-staged
+pnpm run lint-staged

+ 1 - 0
.husky/pre-push

@@ -0,0 +1 @@
+pnpm run test --run

+ 41 - 32
.kiro/steering/ui-ux-pro-max.md

@@ -17,16 +17,19 @@ python3 --version || python --version
 If Python is not installed, install it based on user's OS:
 
 **macOS:**
+
 ```bash
 brew install python3
 ```
 
 **Ubuntu/Debian:**
+
 ```bash
 sudo apt update && sudo apt install python3
 ```
 
 **Windows:**
+
 ```powershell
 winget install Python.Python.3.12
 ```
@@ -40,6 +43,7 @@ When user requests UI/UX work (design, build, create, implement, review, fix, im
 ### Step 1: Analyze User Requirements
 
 Extract key information from user request:
+
 - **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
 - **Style keywords**: minimal, playful, professional, elegant, dark mode, etc.
 - **Industry**: healthcare, fintech, gaming, education, etc.
@@ -80,29 +84,29 @@ Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`
 
 ### Available Domains
 
-| Domain | Use For | Example Keywords |
-|--------|---------|------------------|
-| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service |
-| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism |
-| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern |
-| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service |
-| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof |
-| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie |
-| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading |
-| `prompt` | AI prompts, CSS keywords | (style name) |
+| Domain       | Use For                              | Example Keywords                                         |
+| ------------ | ------------------------------------ | -------------------------------------------------------- |
+| `product`    | Product type recommendations         | SaaS, e-commerce, portfolio, healthcare, beauty, service |
+| `style`      | UI styles, colors, effects           | glassmorphism, minimalism, dark mode, brutalism          |
+| `typography` | Font pairings, Google Fonts          | elegant, playful, professional, modern                   |
+| `color`      | Color palettes by product type       | saas, ecommerce, healthcare, beauty, fintech, service    |
+| `landing`    | Page structure, CTA strategies       | hero, hero-centric, testimonial, pricing, social-proof   |
+| `chart`      | Chart types, library recommendations | trend, comparison, timeline, funnel, pie                 |
+| `ux`         | Best practices, anti-patterns        | animation, accessibility, z-index, loading               |
+| `prompt`     | AI prompts, CSS keywords             | (style name)                                             |
 
 ### Available Stacks
 
-| Stack | Focus |
-|-------|-------|
+| Stack           | Focus                                          |
+| --------------- | ---------------------------------------------- |
 | `html-tailwind` | Tailwind utilities, responsive, a11y (DEFAULT) |
-| `react` | State, hooks, performance, patterns |
-| `nextjs` | SSR, routing, images, API routes |
-| `vue` | Composition API, Pinia, Vue Router |
-| `svelte` | Runes, stores, SvelteKit |
-| `swiftui` | Views, State, Navigation, Animation |
-| `react-native` | Components, Navigation, Lists |
-| `flutter` | Widgets, State, Layout, Theming |
+| `react`         | State, hooks, performance, patterns            |
+| `nextjs`        | SSR, routing, images, API routes               |
+| `vue`           | Composition API, Pinia, Vue Router             |
+| `svelte`        | Runes, stores, SvelteKit                       |
+| `swiftui`       | Views, State, Navigation, Animation            |
+| `react-native`  | Components, Navigation, Lists                  |
+| `flutter`       | Widgets, State, Layout, Theming                |
 
 ---
 
@@ -162,7 +166,7 @@ These are frequently overlooked issues that make UI look unprofessional:
 ### Icons & Visual Elements
 
 | Rule | Do | Don't |
-|------|----|----- |
+| --- | --- | --- |
 | **No emoji icons** | Use SVG icons (Heroicons, Lucide, Simple Icons) | Use emojis like :art: :rocket: :gear: as UI icons |
 | **Stable hover states** | Use color/opacity transitions on hover | Use scale transforms that shift layout |
 | **Correct brand logos** | Research official SVG from Simple Icons | Guess or use incorrect logo paths |
@@ -171,27 +175,27 @@ These are frequently overlooked issues that make UI look unprofessional:
 ### Interaction & Cursor
 
 | Rule | Do | Don't |
-|------|----|----- |
+| --- | --- | --- |
 | **Cursor pointer** | Add `cursor-pointer` to all clickable/hoverable cards | Leave default cursor on interactive elements |
 | **Hover feedback** | Provide visual feedback (color, shadow, border) | No indication element is interactive |
 | **Smooth transitions** | Use `transition-colors duration-200` | Instant state changes or too slow (>500ms) |
 
 ### Light/Dark Mode Contrast
 
-| Rule | Do | Don't |
-|------|----|----- |
-| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent) |
-| **Text contrast light** | Use `#0F172A` (slate-900) for text | Use `#94A3B8` (slate-400) for body text |
-| **Muted text light** | Use `#475569` (slate-600) minimum | Use gray-400 or lighter |
-| **Border visibility** | Use `border-gray-200` in light mode | Use `border-white/10` (invisible) |
+| Rule                      | Do                                  | Don't                                   |
+| ------------------------- | ----------------------------------- | --------------------------------------- |
+| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent)     |
+| **Text contrast light**   | Use `#0F172A` (slate-900) for text  | Use `#94A3B8` (slate-400) for body text |
+| **Muted text light**      | Use `#475569` (slate-600) minimum   | Use gray-400 or lighter                 |
+| **Border visibility**     | Use `border-gray-200` in light mode | Use `border-white/10` (invisible)       |
 
 ### Layout & Spacing
 
-| Rule | Do | Don't |
-|------|----|----- |
-| **Floating navbar** | Add `top-4 left-4 right-4` spacing | Stick navbar to `top-0 left-0 right-0` |
-| **Content padding** | Account for fixed navbar height | Let content hide behind fixed elements |
-| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths |
+| Rule                     | Do                                  | Don't                                  |
+| ------------------------ | ----------------------------------- | -------------------------------------- |
+| **Floating navbar**      | Add `top-4 left-4 right-4` spacing  | Stick navbar to `top-0 left-0 right-0` |
+| **Content padding**      | Account for fixed navbar height     | Let content hide behind fixed elements |
+| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths         |
 
 ---
 
@@ -200,30 +204,35 @@ These are frequently overlooked issues that make UI look unprofessional:
 Before delivering UI code, verify these items:
 
 ### Visual Quality
+
 - [ ] No emojis used as icons (use SVG instead)
 - [ ] All icons from consistent icon set (Heroicons/Lucide)
 - [ ] Brand logos are correct (verified from Simple Icons)
 - [ ] Hover states don't cause layout shift
 
 ### Interaction
+
 - [ ] All clickable elements have `cursor-pointer`
 - [ ] Hover states provide clear visual feedback
 - [ ] Transitions are smooth (150-300ms)
 - [ ] Focus states visible for keyboard navigation
 
 ### Light/Dark Mode
+
 - [ ] Light mode text has sufficient contrast (4.5:1 minimum)
 - [ ] Glass/transparent elements visible in light mode
 - [ ] Borders visible in both modes
 - [ ] Test both modes before delivery
 
 ### Layout
+
 - [ ] Floating elements have proper spacing from edges
 - [ ] No content hidden behind fixed navbars
 - [ ] Responsive at 320px, 768px, 1024px, 1440px
 - [ ] No horizontal scroll on mobile
 
 ### Accessibility
+
 - [ ] All images have alt text
 - [ ] Form inputs have labels
 - [ ] Color is not the only indicator

+ 34 - 0
.prettierrc.cjs

@@ -0,0 +1,34 @@
+module.exports = {
+  // 换行宽度
+  printWidth: 120,
+  // 缩进空格数
+  tabWidth: 2,
+  // 使用空格而非制表符
+  useTabs: false,
+  // 不使用分号
+  semi: false,
+  // 使用单引号
+  singleQuote: true,
+  // 对象属性按需加引号
+  quoteProps: 'as-needed',
+  // jsx 使用单引号
+  jsxSingleQuote: true,
+  // 不使用尾随逗号
+  trailingComma: 'none',
+  // 对象括号内加空格
+  bracketSpacing: true,
+  // 多行元素的 > 不放在最后一行末尾
+  bracketSameLine: false,
+  // 箭头函数总是加括号
+  arrowParens: 'always',
+  // HTML 空格敏感性
+  htmlWhitespaceSensitivity: 'ignore',
+  // Vue 文件不额外缩进 script/style
+  vueIndentScriptAndStyle: false,
+  // 使用 LF 换行符
+  endOfLine: 'lf',
+  // 不折行 markdown
+  proseWrap: 'never',
+  // 单属性不换行
+  singleAttributePerLine: false
+}

+ 41 - 32
.windsurf/workflows/ui-ux-pro-max.md

@@ -18,16 +18,19 @@ python3 --version || python --version
 If Python is not installed, install it based on user's OS:
 
 **macOS:**
+
 ```bash
 brew install python3
 ```
 
 **Ubuntu/Debian:**
+
 ```bash
 sudo apt update && sudo apt install python3
 ```
 
 **Windows:**
+
 ```powershell
 winget install Python.Python.3.12
 ```
@@ -41,6 +44,7 @@ When user requests UI/UX work (design, build, create, implement, review, fix, im
 ### Step 1: Analyze User Requirements
 
 Extract key information from user request:
+
 - **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
 - **Style keywords**: minimal, playful, professional, elegant, dark mode, etc.
 - **Industry**: healthcare, fintech, gaming, education, etc.
@@ -81,29 +85,29 @@ Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`
 
 ### Available Domains
 
-| Domain | Use For | Example Keywords |
-|--------|---------|------------------|
-| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service |
-| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism |
-| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern |
-| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service |
-| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof |
-| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie |
-| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading |
-| `prompt` | AI prompts, CSS keywords | (style name) |
+| Domain       | Use For                              | Example Keywords                                         |
+| ------------ | ------------------------------------ | -------------------------------------------------------- |
+| `product`    | Product type recommendations         | SaaS, e-commerce, portfolio, healthcare, beauty, service |
+| `style`      | UI styles, colors, effects           | glassmorphism, minimalism, dark mode, brutalism          |
+| `typography` | Font pairings, Google Fonts          | elegant, playful, professional, modern                   |
+| `color`      | Color palettes by product type       | saas, ecommerce, healthcare, beauty, fintech, service    |
+| `landing`    | Page structure, CTA strategies       | hero, hero-centric, testimonial, pricing, social-proof   |
+| `chart`      | Chart types, library recommendations | trend, comparison, timeline, funnel, pie                 |
+| `ux`         | Best practices, anti-patterns        | animation, accessibility, z-index, loading               |
+| `prompt`     | AI prompts, CSS keywords             | (style name)                                             |
 
 ### Available Stacks
 
-| Stack | Focus |
-|-------|-------|
+| Stack           | Focus                                          |
+| --------------- | ---------------------------------------------- |
 | `html-tailwind` | Tailwind utilities, responsive, a11y (DEFAULT) |
-| `react` | State, hooks, performance, patterns |
-| `nextjs` | SSR, routing, images, API routes |
-| `vue` | Composition API, Pinia, Vue Router |
-| `svelte` | Runes, stores, SvelteKit |
-| `swiftui` | Views, State, Navigation, Animation |
-| `react-native` | Components, Navigation, Lists |
-| `flutter` | Widgets, State, Layout, Theming |
+| `react`         | State, hooks, performance, patterns            |
+| `nextjs`        | SSR, routing, images, API routes               |
+| `vue`           | Composition API, Pinia, Vue Router             |
+| `svelte`        | Runes, stores, SvelteKit                       |
+| `swiftui`       | Views, State, Navigation, Animation            |
+| `react-native`  | Components, Navigation, Lists                  |
+| `flutter`       | Widgets, State, Layout, Theming                |
 
 ---
 
@@ -163,7 +167,7 @@ These are frequently overlooked issues that make UI look unprofessional:
 ### Icons & Visual Elements
 
 | Rule | Do | Don't |
-|------|----|----- |
+| --- | --- | --- |
 | **No emoji icons** | Use SVG icons (Heroicons, Lucide, Simple Icons) | Use emojis like 🎨 🚀 ⚙️ as UI icons |
 | **Stable hover states** | Use color/opacity transitions on hover | Use scale transforms that shift layout |
 | **Correct brand logos** | Research official SVG from Simple Icons | Guess or use incorrect logo paths |
@@ -172,27 +176,27 @@ These are frequently overlooked issues that make UI look unprofessional:
 ### Interaction & Cursor
 
 | Rule | Do | Don't |
-|------|----|----- |
+| --- | --- | --- |
 | **Cursor pointer** | Add `cursor-pointer` to all clickable/hoverable cards | Leave default cursor on interactive elements |
 | **Hover feedback** | Provide visual feedback (color, shadow, border) | No indication element is interactive |
 | **Smooth transitions** | Use `transition-colors duration-200` | Instant state changes or too slow (>500ms) |
 
 ### Light/Dark Mode Contrast
 
-| Rule | Do | Don't |
-|------|----|----- |
-| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent) |
-| **Text contrast light** | Use `#0F172A` (slate-900) for text | Use `#94A3B8` (slate-400) for body text |
-| **Muted text light** | Use `#475569` (slate-600) minimum | Use gray-400 or lighter |
-| **Border visibility** | Use `border-gray-200` in light mode | Use `border-white/10` (invisible) |
+| Rule                      | Do                                  | Don't                                   |
+| ------------------------- | ----------------------------------- | --------------------------------------- |
+| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent)     |
+| **Text contrast light**   | Use `#0F172A` (slate-900) for text  | Use `#94A3B8` (slate-400) for body text |
+| **Muted text light**      | Use `#475569` (slate-600) minimum   | Use gray-400 or lighter                 |
+| **Border visibility**     | Use `border-gray-200` in light mode | Use `border-white/10` (invisible)       |
 
 ### Layout & Spacing
 
-| Rule | Do | Don't |
-|------|----|----- |
-| **Floating navbar** | Add `top-4 left-4 right-4` spacing | Stick navbar to `top-0 left-0 right-0` |
-| **Content padding** | Account for fixed navbar height | Let content hide behind fixed elements |
-| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths |
+| Rule                     | Do                                  | Don't                                  |
+| ------------------------ | ----------------------------------- | -------------------------------------- |
+| **Floating navbar**      | Add `top-4 left-4 right-4` spacing  | Stick navbar to `top-0 left-0 right-0` |
+| **Content padding**      | Account for fixed navbar height     | Let content hide behind fixed elements |
+| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths         |
 
 ---
 
@@ -201,30 +205,35 @@ These are frequently overlooked issues that make UI look unprofessional:
 Before delivering UI code, verify these items:
 
 ### Visual Quality
+
 - [ ] No emojis used as icons (use SVG instead)
 - [ ] All icons from consistent icon set (Heroicons/Lucide)
 - [ ] Brand logos are correct (verified from Simple Icons)
 - [ ] Hover states don't cause layout shift
 
 ### Interaction
+
 - [ ] All clickable elements have `cursor-pointer`
 - [ ] Hover states provide clear visual feedback
 - [ ] Transitions are smooth (150-300ms)
 - [ ] Focus states visible for keyboard navigation
 
 ### Light/Dark Mode
+
 - [ ] Light mode text has sufficient contrast (4.5:1 minimum)
 - [ ] Glass/transparent elements visible in light mode
 - [ ] Borders visible in both modes
 - [ ] Test both modes before delivery
 
 ### Layout
+
 - [ ] Floating elements have proper spacing from edges
 - [ ] No content hidden behind fixed navbars
 - [ ] Responsive at 320px, 768px, 1024px, 1440px
 - [ ] No horizontal scroll on mobile
 
 ### Accessibility
+
 - [ ] All images have alt text
 - [ ] Form inputs have labels
 - [ ] Color is not the only indicator

+ 25 - 0
commitlint.config.cjs

@@ -0,0 +1,25 @@
+module.exports = {
+  extends: ['@commitlint/config-conventional'],
+  rules: {
+    // type 类型定义
+    'type-enum': [
+      2,
+      'always',
+      [
+        'feat', // 新功能
+        'fix', // 修复 bug
+        'docs', // 文档变更
+        'style', // 代码格式(不影响功能)
+        'refactor', // 重构(既不是新功能也不是修复 bug)
+        'perf', // 性能优化
+        'test', // 添加测试
+        'build', // 构建系统或外部依赖项的更改
+        'ci', // CI 配置更改
+        'chore', // 其他杂项
+        'revert' // 回滚
+      ]
+    ],
+    // subject 大小写不做校验
+    'subject-case': [0]
+  }
+}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 311 - 327
docs/export-1767780590760.md


+ 44 - 1
package.json

@@ -6,7 +6,29 @@
   "scripts": {
     "dev": "vite",
     "build": "vue-tsc -b && vite build",
-    "preview": "vite preview"
+    "preview": "vite preview",
+    "lint": "eslint . --ext .vue,.js,.jsx,.ts,.tsx",
+    "lint:fix": "eslint . --ext .vue,.js,.jsx,.ts,.tsx --fix",
+    "prettier": "prettier --write .",
+    "format-staged": "pretty-quick --staged",
+    "lint-staged": "lint-staged",
+    "test": "vitest",
+    "test:run": "vitest run",
+    "test:ui": "vitest --ui",
+    "test:coverage": "vitest run --coverage",
+    "test:e2e": "playwright test",
+    "test:e2e:ui": "playwright test --ui",
+    "type-check": "vue-tsc --noEmit",
+    "prepare": "husky"
+  },
+  "lint-staged": {
+    "*.{js,jsx,ts,tsx,vue}": [
+      "eslint --fix",
+      "prettier --write"
+    ],
+    "*.{css,scss,less,html,md,json}": [
+      "prettier --write"
+    ]
   },
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.2",
@@ -19,11 +41,32 @@
     "vue-router": "^4.6.4"
   },
   "devDependencies": {
+    "@commitlint/cli": "^20.3.0",
+    "@commitlint/config-conventional": "^20.3.0",
+    "@playwright/test": "^1.57.0",
     "@types/node": "^20.17.0",
+    "@typescript-eslint/eslint-plugin": "^8.52.0",
+    "@typescript-eslint/parser": "^8.52.0",
     "@vitejs/plugin-vue": "^5.2.0",
+    "@vitest/coverage-v8": "^4.0.16",
+    "@vue/test-utils": "^2.4.6",
     "@vue/tsconfig": "^0.7.0",
+    "eslint": "^8.57.1",
+    "eslint-config-airbnb-base": "^15.0.0",
+    "eslint-config-prettier": "^10.1.8",
+    "eslint-plugin-import": "^2.32.0",
+    "eslint-plugin-prettier": "^5.5.4",
+    "eslint-plugin-vue": "^9.33.0",
+    "happy-dom": "^20.1.0",
+    "husky": "^9.1.7",
+    "lint-staged": "^16.2.7",
+    "prettier": "^3.7.4",
+    "pretty-quick": "^4.2.2",
     "typescript": "~5.6.3",
+    "unplugin-auto-import": "^20.3.0",
+    "unplugin-vue-components": "^30.0.0",
     "vite": "^5.4.0",
+    "vitest": "^4.0.16",
     "vue-tsc": "^2.2.0"
   }
 }

+ 26 - 0
playwright.config.ts

@@ -0,0 +1,26 @@
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+  testDir: './tests/e2e',
+  fullyParallel: true,
+  forbidOnly: !!process.env.CI,
+  retries: process.env.CI ? 2 : 0,
+  workers: process.env.CI ? 1 : undefined,
+  reporter: 'html',
+  use: {
+    baseURL: 'http://localhost:3000',
+    trace: 'on-first-retry',
+    screenshot: 'only-on-failure'
+  },
+  projects: [
+    {
+      name: 'chromium',
+      use: { ...devices['Desktop Chrome'] }
+    }
+  ],
+  webServer: {
+    command: 'pnpm run dev',
+    url: 'http://localhost:3000',
+    reuseExistingServer: !process.env.CI
+  }
+})

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 813 - 136
pnpm-lock.yaml


+ 2 - 4
src/App.vue

@@ -2,8 +2,6 @@
   <router-view />
 </template>
 
-<script setup lang="ts">
-</script>
+<script setup lang="ts"></script>
 
-<style>
-</style>
+<style></style>

+ 9 - 2
src/api/audit.ts

@@ -53,11 +53,18 @@ export function getAuditLogStats(days = 7): Promise<ApiResponse<AuditLogStats>>
 }
 
 // 获取用户操作历史
-export function getUserAuditLogs(userId: string, params?: { page?: number; pageSize?: number }): Promise<ApiResponse<PageResult<AuditLog>>> {
+export function getUserAuditLogs(
+  userId: string,
+  params?: { page?: number; pageSize?: number }
+): Promise<ApiResponse<PageResult<AuditLog>>> {
   return get(`/audit-logs/user/${userId}`, params)
 }
 
 // 获取资源操作历史
-export function getResourceAuditLogs(resource: string, resourceId: string, params?: { page?: number; pageSize?: number }): Promise<ApiResponse<PageResult<AuditLog>>> {
+export function getResourceAuditLogs(
+  resource: string,
+  resourceId: string,
+  params?: { page?: number; pageSize?: number }
+): Promise<ApiResponse<PageResult<AuditLog>>> {
   return get(`/audit-logs/resource/${resource}/${resourceId}`, params)
 }

+ 54 - 36
src/api/cloudflare-stream.ts

@@ -23,14 +23,15 @@ import type {
 
 // Cloudflare Stream 配置
 export interface CloudflareStreamConfig {
-  accountId: string           // Cloudflare Account ID
-  apiToken?: string           // API Token(直接调用时需要)
-  customerSubdomain?: string  // 客户子域名,如 customer-xxx
-  baseUrl?: string            // 自定义 API 基础 URL(用于后端代理)
+  accountId: string // Cloudflare Account ID
+  apiToken?: string // API Token(直接调用时需要)
+  customerSubdomain?: string // 客户子域名,如 customer-xxx
+  baseUrl?: string // 自定义 API 基础 URL(用于后端代理)
 }
 
 class CloudflareStreamApi {
   private client: AxiosInstance
+
   private config: CloudflareStreamConfig
 
   constructor(config: CloudflareStreamConfig) {
@@ -42,7 +43,7 @@ class CloudflareStreamApi {
       timeout: 30000,
       headers: {
         'Content-Type': 'application/json',
-        ...(config.apiToken ? { 'Authorization': `Bearer ${config.apiToken}` } : {})
+        ...(config.apiToken ? { Authorization: `Bearer ${config.apiToken}` } : {})
       }
     })
 
@@ -99,13 +100,16 @@ class CloudflareStreamApi {
   /**
    * 更新视频元数据
    */
-  async updateVideo(videoId: string, data: {
-    meta?: Record<string, any>
-    allowedOrigins?: string[]
-    requireSignedURLs?: boolean
-    scheduledDeletion?: string
-    uploadExpiry?: string
-  }): Promise<CloudflareApiResponse<CloudflareVideo>> {
+  async updateVideo(
+    videoId: string,
+    data: {
+      meta?: Record<string, any>
+      allowedOrigins?: string[]
+      requireSignedURLs?: boolean
+      scheduledDeletion?: string
+      uploadExpiry?: string
+    }
+  ): Promise<CloudflareApiResponse<CloudflareVideo>> {
     return this.client.post(`/${videoId}`, data)
   }
 
@@ -133,7 +137,9 @@ class CloudflareStreamApi {
   /**
    * 创建直接上传 URL(TUS 协议)
    */
-  async createDirectUploadUrl(params?: CreateUploadUrlParams): Promise<CloudflareApiResponse<DirectUploadResponse['result']>> {
+  async createDirectUploadUrl(
+    params?: CreateUploadUrlParams
+  ): Promise<CloudflareApiResponse<DirectUploadResponse['result']>> {
     return this.client.post('/direct_upload', params || {})
   }
 
@@ -144,7 +150,7 @@ class CloudflareStreamApi {
   async uploadWithTus(file: File, uploadUrl: string, onProgress?: (progress: number) => void): Promise<void> {
     // 动态导入 tus-js-client(需要先安装:npm install tus-js-client)
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const tusClient = await import('tus-js-client' as any) as any
+    const tusClient = (await import('tus-js-client' as any)) as any
     const Upload = tusClient.Upload || tusClient.default?.Upload
 
     if (!Upload) {
@@ -198,12 +204,15 @@ class CloudflareStreamApi {
   /**
    * 生成带时间戳的缩略图 URL
    */
-  getThumbnailUrl(videoId: string, options?: {
-    time?: string      // 时间,如 "1s", "5m30s"
-    height?: number    // 高度
-    width?: number     // 宽度
-    fit?: 'crop' | 'clip' | 'scale' | 'fill'
-  }): string {
+  getThumbnailUrl(
+    videoId: string,
+    options?: {
+      time?: string // 时间,如 "1s", "5m30s"
+      height?: number // 高度
+      width?: number // 宽度
+      fit?: 'crop' | 'clip' | 'scale' | 'fill'
+    }
+  ): string {
     const domain = this.getCustomerDomain()
     const params = new URLSearchParams()
     if (options?.time) params.set('time', options.time)
@@ -211,20 +220,23 @@ class CloudflareStreamApi {
     if (options?.width) params.set('width', options.width.toString())
     if (options?.fit) params.set('fit', options.fit)
     const queryString = params.toString()
-    return `https://${domain}/${videoId}/thumbnails/thumbnail.jpg${queryString ? '?' + queryString : ''}`
+    return `https://${domain}/${videoId}/thumbnails/thumbnail.jpg${queryString ? `?${queryString}` : ''}`
   }
 
   /**
    * 生成动图预览 URL
    */
-  getAnimatedThumbnailUrl(videoId: string, options?: {
-    start?: string     // 开始时间
-    end?: string       // 结束时间
-    height?: number
-    width?: number
-    fit?: 'crop' | 'clip' | 'scale' | 'fill'
-    fps?: number
-  }): string {
+  getAnimatedThumbnailUrl(
+    videoId: string,
+    options?: {
+      start?: string // 开始时间
+      end?: string // 结束时间
+      height?: number
+      width?: number
+      fit?: 'crop' | 'clip' | 'scale' | 'fill'
+      fps?: number
+    }
+  ): string {
     const domain = this.getCustomerDomain()
     const params = new URLSearchParams()
     if (options?.start) params.set('start', options.start)
@@ -234,7 +246,7 @@ class CloudflareStreamApi {
     if (options?.fit) params.set('fit', options.fit)
     if (options?.fps) params.set('fps', options.fps.toString())
     const queryString = params.toString()
-    return `https://${domain}/${videoId}/thumbnails/thumbnail.gif${queryString ? '?' + queryString : ''}`
+    return `https://${domain}/${videoId}/thumbnails/thumbnail.gif${queryString ? `?${queryString}` : ''}`
   }
 
   // ==================== 直播管理 ====================
@@ -263,7 +275,10 @@ class CloudflareStreamApi {
   /**
    * 更新直播输入
    */
-  async updateLiveInput(liveInputId: string, params: CreateLiveInputParams): Promise<CloudflareApiResponse<CloudflareLiveInput>> {
+  async updateLiveInput(
+    liveInputId: string,
+    params: CreateLiveInputParams
+  ): Promise<CloudflareApiResponse<CloudflareLiveInput>> {
     return this.client.put(`/live_inputs/${liveInputId}`, params)
   }
 
@@ -303,11 +318,14 @@ class CloudflareStreamApi {
   /**
    * 创建直播输出(转推到其他平台)
    */
-  async createLiveOutput(liveInputId: string, params: {
-    url: string
-    streamKey: string
-    enabled?: boolean
-  }): Promise<CloudflareApiResponse<CloudflareLiveOutput>> {
+  async createLiveOutput(
+    liveInputId: string,
+    params: {
+      url: string
+      streamKey: string
+      enabled?: boolean
+    }
+  ): Promise<CloudflareApiResponse<CloudflareLiveOutput>> {
     return this.client.post(`/live_inputs/${liveInputId}/outputs`, params)
   }
 

+ 1 - 7
src/api/login.ts

@@ -1,11 +1,5 @@
 import { get, post } from '@/utils/request'
-import type {
-  ApiResponse,
-  LoginParams,
-  LoginResponse,
-  AdminInfo,
-  ChangePasswordRequest
-} from '@/types'
+import type { ApiResponse, LoginParams, LoginResponse, AdminInfo, ChangePasswordRequest } from '@/types'
 
 // 登录
 export function login(data: LoginParams): Promise<ApiResponse<LoginResponse>> {

+ 1 - 6
src/api/machine.ts

@@ -1,10 +1,5 @@
 import { get, post } from '@/utils/request'
-import type {
-  ApiResponse,
-  MachineDTO,
-  MachineAddRequest,
-  MachineUpdateRequest
-} from '@/types'
+import type { ApiResponse, MachineDTO, MachineAddRequest, MachineUpdateRequest } from '@/types'
 
 // 获取机器列表
 export function listMachines(): Promise<ApiResponse<MachineDTO[]>> {

+ 25 - 14
src/api/stream.ts

@@ -57,10 +57,12 @@ export function importVideoFromUrl(data: {
 /**
  * 获取上传 URL
  */
-export function getUploadUrl(params?: CreateUploadUrlParams): Promise<ApiResponse<{
-  uploadURL: string
-  uid: string
-}>> {
+export function getUploadUrl(params?: CreateUploadUrlParams): Promise<
+  ApiResponse<{
+    uploadURL: string
+    uid: string
+  }>
+> {
   return post('/stream/video/upload-url', params)
 }
 
@@ -100,7 +102,10 @@ export function getLiveInput(liveInputId: string): Promise<ApiResponse<Cloudflar
 /**
  * 更新直播输入
  */
-export function updateLiveInput(liveInputId: string, data: CreateLiveInputParams): Promise<ApiResponse<CloudflareLiveInput>> {
+export function updateLiveInput(
+  liveInputId: string,
+  data: CreateLiveInputParams
+): Promise<ApiResponse<CloudflareLiveInput>> {
   return put(`/stream/live/${liveInputId}`, data)
 }
 
@@ -121,10 +126,13 @@ export function getLivePlayback(liveInputId: string): Promise<ApiResponse<VideoP
 /**
  * 获取直播录像列表
  */
-export function listLiveRecordings(liveInputId: string, params?: {
-  pageNum?: number
-  pageSize?: number
-}): Promise<ApiResponse<{ rows: CloudflareVideo[]; total: number }>> {
+export function listLiveRecordings(
+  liveInputId: string,
+  params?: {
+    pageNum?: number
+    pageSize?: number
+  }
+): Promise<ApiResponse<{ rows: CloudflareVideo[]; total: number }>> {
   return get(`/stream/live/${liveInputId}/recordings`, params)
 }
 
@@ -140,11 +148,14 @@ export function listLiveOutputs(liveInputId: string): Promise<ApiResponse<any[]>
 /**
  * 创建转推输出
  */
-export function createLiveOutput(liveInputId: string, data: {
-  url: string
-  streamKey: string
-  enabled?: boolean
-}): Promise<ApiResponse<any>> {
+export function createLiveOutput(
+  liveInputId: string,
+  data: {
+    url: string
+    streamKey: string
+    enabled?: boolean
+  }
+): Promise<ApiResponse<any>> {
   return post(`/stream/live/${liveInputId}/outputs`, data)
 }
 

+ 5 - 2
src/assets/styles/index.scss

@@ -6,7 +6,9 @@
   box-sizing: border-box;
 }
 
-html, body, #app {
+html,
+body,
+#app {
   width: 100%;
   height: 100%;
   font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
@@ -20,7 +22,8 @@ a {
   color: inherit;
 }
 
-ul, ol {
+ul,
+ol {
   list-style: none;
 }
 

+ 108 - 0
src/auto-imports.d.ts

@@ -0,0 +1,108 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// noinspection JSUnusedGlobalSymbols
+// Generated by unplugin-auto-import
+// biome-ignore lint: disable
+export {}
+declare global {
+  const EffectScope: typeof import('vue').EffectScope
+  const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
+  const computed: typeof import('vue').computed
+  const createApp: typeof import('vue').createApp
+  const createPinia: typeof import('pinia').createPinia
+  const customRef: typeof import('vue').customRef
+  const defineAsyncComponent: typeof import('vue').defineAsyncComponent
+  const defineComponent: typeof import('vue').defineComponent
+  const defineStore: typeof import('pinia').defineStore
+  const effectScope: typeof import('vue').effectScope
+  const getActivePinia: typeof import('pinia').getActivePinia
+  const getCurrentInstance: typeof import('vue').getCurrentInstance
+  const getCurrentScope: typeof import('vue').getCurrentScope
+  const getCurrentWatcher: typeof import('vue').getCurrentWatcher
+  const h: typeof import('vue').h
+  const inject: typeof import('vue').inject
+  const isProxy: typeof import('vue').isProxy
+  const isReactive: typeof import('vue').isReactive
+  const isReadonly: typeof import('vue').isReadonly
+  const isRef: typeof import('vue').isRef
+  const isShallow: typeof import('vue').isShallow
+  const mapActions: typeof import('pinia').mapActions
+  const mapGetters: typeof import('pinia').mapGetters
+  const mapState: typeof import('pinia').mapState
+  const mapStores: typeof import('pinia').mapStores
+  const mapWritableState: typeof import('pinia').mapWritableState
+  const markRaw: typeof import('vue').markRaw
+  const nextTick: typeof import('vue').nextTick
+  const onActivated: typeof import('vue').onActivated
+  const onBeforeMount: typeof import('vue').onBeforeMount
+  const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
+  const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
+  const onBeforeUnmount: typeof import('vue').onBeforeUnmount
+  const onBeforeUpdate: typeof import('vue').onBeforeUpdate
+  const onDeactivated: typeof import('vue').onDeactivated
+  const onErrorCaptured: typeof import('vue').onErrorCaptured
+  const onMounted: typeof import('vue').onMounted
+  const onRenderTracked: typeof import('vue').onRenderTracked
+  const onRenderTriggered: typeof import('vue').onRenderTriggered
+  const onScopeDispose: typeof import('vue').onScopeDispose
+  const onServerPrefetch: typeof import('vue').onServerPrefetch
+  const onUnmounted: typeof import('vue').onUnmounted
+  const onUpdated: typeof import('vue').onUpdated
+  const onWatcherCleanup: typeof import('vue').onWatcherCleanup
+  const provide: typeof import('vue').provide
+  const reactive: typeof import('vue').reactive
+  const readonly: typeof import('vue').readonly
+  const ref: typeof import('vue').ref
+  const resolveComponent: typeof import('vue').resolveComponent
+  const setActivePinia: typeof import('pinia').setActivePinia
+  const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
+  const shallowReactive: typeof import('vue').shallowReactive
+  const shallowReadonly: typeof import('vue').shallowReadonly
+  const shallowRef: typeof import('vue').shallowRef
+  const storeToRefs: typeof import('pinia').storeToRefs
+  const toRaw: typeof import('vue').toRaw
+  const toRef: typeof import('vue').toRef
+  const toRefs: typeof import('vue').toRefs
+  const toValue: typeof import('vue').toValue
+  const triggerRef: typeof import('vue').triggerRef
+  const unref: typeof import('vue').unref
+  const useAttrs: typeof import('vue').useAttrs
+  const useCssModule: typeof import('vue').useCssModule
+  const useCssVars: typeof import('vue').useCssVars
+  const useId: typeof import('vue').useId
+  const useLink: typeof import('vue-router').useLink
+  const useModel: typeof import('vue').useModel
+  const useRoute: typeof import('vue-router').useRoute
+  const useRouter: typeof import('vue-router').useRouter
+  const useSlots: typeof import('vue').useSlots
+  const useTemplateRef: typeof import('vue').useTemplateRef
+  const watch: typeof import('vue').watch
+  const watchEffect: typeof import('vue').watchEffect
+  const watchPostEffect: typeof import('vue').watchPostEffect
+  const watchSyncEffect: typeof import('vue').watchSyncEffect
+}
+// for type re-export
+declare global {
+  // @ts-ignore
+  export type {
+    Component,
+    Slot,
+    Slots,
+    ComponentPublicInstance,
+    ComputedRef,
+    DirectiveBinding,
+    ExtractDefaultPropTypes,
+    ExtractPropTypes,
+    ExtractPublicPropTypes,
+    InjectionKey,
+    PropType,
+    Ref,
+    ShallowRef,
+    MaybeRef,
+    MaybeRefOrGetter,
+    VNode,
+    WritableComputedRef
+  } from 'vue'
+  import('vue')
+}

+ 69 - 0
src/components.d.ts

@@ -0,0 +1,69 @@
+/* eslint-disable */
+// @ts-nocheck
+// biome-ignore lint: disable
+// oxlint-disable
+// ------
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+
+export {}
+
+/* prettier-ignore */
+declare module 'vue' {
+  export interface GlobalComponents {
+    ElAlert: typeof import('element-plus/es')['ElAlert']
+    ElAside: typeof import('element-plus/es')['ElAside']
+    ElAvatar: typeof import('element-plus/es')['ElAvatar']
+    ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
+    ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
+    ElButton: typeof import('element-plus/es')['ElButton']
+    ElCard: typeof import('element-plus/es')['ElCard']
+    ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+    ElCol: typeof import('element-plus/es')['ElCol']
+    ElCollapse: typeof import('element-plus/es')['ElCollapse']
+    ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
+    ElContainer: typeof import('element-plus/es')['ElContainer']
+    ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+    ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
+    ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
+    ElDialog: typeof import('element-plus/es')['ElDialog']
+    ElDivider: typeof import('element-plus/es')['ElDivider']
+    ElDropdown: typeof import('element-plus/es')['ElDropdown']
+    ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
+    ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
+    ElEmpty: typeof import('element-plus/es')['ElEmpty']
+    ElForm: typeof import('element-plus/es')['ElForm']
+    ElFormItem: typeof import('element-plus/es')['ElFormItem']
+    ElHeader: typeof import('element-plus/es')['ElHeader']
+    ElIcon: typeof import('element-plus/es')['ElIcon']
+    ElImage: typeof import('element-plus/es')['ElImage']
+    ElInput: typeof import('element-plus/es')['ElInput']
+    ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
+    ElMain: typeof import('element-plus/es')['ElMain']
+    ElMenu: typeof import('element-plus/es')['ElMenu']
+    ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
+    ElOption: typeof import('element-plus/es')['ElOption']
+    ElPagination: typeof import('element-plus/es')['ElPagination']
+    ElProgress: typeof import('element-plus/es')['ElProgress']
+    ElResult: typeof import('element-plus/es')['ElResult']
+    ElRow: typeof import('element-plus/es')['ElRow']
+    ElSelect: typeof import('element-plus/es')['ElSelect']
+    ElSpace: typeof import('element-plus/es')['ElSpace']
+    ElStatistic: typeof import('element-plus/es')['ElStatistic']
+    ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
+    ElSwitch: typeof import('element-plus/es')['ElSwitch']
+    ElTable: typeof import('element-plus/es')['ElTable']
+    ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
+    ElTabPane: typeof import('element-plus/es')['ElTabPane']
+    ElTabs: typeof import('element-plus/es')['ElTabs']
+    ElTag: typeof import('element-plus/es')['ElTag']
+    ElUpload: typeof import('element-plus/es')['ElUpload']
+    HelloWorld: typeof import('./components/HelloWorld.vue')['default']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+    VideoPlayer: typeof import('./components/VideoPlayer.vue')['default']
+  }
+  export interface GlobalDirectives {
+    vLoading: typeof import('element-plus/es')['ElLoadingDirective']
+  }
+}

+ 6 - 9
src/components/HelloWorld.vue

@@ -13,23 +13,20 @@ const count = ref(0)
     <button type="button" @click="count++">count is {{ count }}</button>
     <p>
       Edit
-      <code>components/HelloWorld.vue</code> to test HMR
+      <code>components/HelloWorld.vue</code>
+      to test HMR
     </p>
   </div>
 
   <p>
     Check out
-    <a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
-      >create-vue</a
-    >, the official Vue + Vite starter
+    <a href="https://vuejs.org/guide/quick-start.html#local" target="_blank">create-vue</a>
+    , the official Vue + Vite starter
   </p>
   <p>
     Learn more about IDE Support for Vue in the
-    <a
-      href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
-      target="_blank"
-      >Vue Docs Scaling up Guide</a
-    >.
+    <a href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support" target="_blank">Vue Docs Scaling up Guide</a>
+    .
   </p>
   <p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
 </template>

+ 13 - 10
src/components/VideoPlayer.vue

@@ -45,11 +45,11 @@
 import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
 
 interface Props {
-  src?: string                    // 视频源地址
-  videoId?: string                // Cloudflare Stream video ID
-  customerDomain?: string         // Cloudflare 自定义域名
+  src?: string // 视频源地址
+  videoId?: string // Cloudflare Stream video ID
+  customerDomain?: string // Cloudflare 自定义域名
   playerType?: 'cloudflare' | 'hls' | 'native'
-  useIframe?: boolean             // Cloudflare 是否使用 iframe
+  useIframe?: boolean // Cloudflare 是否使用 iframe
   controls?: boolean
   autoplay?: boolean
   muted?: boolean
@@ -91,7 +91,7 @@ const cloudflareIframeSrc = computed(() => {
   if (props.loop) params.set('loop', 'true')
   if (props.preload) params.set('preload', props.preload)
   const queryString = params.toString()
-  return `https://${domain}/${props.videoId}/iframe${queryString ? '?' + queryString : ''}`
+  return `https://${domain}/${props.videoId}/iframe${queryString ? `?${queryString}` : ''}`
 })
 
 // 初始化 HLS.js
@@ -226,12 +226,15 @@ defineExpose({
   fullscreen
 })
 
-watch(() => props.src, (newSrc) => {
-  if (props.playerType === 'hls' && newSrc) {
-    destroyHls()
-    initHls()
+watch(
+  () => props.src,
+  (newSrc) => {
+    if (props.playerType === 'hls' && newSrc) {
+      destroyHls()
+      initHls()
+    }
   }
-})
+)
 
 onMounted(() => {
   if (props.playerType === 'hls') {

+ 25 - 33
src/layout/index.vue

@@ -1,9 +1,6 @@
 <template>
   <el-container class="app-wrapper">
-    <el-aside
-      :width="sidebarOpened ? '210px' : '64px'"
-      class="sidebar-container"
-    >
+    <el-aside :width="sidebarOpened ? '210px' : '64px'" class="sidebar-container">
       <div class="logo">
         <img src="@/assets/logo.svg" alt="logo" />
         <h1 v-show="sidebarOpened">摄像头管理</h1>
@@ -68,10 +65,7 @@
         <div class="header-right">
           <el-dropdown @command="handleCommand">
             <span class="user-info">
-              <el-avatar
-                :size="32"
-                :src="'https://cube.elemecdn.com/0/88/03b0d39583f4b3a790e9d12d41f23e23jps.jpg'"
-              />
+              <el-avatar :size="32" :src="'https://cube.elemecdn.com/0/88/03b0d39583f4b3a790e9d12d41f23e23jps.jpg'" />
               <span class="username">{{ userInfo?.username }}</span>
               <el-icon><ArrowDown /></el-icon>
             </span>
@@ -96,8 +90,8 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted } from "vue";
-import { useRoute, useRouter } from "vue-router";
+import { computed, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
 import {
   VideoCamera,
   Fold,
@@ -107,44 +101,42 @@ import {
   Film,
   VideoCameraFilled,
   Setting,
-  UserFilled,
-} from "@element-plus/icons-vue";
-import { useAppStore } from "@/store/app";
-import { useUserStore } from "@/store/user";
+  UserFilled
+} from '@element-plus/icons-vue'
+import { useAppStore } from '@/store/app'
+import { useUserStore } from '@/store/user'
 
-const route = useRoute();
-const router = useRouter();
-const appStore = useAppStore();
-const userStore = useUserStore();
+const route = useRoute()
+const router = useRouter()
+const appStore = useAppStore()
+const userStore = useUserStore()
 
-const sidebarOpened = computed(() => appStore.sidebarOpened);
-const userInfo = computed(() => userStore.userInfo);
+const sidebarOpened = computed(() => appStore.sidebarOpened)
+const userInfo = computed(() => userStore.userInfo)
 
 const activeMenu = computed(() => {
-  const { path } = route;
-  return path;
-});
+  const { path } = route
+  return path
+})
 
 const breadcrumbs = computed(() => {
-  return route.matched.filter(
-    (item) => item.meta && item.meta.title && !item.meta.hidden
-  );
-});
+  return route.matched.filter((item) => item.meta && item.meta.title && !item.meta.hidden)
+})
 
 function toggleSidebar() {
-  appStore.toggleSidebar();
+  appStore.toggleSidebar()
 }
 
 async function handleCommand(command: string) {
-  if (command === "logout") {
-    await userStore.logoutAction();
-    router.push("/login");
+  if (command === 'logout') {
+    await userStore.logoutAction()
+    router.push('/login')
   }
 }
 
 onMounted(() => {
-  userStore.getUserInfo();
-});
+  userStore.getUserInfo()
+})
 </script>
 
 <style lang="scss" scoped>

+ 3 - 5
src/router/index.ts

@@ -108,12 +108,10 @@ router.beforeEach((to, _from, next) => {
     } else {
       next()
     }
+  } else if (whiteList.includes(to.path)) {
+    next()
   } else {
-    if (whiteList.includes(to.path)) {
-      next()
-    } else {
-      next(`/login?redirect=${to.fullPath}`)
-    }
+    next(`/login?redirect=${to.fullPath}`)
   }
 })
 

+ 23 - 17
src/store/stream.ts

@@ -73,11 +73,14 @@ export const useStreamStore = defineStore('stream', () => {
   }
 
   // 生成缩略图 URL
-  function getThumbnailUrl(videoId: string, options?: {
-    time?: string
-    height?: number
-    width?: number
-  }): string {
+  function getThumbnailUrl(
+    videoId: string,
+    options?: {
+      time?: string
+      height?: number
+      width?: number
+    }
+  ): string {
     if (!customerDomain.value) return ''
 
     const params = new URLSearchParams()
@@ -86,20 +89,23 @@ export const useStreamStore = defineStore('stream', () => {
     if (options?.width) params.set('width', options.width.toString())
     const queryString = params.toString()
 
-    return `https://${customerDomain.value}/${videoId}/thumbnails/thumbnail.jpg${queryString ? '?' + queryString : ''}`
+    return `https://${customerDomain.value}/${videoId}/thumbnails/thumbnail.jpg${queryString ? `?${queryString}` : ''}`
   }
 
   // 生成 iframe URL
-  function getIframeUrl(videoId: string, options?: {
-    autoplay?: boolean
-    muted?: boolean
-    loop?: boolean
-    preload?: string
-    primaryColor?: string
-    letterboxColor?: string
-    startTime?: string
-    defaultTextTrack?: string
-  }): string {
+  function getIframeUrl(
+    videoId: string,
+    options?: {
+      autoplay?: boolean
+      muted?: boolean
+      loop?: boolean
+      preload?: string
+      primaryColor?: string
+      letterboxColor?: string
+      startTime?: string
+      defaultTextTrack?: string
+    }
+  ): string {
     if (!customerDomain.value) return ''
 
     const params = new URLSearchParams()
@@ -113,7 +119,7 @@ export const useStreamStore = defineStore('stream', () => {
     if (options?.defaultTextTrack) params.set('defaultTextTrack', options.defaultTextTrack)
     const queryString = params.toString()
 
-    return `https://${customerDomain.value}/${videoId}/iframe${queryString ? '?' + queryString : ''}`
+    return `https://${customerDomain.value}/${videoId}/iframe${queryString ? `?${queryString}` : ''}`
   }
 
   // 生成 HLS URL

+ 2 - 9
src/store/user.ts

@@ -1,15 +1,8 @@
 import { defineStore } from 'pinia'
 import { ref } from 'vue'
-import type { AdminInfo } from '@/types'
-import {
-  getToken,
-  setToken,
-  removeToken,
-  setRefreshToken,
-  removeRefreshToken
-} from '@/utils/auth'
+import type { AdminInfo, LoginParams } from '@/types'
+import { getToken, setToken, removeToken, setRefreshToken, removeRefreshToken } from '@/utils/auth'
 import { login, logout, getInfo } from '@/api/login'
-import type { LoginParams } from '@/types'
 
 export const useUserStore = defineStore('user', () => {
   const token = ref<string>(getToken() || '')

+ 4 - 4
src/types/cloudflare.ts

@@ -153,10 +153,10 @@ export interface CloudflareListResponse<T> {
 
 // 签名 URL 参数
 export interface SignedUrlParams {
-  id: string                    // 视频 ID
-  exp?: number                  // 过期时间戳
-  nbf?: number                  // 生效时间戳
-  downloadable?: boolean        // 是否可下载
+  id: string // 视频 ID
+  exp?: number // 过期时间戳
+  nbf?: number // 生效时间戳
+  downloadable?: boolean // 是否可下载
   accessRules?: Array<{
     type: 'ip.geoip.country' | 'ip.src' | 'any'
     country?: string[]

+ 3 - 3
src/utils/request.ts

@@ -17,7 +17,7 @@ service.interceptors.request.use(
   (config) => {
     const token = getToken()
     if (token) {
-      config.headers['Authorization'] = 'Bearer ' + token
+      config.headers.Authorization = `Bearer ${token}`
     }
     return config
   },
@@ -64,13 +64,13 @@ service.interceptors.response.use(
     return res as any
   },
   (error) => {
-    let message = error.message
+    let { message } = error
     if (message === 'Network Error') {
       message = '网络连接异常'
     } else if (message.includes('timeout')) {
       message = '请求超时'
     } else if (message.includes('Request failed with status code')) {
-      message = '接口' + message.substr(message.length - 3) + '异常'
+      message = `接口${message.substr(message.length - 3)}异常`
     }
     ElMessage.error(message)
     return Promise.reject(error)

+ 12 - 10
src/views/audit/index.vue

@@ -107,9 +107,7 @@
         </el-table-column>
         <el-table-column label="操作" width="80" align="center" fixed="right">
           <template #default="{ row }">
-            <el-button type="primary" link size="small" @click="showDetail(row)">
-              详情
-            </el-button>
+            <el-button type="primary" link size="small" @click="showDetail(row)">详情</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -132,7 +130,9 @@
       <el-descriptions v-if="currentLog" :column="1" border>
         <el-descriptions-item label="日志 ID">{{ currentLog.id }}</el-descriptions-item>
         <el-descriptions-item label="操作时间">{{ formatTime(currentLog.created_at) }}</el-descriptions-item>
-        <el-descriptions-item label="操作用户">{{ currentLog.username || currentLog.user_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="操作用户">
+          {{ currentLog.username || currentLog.user_id || '-' }}
+        </el-descriptions-item>
         <el-descriptions-item label="操作类型">
           <el-tag :type="getActionTagType(currentLog.action)">{{ getActionLabel(currentLog.action) }}</el-tag>
         </el-descriptions-item>
@@ -143,7 +143,9 @@
           <span class="user-agent-text">{{ currentLog.user_agent || '-' }}</span>
         </el-descriptions-item>
         <el-descriptions-item label="详细信息">
-          <pre v-if="currentLog.parsedDetails" class="details-json">{{ JSON.stringify(currentLog.parsedDetails, null, 2) }}</pre>
+          <pre v-if="currentLog.parsedDetails" class="details-json">{{
+            JSON.stringify(currentLog.parsedDetails, null, 2)
+          }}</pre>
           <span v-else>-</span>
         </el-descriptions-item>
       </el-descriptions>
@@ -179,7 +181,7 @@ const queryParams = reactive<AuditLogQueryParams & { page: number; pageSize: num
 
 // 简单的操作计数 (基于当前页数据)
 function getActionCount(action: string): number {
-  return auditList.value.filter(log => log.action === action).length
+  return auditList.value.filter((log) => log.action === action).length
 }
 
 function getActionLabel(action: string): string {
@@ -189,7 +191,7 @@ function getActionLabel(action: string): string {
     delete: '删除',
     login: '登录',
     logout: '登出',
-    view: '查看',
+    view: '查看'
   }
   return map[action] || action
 }
@@ -201,7 +203,7 @@ function getActionTagType(action: string): '' | 'success' | 'warning' | 'danger'
     delete: 'danger',
     login: '',
     logout: 'info',
-    view: 'info',
+    view: 'info'
   }
   return map[action] || 'info'
 }
@@ -213,7 +215,7 @@ function getResourceLabel(resource: string): string {
     video: '视频',
     session: '直播',
     live_input: '直播源',
-    auth: '认证',
+    auth: '认证'
   }
   return map[resource] || resource
 }
@@ -230,7 +232,7 @@ function formatDetails(details: Record<string, any>): string {
   if (details.title) parts.push(`标题: ${details.title}`)
   if (details.name) parts.push(`名称: ${details.name}`)
   if (details.username) parts.push(`用户: ${details.username}`)
-  return parts.length > 0 ? parts.join(', ') : JSON.stringify(details).substring(0, 50) + '...'
+  return parts.length > 0 ? parts.join(', ') : `${JSON.stringify(details).substring(0, 50)}...`
 }
 
 function showDetail(row: AuditLog) {

+ 4 - 25
src/views/camera/channel.vue

@@ -23,24 +23,9 @@
     <!-- 数据表格 -->
     <el-table v-loading="loading" :data="filteredChannels" border>
       <el-table-column type="index" label="序号" width="60" align="center" />
-      <el-table-column
-        prop="channelId"
-        label="通道ID"
-        min-width="120"
-        show-overflow-tooltip
-      />
-      <el-table-column
-        prop="name"
-        label="通道名称"
-        min-width="120"
-        show-overflow-tooltip
-      />
-      <el-table-column
-        prop="rtspUrl"
-        label="RTSP地址"
-        min-width="250"
-        show-overflow-tooltip
-      />
+      <el-table-column prop="channelId" label="通道ID" min-width="120" show-overflow-tooltip />
+      <el-table-column prop="name" label="通道名称" min-width="120" show-overflow-tooltip />
+      <el-table-column prop="rtspUrl" label="RTSP地址" min-width="250" show-overflow-tooltip />
       <el-table-column prop="defaultView" label="默认视角" width="100" align="center">
         <template #default="{ row }">
           <el-tag :type="row.defaultView ? 'success' : 'info'">
@@ -57,13 +42,7 @@
       </el-table-column>
       <el-table-column label="操作" width="150" align="center" fixed="right">
         <template #default="{ row }">
-          <el-button
-            type="primary"
-            link
-            :icon="VideoPlay"
-            @click="handlePlay(row)"
-            :disabled="row.status !== 'ONLINE'"
-          >
+          <el-button type="primary" link :icon="VideoPlay" @click="handlePlay(row)" :disabled="row.status !== 'ONLINE'">
             播放
           </el-button>
         </template>

+ 27 - 138
src/views/camera/index.vue

@@ -4,12 +4,7 @@
     <div class="search-form">
       <el-form :model="queryParams" inline>
         <el-form-item label="机器">
-          <el-select
-            v-model="queryParams.machineId"
-            placeholder="请选择机器"
-            clearable
-            @change="handleQuery"
-          >
+          <el-select v-model="queryParams.machineId" placeholder="请选择机器" clearable @change="handleQuery">
             <el-option
               v-for="machine in machineList"
               :key="machine.machineId"
@@ -19,20 +14,13 @@
           </el-select>
         </el-form-item>
         <el-form-item label="状态">
-          <el-select
-            v-model="queryParams.status"
-            placeholder="请选择"
-            clearable
-            @change="handleQuery"
-          >
+          <el-select v-model="queryParams.status" placeholder="请选择" clearable @change="handleQuery">
             <el-option label="在线" value="ONLINE" />
             <el-option label="离线" value="OFFLINE" />
           </el-select>
         </el-form-item>
         <el-form-item>
-          <el-button type="primary" :icon="Search" @click="handleQuery"
-            >搜索</el-button
-          >
+          <el-button type="primary" :icon="Search" @click="handleQuery">搜索</el-button>
           <el-button :icon="Refresh" @click="resetQuery">重置</el-button>
         </el-form-item>
       </el-form>
@@ -40,48 +28,19 @@
 
     <!-- 操作按钮 -->
     <div class="table-actions">
-      <el-button type="primary" :icon="Plus" @click="handleAdd"
-        >新增摄像头</el-button
-      >
-      <el-button type="success" :icon="Refresh" @click="getList"
-        >刷新列表</el-button
-      >
+      <el-button type="primary" :icon="Plus" @click="handleAdd">新增摄像头</el-button>
+      <el-button type="success" :icon="Refresh" @click="getList">刷新列表</el-button>
     </div>
 
     <!-- 数据表格 -->
     <el-table v-loading="loading" :data="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="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="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'">
@@ -105,48 +64,19 @@
       </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
-          >
+          <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>
 
     <!-- 新增/编辑弹窗 -->
-    <el-dialog
-      v-model="dialogVisible"
-      :title="dialogTitle"
-      width="650px"
-      destroy-on-close
-    >
+    <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-input v-model="form.cameraId" placeholder="请输入摄像头ID" :disabled="isEdit" />
         </el-form-item>
         <el-form-item label="名称" prop="name">
           <el-input v-model="form.name" placeholder="请输入名称" />
@@ -171,12 +101,7 @@
           </el-col>
           <el-col :span="12">
             <el-form-item label="密码" prop="password">
-              <el-input
-                v-model="form.password"
-                type="password"
-                placeholder="请输入密码"
-                show-password
-              />
+              <el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
             </el-form-item>
           </el-col>
         </el-row>
@@ -215,19 +140,12 @@
       </el-form>
       <template #footer>
         <el-button @click="dialogVisible = false">取消</el-button>
-        <el-button type="primary" :loading="submitLoading" @click="handleSubmit"
-          >确定</el-button
-        >
+        <el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
       </template>
     </el-dialog>
 
     <!-- 通道弹窗 -->
-    <el-dialog
-      v-model="channelDialogVisible"
-      title="通道列表"
-      width="700px"
-      destroy-on-close
-    >
+    <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" />
@@ -253,36 +171,11 @@
 
 <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 {
-  adminListCameras,
-  adminAddCamera,
-  adminUpdateCamera,
-  adminDeleteCamera,
-  adminCheckCamera
-} from '@/api/camera'
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
+import { Search, Refresh, Plus, View, Edit, Delete, Connection } from '@element-plus/icons-vue'
+import { adminListCameras, adminAddCamera, adminUpdateCamera, adminDeleteCamera, adminCheckCamera } from '@/api/camera'
 import { listMachines } from '@/api/machine'
-import type {
-  CameraInfoDTO,
-  ChannelInfoDTO,
-  CameraAddRequest,
-  CameraUpdateRequest,
-  MachineDTO
-} from '@/types'
+import type { CameraInfoDTO, ChannelInfoDTO, CameraAddRequest, CameraUpdateRequest, MachineDTO } from '@/types'
 
 const loading = ref(false)
 const submitLoading = ref(false)
@@ -329,7 +222,7 @@ const dialogTitle = computed(() => (isEdit.value ? '编辑摄像头' : '新增
 const filteredList = computed(() => {
   let list = cameraList.value
   if (queryParams.status) {
-    list = list.filter(item => item.status === queryParams.status)
+    list = list.filter((item) => item.status === queryParams.status)
   }
   return list
 })
@@ -436,13 +329,9 @@ async function handleCheck(row: CameraInfoDTO) {
 
 async function handleDelete(row: CameraInfoDTO) {
   try {
-    await ElMessageBox.confirm(
-      `确定要删除摄像头 "${row.name}" 吗?`,
-      '提示',
-      {
-        type: 'warning'
-      }
-    )
+    await ElMessageBox.confirm(`确定要删除摄像头 "${row.name}" 吗?`, '提示', {
+      type: 'warning'
+    })
     const res = await adminDeleteCamera(row.id)
     if (res.code === 200) {
       ElMessage.success('删除成功')

+ 13 - 13
src/views/camera/stream-test.vue

@@ -17,11 +17,7 @@
         </el-form-item>
 
         <el-form-item label="Video ID">
-          <el-input
-            v-model="config.videoId"
-            placeholder="Cloudflare Stream Video ID"
-            style="width: 300px"
-          />
+          <el-input v-model="config.videoId" placeholder="Cloudflare Stream Video ID" style="width: 300px" />
         </el-form-item>
 
         <el-form-item label="自定义域名">
@@ -41,11 +37,7 @@
 
       <el-form label-width="140px" inline>
         <el-form-item label="或输入 HLS 地址">
-          <el-input
-            v-model="config.hlsUrl"
-            placeholder="https://xxx/manifest/video.m3u8"
-            style="width: 500px"
-          />
+          <el-input v-model="config.hlsUrl" placeholder="https://xxx/manifest/video.m3u8" style="width: 500px" />
         </el-form-item>
         <el-form-item>
           <el-button type="success" @click="loadHlsUrl">播放 HLS</el-button>
@@ -93,11 +85,19 @@
         <template #default>
           <ol>
             <li>在 Cloudflare Dashboard 中上传视频或创建直播</li>
-            <li>获取 Video ID(格式如:<code>ea95132c15732412d22c1476fa83f27a</code>)</li>
-            <li>获取自定义域名(格式如:<code>customer-xxx.cloudflarestream.com</code>)</li>
+            <li>
+              获取 Video ID(格式如:
+              <code>ea95132c15732412d22c1476fa83f27a</code>
+              )
+            </li>
+            <li>
+              获取自定义域名(格式如:
+              <code>customer-xxx.cloudflarestream.com</code>
+              )
+            </li>
             <li>填入上方表单并点击加载视频</li>
           </ol>
-          <p style="margin-top: 10px;">
+          <p style="margin-top: 10px">
             <strong>HLS 地址格式:</strong>
             <code>https://customer-xxx.cloudflarestream.com/{video_id}/manifest/video.m3u8</code>
           </p>

+ 7 - 21
src/views/dashboard/index.vue

@@ -17,7 +17,9 @@
           </div>
           <div class="stat-footer">
             <span class="online">已启用: {{ dashboardData?.machineEnabled || 0 }}</span>
-            <span class="offline">已禁用: {{ (dashboardData?.machineTotal || 0) - (dashboardData?.machineEnabled || 0) }}</span>
+            <span class="offline">
+              已禁用: {{ (dashboardData?.machineTotal || 0) - (dashboardData?.machineEnabled || 0) }}
+            </span>
           </div>
         </el-card>
       </el-col>
@@ -68,9 +70,7 @@
               <el-icon :size="40"><CircleCheck /></el-icon>
             </div>
             <div class="stat-info">
-              <div class="stat-value online-rate">
-                {{ onlineRate }}%
-              </div>
+              <div class="stat-value online-rate">{{ onlineRate }}%</div>
               <div class="stat-label">摄像头在线率</div>
             </div>
           </div>
@@ -88,12 +88,7 @@
           <template #header>
             <div class="card-header">
               <span>快捷操作</span>
-              <el-button
-                type="primary"
-                link
-                :icon="Refresh"
-                @click="loadDashboardData"
-              >刷新数据</el-button>
+              <el-button type="primary" link :icon="Refresh" @click="loadDashboardData">刷新数据</el-button>
             </div>
           </template>
           <el-row :gutter="20">
@@ -136,9 +131,7 @@
             <el-descriptions-item label="数据更新时间">
               {{ lastUpdateTime }}
             </el-descriptions-item>
-            <el-descriptions-item label="版本">
-              v1.0.0
-            </el-descriptions-item>
+            <el-descriptions-item label="版本">v1.0.0</el-descriptions-item>
           </el-descriptions>
         </el-card>
       </el-col>
@@ -150,14 +143,7 @@
 import { ref, onMounted, computed } from 'vue'
 import { useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
-import {
-  Monitor,
-  VideoCamera,
-  Connection,
-  CircleCheck,
-  Refresh,
-  VideoPlay
-} from '@element-plus/icons-vue'
+import { Monitor, VideoCamera, Connection, CircleCheck, Refresh, VideoPlay } from '@element-plus/icons-vue'
 import { getDashboardStats } from '@/api/stats'
 import type { DashboardStatsDTO } from '@/types'
 

+ 21 - 14
src/views/login/index.vue

@@ -48,13 +48,7 @@
           <span class="login__forgot" @click="goHelp">忘记密码?</span>
         </div>
 
-        <el-button
-          type="primary"
-          size="large"
-          :loading="loading"
-          class="login__submit"
-          @click="handleLogin"
-        >
+        <el-button type="primary" size="large" :loading="loading" class="login__submit" @click="handleLogin">
           登 录
         </el-button>
       </el-form>
@@ -185,7 +179,8 @@ function goHelp() {
   display: grid;
   grid-template-columns: 1fr;
   place-items: center;
-  background: radial-gradient(1200px 600px at 100% 0%, rgba(102, 126, 234, 0.25), transparent 60%),
+  background:
+    radial-gradient(1200px 600px at 100% 0%, rgba(102, 126, 234, 0.25), transparent 60%),
     radial-gradient(1000px 500px at 0% 100%, rgba(118, 75, 162, 0.25), transparent 60%),
     linear-gradient(135deg, #0f172a 0%, #111827 60%, #0b1220 100%);
   padding: 24px;
@@ -195,7 +190,8 @@ function goHelp() {
 .login__bg {
   position: absolute;
   inset: -10% -10% -10% -10%;
-  background: radial-gradient(800px 400px at 50% -10%, rgba(59, 130, 246, 0.15), transparent 60%),
+  background:
+    radial-gradient(800px 400px at 50% -10%, rgba(59, 130, 246, 0.15), transparent 60%),
     radial-gradient(700px 350px at 100% 100%, rgba(236, 72, 153, 0.12), transparent 60%);
   filter: blur(40px);
   pointer-events: none;
@@ -223,7 +219,10 @@ function goHelp() {
   width: 36px;
   height: 36px;
 }
-.login__titles { display: grid; gap: 2px; }
+.login__titles {
+  display: grid;
+  gap: 2px;
+}
 .login__title {
   margin: 0;
   font-size: 22px;
@@ -238,15 +237,21 @@ function goHelp() {
 
 .login__form {
   margin-top: 8px;
-  .el-input__wrapper { background: rgba(255,255,255,0.92); }
-  .el-input { height: 44px; }
+  .el-input__wrapper {
+    background: rgba(255, 255, 255, 0.92);
+  }
+  .el-input {
+    height: 44px;
+  }
 }
 
 .login__captcha {
   display: flex;
   width: 100%;
   gap: 10px;
-  .el-input { flex: 1; }
+  .el-input {
+    flex: 1;
+  }
 }
 .login__captcha-img {
   width: 108px;
@@ -284,6 +289,8 @@ function goHelp() {
 }
 
 @media (max-width: 480px) {
-  .login__card { padding: 28px 20px 22px; }
+  .login__card {
+    padding: 28px 20px 22px;
+  }
 }
 </style>

+ 21 - 25
src/views/stats/index.vue

@@ -17,17 +17,19 @@
       <el-col :xs="12" :sm="6">
         <el-card shadow="hover" class="summary-card machines">
           <el-statistic title="机器总数" :value="statsData?.machineTotal || 0">
-            <template #prefix><el-icon><Monitor /></el-icon></template>
+            <template #prefix>
+              <el-icon><Monitor /></el-icon>
+            </template>
           </el-statistic>
-          <div class="stat-detail">
-            已启用: {{ statsData?.machineEnabled || 0 }}
-          </div>
+          <div class="stat-detail">已启用: {{ statsData?.machineEnabled || 0 }}</div>
         </el-card>
       </el-col>
       <el-col :xs="12" :sm="6">
         <el-card shadow="hover" class="summary-card cameras">
           <el-statistic title="摄像头总数" :value="statsData?.cameraTotal || 0">
-            <template #prefix><el-icon><VideoCamera /></el-icon></template>
+            <template #prefix>
+              <el-icon><VideoCamera /></el-icon>
+            </template>
           </el-statistic>
           <div class="stat-detail">
             <span class="online">在线: {{ statsData?.cameraOnline || 0 }}</span>
@@ -38,21 +40,21 @@
       <el-col :xs="12" :sm="6">
         <el-card shadow="hover" class="summary-card channels">
           <el-statistic title="通道总数" :value="statsData?.channelTotal || 0">
-            <template #prefix><el-icon><Connection /></el-icon></template>
+            <template #prefix>
+              <el-icon><Connection /></el-icon>
+            </template>
           </el-statistic>
-          <div class="stat-detail">
-            可用通道数量
-          </div>
+          <div class="stat-detail">可用通道数量</div>
         </el-card>
       </el-col>
       <el-col :xs="12" :sm="6">
         <el-card shadow="hover" class="summary-card rate">
           <el-statistic title="摄像头在线率" :value="onlineRate" suffix="%">
-            <template #prefix><el-icon><CircleCheck /></el-icon></template>
+            <template #prefix>
+              <el-icon><CircleCheck /></el-icon>
+            </template>
           </el-statistic>
-          <div class="stat-detail">
-            系统运行状态
-          </div>
+          <div class="stat-detail">系统运行状态</div>
         </el-card>
       </el-col>
     </el-row>
@@ -118,7 +120,9 @@
               <div class="status-bar" :style="{ width: getMachineStatusWidth('disabled') + '%' }"></div>
               <div class="status-info">
                 <span class="status-label">已禁用</span>
-                <span class="status-value">{{ (statsData?.machineTotal || 0) - (statsData?.machineEnabled || 0) }}</span>
+                <span class="status-value">
+                  {{ (statsData?.machineTotal || 0) - (statsData?.machineEnabled || 0) }}
+                </span>
               </div>
             </div>
           </div>
@@ -131,14 +135,7 @@
 <script setup lang="ts">
 import { ref, computed, onMounted } from 'vue'
 import { ElMessage } from 'element-plus'
-import {
-  Refresh,
-  Monitor,
-  VideoCamera,
-  Connection,
-  CircleCheck,
-  TrendCharts
-} from '@element-plus/icons-vue'
+import { Refresh, Monitor, VideoCamera, Connection, CircleCheck, TrendCharts } from '@element-plus/icons-vue'
 import { getDashboardStats } from '@/api/stats'
 import type { DashboardStatsDTO } from '@/types'
 
@@ -164,9 +161,8 @@ function getStatusWidth(type: 'online' | 'offline'): number {
 
 function getMachineStatusWidth(type: 'enabled' | 'disabled'): number {
   if (!statsData.value || !statsData.value.machineTotal) return 0
-  const value = type === 'enabled'
-    ? statsData.value.machineEnabled
-    : (statsData.value.machineTotal - statsData.value.machineEnabled)
+  const value =
+    type === 'enabled' ? statsData.value.machineEnabled : statsData.value.machineTotal - statsData.value.machineEnabled
   return Math.round((value / statsData.value.machineTotal) * 100)
 }
 

+ 31 - 15
src/views/stream/config.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="page-container">
     <el-card header="Cloudflare Stream 配置">
-      <el-form :model="config" label-width="160px" style="max-width: 600px;">
+      <el-form :model="config" label-width="160px" style="max-width: 600px">
         <el-form-item label="Account ID" required>
           <el-input v-model="config.accountId" placeholder="Cloudflare Account ID" />
           <div class="form-tip">在 Cloudflare Dashboard 右侧可以找到</div>
@@ -23,7 +23,8 @@
             show-password
           />
           <div class="form-tip">
-            仅在前端直接调用 API 时需要(不推荐)<br />
+            仅在前端直接调用 API 时需要(不推荐)
+            <br />
             推荐通过后端代理调用,避免暴露 Token
           </div>
         </el-form-item>
@@ -35,11 +36,14 @@
       </el-form>
     </el-card>
 
-    <el-card header="配置说明" style="margin-top: 20px;">
+    <el-card header="配置说明" style="margin-top: 20px">
       <el-collapse>
         <el-collapse-item title="如何获取 Account ID" name="1">
           <ol>
-            <li>登录 <a href="https://dash.cloudflare.com" target="_blank">Cloudflare Dashboard</a></li>
+            <li>
+              登录
+              <a href="https://dash.cloudflare.com" target="_blank">Cloudflare Dashboard</a>
+            </li>
             <li>选择 Stream 产品</li>
             <li>在页面右侧可以看到 Account ID</li>
           </ol>
@@ -49,25 +53,36 @@
           <ol>
             <li>进入 Stream 产品页面</li>
             <li>上传或选择任意视频</li>
-            <li>查看播放地址,格式为 <code>https://customer-xxx.cloudflarestream.com/{video_id}/...</code></li>
-            <li><code>xxx</code> 部分就是 Customer Subdomain</li>
+            <li>
+              查看播放地址,格式为
+              <code>https://customer-xxx.cloudflarestream.com/{video_id}/...</code>
+            </li>
+            <li>
+              <code>xxx</code>
+              部分就是 Customer Subdomain
+            </li>
           </ol>
         </el-collapse-item>
 
         <el-collapse-item title="如何创建 API Token" name="3">
           <ol>
-            <li>访问 <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank">API Tokens 页面</a></li>
+            <li>
+              访问
+              <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank">API Tokens 页面</a>
+            </li>
             <li>点击 "Create Token"</li>
             <li>选择 "Stream - Edit" 模板或自定义权限</li>
             <li>创建后复制 Token</li>
           </ol>
-          <el-alert type="warning" :closable="false" style="margin-top: 10px;">
-            <strong>安全提示:</strong>不要在前端代码中暴露 API Token。推荐在后端服务器中存储 Token,前端通过后端代理调用 API。
+          <el-alert type="warning" :closable="false" style="margin-top: 10px">
+            <strong>安全提示:</strong>
+            不要在前端代码中暴露 API Token。推荐在后端服务器中存储 Token,前端通过后端代理调用 API。
           </el-alert>
         </el-collapse-item>
 
         <el-collapse-item title="后端 API 代理示例" name="4">
-          <pre class="code-block">// Node.js / Express 示例
+          <pre class="code-block">
+// Node.js / Express 示例
 const express = require('express');
 const axios = require('axios');
 
@@ -101,16 +116,17 @@ app.post('/api/stream/live', async (req, res) =&gt; {
     code: 200,
     data: response.data.result
   });
-});</pre>
+});</pre
+          >
         </el-collapse-item>
       </el-collapse>
     </el-card>
 
-    <el-card header="快速测试" style="margin-top: 20px;">
-      <el-form label-width="100px" style="max-width: 800px;">
+    <el-card header="快速测试" style="margin-top: 20px">
+      <el-form label-width="100px" style="max-width: 800px">
         <el-form-item label="Video ID">
-          <el-input v-model="testVideoId" placeholder="输入 Video ID 测试播放" style="width: 400px;" />
-          <el-button type="primary" style="margin-left: 10px;" @click="testPlay" :disabled="!testVideoId">
+          <el-input v-model="testVideoId" placeholder="输入 Video ID 测试播放" style="width: 400px" />
+          <el-button type="primary" style="margin-left: 10px" @click="testPlay" :disabled="!testVideoId">
             测试播放
           </el-button>
         </el-form-item>

+ 52 - 30
src/views/stream/live-list.vue

@@ -38,18 +38,10 @@
       </el-table-column>
       <el-table-column label="操作" width="320" align="center" fixed="right">
         <template #default="{ row }">
-          <el-button type="primary" link :icon="VideoPlay" @click="handleWatch(row)">
-            观看
-          </el-button>
-          <el-button type="success" link :icon="Connection" @click="showStreamInfo(row)">
-            推流信息
-          </el-button>
-          <el-button type="info" link :icon="List" @click="showRecordings(row)">
-            录像
-          </el-button>
-          <el-button type="danger" link :icon="Delete" @click="handleDelete(row)">
-            删除
-          </el-button>
+          <el-button type="primary" link :icon="VideoPlay" @click="handleWatch(row)">观看</el-button>
+          <el-button type="success" link :icon="Connection" @click="showStreamInfo(row)">推流信息</el-button>
+          <el-button type="info" link :icon="List" @click="showRecordings(row)">录像</el-button>
+          <el-button type="danger" link :icon="Delete" @click="handleDelete(row)">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -83,14 +75,26 @@
             <el-descriptions-item label="推流地址">
               <div class="url-item">
                 <span class="url-text">{{ currentLiveInput.rtmps?.url }}</span>
-                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentLiveInput.rtmps?.url)" />
+                <el-button
+                  link
+                  type="primary"
+                  :icon="CopyDocument"
+                  @click="copyToClipboard(currentLiveInput.rtmps?.url)"
+                />
               </div>
             </el-descriptions-item>
             <el-descriptions-item label="推流密钥">
               <div class="url-item">
-                <span class="url-text">{{ showStreamKey ? currentLiveInput.rtmps?.streamKey : '••••••••••••••••' }}</span>
+                <span class="url-text">
+                  {{ showStreamKey ? currentLiveInput.rtmps?.streamKey : '••••••••••••••••' }}
+                </span>
                 <el-button link type="primary" :icon="View" @click="showStreamKey = !showStreamKey" />
-                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentLiveInput.rtmps?.streamKey)" />
+                <el-button
+                  link
+                  type="primary"
+                  :icon="CopyDocument"
+                  @click="copyToClipboard(currentLiveInput.rtmps?.streamKey)"
+                />
               </div>
             </el-descriptions-item>
           </el-descriptions>
@@ -106,20 +110,35 @@
             <el-descriptions-item label="SRT 地址">
               <div class="url-item">
                 <span class="url-text">{{ currentLiveInput.srt?.url }}</span>
-                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentLiveInput.srt?.url)" />
+                <el-button
+                  link
+                  type="primary"
+                  :icon="CopyDocument"
+                  @click="copyToClipboard(currentLiveInput.srt?.url)"
+                />
               </div>
             </el-descriptions-item>
             <el-descriptions-item label="Stream ID">
               <div class="url-item">
                 <span class="url-text">{{ currentLiveInput.srt?.streamId }}</span>
-                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentLiveInput.srt?.streamId)" />
+                <el-button
+                  link
+                  type="primary"
+                  :icon="CopyDocument"
+                  @click="copyToClipboard(currentLiveInput.srt?.streamId)"
+                />
               </div>
             </el-descriptions-item>
             <el-descriptions-item label="Passphrase">
               <div class="url-item">
                 <span class="url-text">{{ showStreamKey ? currentLiveInput.srt?.passphrase : '••••••••••••' }}</span>
                 <el-button link type="primary" :icon="View" @click="showStreamKey = !showStreamKey" />
-                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentLiveInput.srt?.passphrase)" />
+                <el-button
+                  link
+                  type="primary"
+                  :icon="CopyDocument"
+                  @click="copyToClipboard(currentLiveInput.srt?.passphrase)"
+                />
               </div>
             </el-descriptions-item>
           </el-descriptions>
@@ -130,19 +149,27 @@
             <el-descriptions-item label="HLS 地址">
               <div class="url-item">
                 <span class="url-text">{{ getHlsUrl(currentLiveInput.uid) }}</span>
-                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(getHlsUrl(currentLiveInput.uid))" />
+                <el-button
+                  link
+                  type="primary"
+                  :icon="CopyDocument"
+                  @click="copyToClipboard(getHlsUrl(currentLiveInput.uid))"
+                />
               </div>
             </el-descriptions-item>
             <el-descriptions-item label="iframe 嵌入">
               <div class="url-item">
                 <span class="url-text">{{ getIframeUrl(currentLiveInput.uid) }}</span>
-                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(getIframeUrl(currentLiveInput.uid))" />
+                <el-button
+                  link
+                  type="primary"
+                  :icon="CopyDocument"
+                  @click="copyToClipboard(getIframeUrl(currentLiveInput.uid))"
+                />
               </div>
             </el-descriptions-item>
           </el-descriptions>
-          <el-alert type="warning" :closable="false" style="margin-top: 15px">
-            播放地址仅在直播中有效
-          </el-alert>
+          <el-alert type="warning" :closable="false" style="margin-top: 15px">播放地址仅在直播中有效</el-alert>
         </el-tab-pane>
       </el-tabs>
     </el-dialog>
@@ -163,9 +190,7 @@
         </el-table-column>
         <el-table-column label="操作" width="120" align="center">
           <template #default="{ row }">
-            <el-button type="primary" link :icon="VideoPlay" @click="playRecording(row)">
-              播放
-            </el-button>
+            <el-button type="primary" link :icon="VideoPlay" @click="playRecording(row)">播放</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -189,10 +214,7 @@
 <script setup lang="ts">
 import { ref, reactive, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import {
-  Plus, Refresh, VideoPlay, Connection, List, Delete,
-  CopyDocument, View
-} from '@element-plus/icons-vue'
+import { Plus, Refresh, VideoPlay, Connection, List, Delete, CopyDocument, View } from '@element-plus/icons-vue'
 import VideoPlayer from '@/components/VideoPlayer.vue'
 import { useStreamStore } from '@/store/stream'
 import type { CloudflareLiveInput, CloudflareVideo } from '@/types/cloudflare'

+ 25 - 22
src/views/stream/video-list.vue

@@ -36,7 +36,7 @@
             :src="getThumbnailUrl(row.uid)"
             :preview-src-list="[getThumbnailUrl(row.uid)]"
             fit="cover"
-            style="width: 140px; height: 80px; border-radius: 4px;"
+            style="width: 140px; height: 80px; border-radius: 4px"
             :preview-teleported="true"
           >
             <template #error>
@@ -89,12 +89,8 @@
           <el-button type="primary" link :icon="VideoPlay" @click="handlePlay(row)" :disabled="!row.readyToStream">
             播放
           </el-button>
-          <el-button type="info" link :icon="View" @click="handleDetail(row)">
-            详情
-          </el-button>
-          <el-button type="danger" link :icon="Delete" @click="handleDelete(row)">
-            删除
-          </el-button>
+          <el-button type="info" link :icon="View" @click="handleDetail(row)">详情</el-button>
+          <el-button type="danger" link :icon="Delete" @click="handleDelete(row)">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -113,30 +109,29 @@
 
     <!-- 上传弹窗 -->
     <el-dialog v-model="uploadDialogVisible" title="上传视频" width="500px" destroy-on-close>
-      <el-upload
-        drag
-        :auto-upload="false"
-        :limit="1"
-        accept="video/*"
-        :on-change="handleFileChange"
-      >
+      <el-upload drag :auto-upload="false" :limit="1" accept="video/*" :on-change="handleFileChange">
         <el-icon class="el-icon--upload"><UploadFilled /></el-icon>
-        <div class="el-upload__text">拖拽视频文件到此处,或 <em>点击上传</em></div>
+        <div class="el-upload__text">
+          拖拽视频文件到此处,或
+          <em>点击上传</em>
+        </div>
         <template #tip>
           <div class="el-upload__tip">支持 MP4、MOV、MKV、AVI 等常见视频格式</div>
         </template>
       </el-upload>
-      <el-form v-if="uploadFile" :model="uploadForm" label-width="80px" style="margin-top: 20px;">
+      <el-form v-if="uploadFile" :model="uploadForm" label-width="80px" style="margin-top: 20px">
         <el-form-item label="视频名称">
           <el-input v-model="uploadForm.name" placeholder="请输入视频名称" />
         </el-form-item>
       </el-form>
-      <el-progress v-if="uploadProgress > 0" :percentage="uploadProgress" :status="uploadProgress === 100 ? 'success' : undefined" />
+      <el-progress
+        v-if="uploadProgress > 0"
+        :percentage="uploadProgress"
+        :status="uploadProgress === 100 ? 'success' : undefined"
+      />
       <template #footer>
         <el-button @click="uploadDialogVisible = false">取消</el-button>
-        <el-button type="primary" :loading="uploading" :disabled="!uploadFile" @click="handleUpload">
-          上传
-        </el-button>
+        <el-button type="primary" :loading="uploading" :disabled="!uploadFile" @click="handleUpload">上传</el-button>
       </template>
     </el-dialog>
 
@@ -214,8 +209,16 @@
 import { ref, reactive, onMounted } from 'vue'
 import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
 import {
-  Search, Refresh, Upload, Link, VideoPlay, View, Delete,
-  CopyDocument, Picture, UploadFilled
+  Search,
+  Refresh,
+  Upload,
+  Link,
+  VideoPlay,
+  View,
+  Delete,
+  CopyDocument,
+  Picture,
+  UploadFilled
 } from '@element-plus/icons-vue'
 import VideoPlayer from '@/components/VideoPlayer.vue'
 import { useStreamStore } from '@/store/stream'

+ 134 - 251
src/views/user/index.vue

@@ -4,28 +4,17 @@
     <div class="search-form">
       <el-form :model="queryParams" inline>
         <el-form-item label="搜索">
-          <el-input
-            v-model="queryParams.search"
-            placeholder="请输入用户名"
-            clearable
-            @keyup.enter="handleQuery"
-          />
+          <el-input v-model="queryParams.search" placeholder="请输入用户名" clearable @keyup.enter="handleQuery" />
         </el-form-item>
         <el-form-item label="角色">
-          <el-select
-            v-model="queryParams.role"
-            placeholder="请选择角色"
-            clearable
-          >
+          <el-select v-model="queryParams.role" placeholder="请选择角色" clearable>
             <el-option label="管理员" value="admin" />
             <el-option label="操作员" value="operator" />
             <el-option label="观察者" value="viewer" />
           </el-select>
         </el-form-item>
         <el-form-item>
-          <el-button type="primary" :icon="Search" @click="handleQuery"
-            >搜索</el-button
-          >
+          <el-button type="primary" :icon="Search" @click="handleQuery">搜索</el-button>
           <el-button :icon="Refresh" @click="resetQuery">重置</el-button>
         </el-form-item>
       </el-form>
@@ -33,12 +22,8 @@
 
     <!-- 操作按钮 -->
     <div class="table-actions">
-      <el-button type="primary" :icon="Plus" @click="handleAdd"
-        >新增用户</el-button
-      >
-      <el-button type="success" :icon="Refresh" @click="getList"
-        >刷新列表</el-button
-      >
+      <el-button type="primary" :icon="Plus" @click="handleAdd">新增用户</el-button>
+      <el-button type="success" :icon="Refresh" @click="getList">刷新列表</el-button>
     </div>
 
     <!-- 数据表格 -->
@@ -47,50 +32,24 @@
       <el-table-column prop="username" label="用户名" min-width="150" />
       <el-table-column prop="role" label="角色" width="120" align="center">
         <template #default="{ row }">
-          <el-tag :type="getRoleTagType(row.role)">{{
-            getRoleLabel(row.role)
-          }}</el-tag>
+          <el-tag :type="getRoleTagType(row.role)">{{ getRoleLabel(row.role) }}</el-tag>
         </template>
       </el-table-column>
-      <el-table-column
-        prop="createdAt"
-        label="创建时间"
-        width="180"
-        align="center"
-      >
+      <el-table-column prop="createdAt" label="创建时间" width="180" align="center">
         <template #default="{ row }">
           {{ formatTime(row.createdAt) }}
         </template>
       </el-table-column>
-      <el-table-column
-        prop="updatedAt"
-        label="更新时间"
-        width="180"
-        align="center"
-      >
+      <el-table-column prop="updatedAt" label="更新时间" width="180" align="center">
         <template #default="{ row }">
           {{ formatTime(row.updatedAt) }}
         </template>
       </el-table-column>
       <el-table-column label="操作" width="220" align="center" fixed="right">
         <template #default="{ row }">
-          <el-button type="primary" link :icon="Edit" @click="handleEdit(row)"
-            >编辑</el-button
-          >
-          <el-button
-            type="warning"
-            link
-            :icon="Key"
-            @click="handleResetPassword(row)"
-            >重置密码</el-button
-          >
-          <el-button
-            type="danger"
-            link
-            :icon="Delete"
-            @click="handleDelete(row)"
-            >删除</el-button
-          >
+          <el-button type="primary" link :icon="Edit" @click="handleEdit(row)">编辑</el-button>
+          <el-button type="warning" link :icon="Key" @click="handleResetPassword(row)">重置密码</el-button>
+          <el-button type="danger" link :icon="Delete" @click="handleDelete(row)">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -108,34 +67,16 @@
     />
 
     <!-- 新增/编辑弹窗 -->
-    <el-dialog
-      v-model="dialogVisible"
-      :title="dialogTitle"
-      width="500px"
-      destroy-on-close
-    >
+    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
       <el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
         <el-form-item label="用户名" prop="username">
-          <el-input
-            v-model="form.username"
-            placeholder="请输入用户名"
-            :disabled="isEdit"
-          />
+          <el-input v-model="form.username" placeholder="请输入用户名" :disabled="isEdit" />
         </el-form-item>
         <el-form-item v-if="!isEdit" label="密码" prop="password">
-          <el-input
-            v-model="form.password"
-            type="password"
-            placeholder="请输入密码"
-            show-password
-          />
+          <el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
         </el-form-item>
         <el-form-item label="角色" prop="role">
-          <el-select
-            v-model="form.role"
-            placeholder="请选择角色"
-            style="width: 100%"
-          >
+          <el-select v-model="form.role" placeholder="请选择角色" style="width: 100%">
             <el-option label="管理员" value="admin" />
             <el-option label="操作员" value="operator" />
             <el-option label="观察者" value="viewer" />
@@ -144,303 +85,245 @@
       </el-form>
       <template #footer>
         <el-button @click="dialogVisible = false">取消</el-button>
-        <el-button type="primary" :loading="submitLoading" @click="handleSubmit"
-          >确定</el-button
-        >
+        <el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
       </template>
     </el-dialog>
 
     <!-- 重置密码弹窗 -->
-    <el-dialog
-      v-model="resetDialogVisible"
-      title="重置密码"
-      width="400px"
-      destroy-on-close
-    >
-      <el-form
-        ref="resetFormRef"
-        :model="resetForm"
-        :rules="resetRules"
-        label-width="80px"
-      >
+    <el-dialog v-model="resetDialogVisible" title="重置密码" width="400px" destroy-on-close>
+      <el-form ref="resetFormRef" :model="resetForm" :rules="resetRules" label-width="80px">
         <el-form-item label="新密码" prop="password">
-          <el-input
-            v-model="resetForm.password"
-            type="password"
-            placeholder="请输入新密码"
-            show-password
-          />
+          <el-input v-model="resetForm.password" type="password" placeholder="请输入新密码" show-password />
         </el-form-item>
         <el-form-item label="确认密码" prop="confirmPassword">
-          <el-input
-            v-model="resetForm.confirmPassword"
-            type="password"
-            placeholder="请再次输入密码"
-            show-password
-          />
+          <el-input v-model="resetForm.confirmPassword" type="password" placeholder="请再次输入密码" show-password />
         </el-form-item>
       </el-form>
       <template #footer>
         <el-button @click="resetDialogVisible = false">取消</el-button>
-        <el-button
-          type="primary"
-          :loading="resetLoading"
-          @click="handleResetSubmit"
-          >确定</el-button
-        >
+        <el-button type="primary" :loading="resetLoading" @click="handleResetSubmit">确定</el-button>
       </template>
     </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, computed, onMounted } from "vue";
-import {
-  ElMessage,
-  ElMessageBox,
-  type FormInstance,
-  type FormRules,
-} from "element-plus";
-import {
-  Search,
-  Refresh,
-  Plus,
-  Edit,
-  Delete,
-  Key,
-} from "@element-plus/icons-vue";
-import {
-  listUsers,
-  createUser,
-  updateUser,
-  deleteUser,
-  resetPassword,
-  type User,
-  type UserForm,
-} from "@/api/user";
-
-const loading = ref(false);
-const submitLoading = ref(false);
-const resetLoading = ref(false);
-const userList = ref<User[]>([]);
-const total = ref(0);
-const dialogVisible = ref(false);
-const resetDialogVisible = ref(false);
-const formRef = ref<FormInstance>();
-const resetFormRef = ref<FormInstance>();
-const currentUserId = ref("");
+import { ref, reactive, computed, onMounted } from 'vue'
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
+import { Search, Refresh, Plus, Edit, Delete, Key } from '@element-plus/icons-vue'
+import { listUsers, createUser, updateUser, deleteUser, resetPassword, type User, type UserForm } from '@/api/user'
+
+const loading = ref(false)
+const submitLoading = ref(false)
+const resetLoading = ref(false)
+const userList = ref<User[]>([])
+const total = ref(0)
+const dialogVisible = ref(false)
+const resetDialogVisible = ref(false)
+const formRef = ref<FormInstance>()
+const resetFormRef = ref<FormInstance>()
+const currentUserId = ref('')
 
 const queryParams = reactive({
   page: 1,
   pageSize: 10,
-  search: "",
-  role: "" as "" | "admin" | "operator" | "viewer",
-});
+  search: '',
+  role: '' as '' | 'admin' | 'operator' | 'viewer'
+})
 
 const form = reactive<UserForm & { id?: string }>({
-  username: "",
-  password: "",
-  role: "viewer",
-});
+  username: '',
+  password: '',
+  role: 'viewer'
+})
 
 const resetForm = reactive({
-  password: "",
-  confirmPassword: "",
-});
+  password: '',
+  confirmPassword: ''
+})
 
-const isEdit = computed(() => !!form.id);
-const dialogTitle = computed(() => (isEdit.value ? "编辑用户" : "新增用户"));
+const isEdit = computed(() => !!form.id)
+const dialogTitle = computed(() => (isEdit.value ? '编辑用户' : '新增用户'))
 
 const rules: FormRules = {
   username: [
-    { required: true, message: "请输入用户名", trigger: "blur" },
-    { min: 3, max: 20, message: "用户名长度为3-20个字符", trigger: "blur" },
+    { required: true, message: '请输入用户名', trigger: 'blur' },
+    { min: 3, max: 20, message: '用户名长度为3-20个字符', trigger: 'blur' }
   ],
   password: [
-    { required: true, message: "请输入密码", trigger: "blur" },
-    { min: 6, max: 20, message: "密码长度为6-20个字符", trigger: "blur" },
+    { required: true, message: '请输入密码', trigger: 'blur' },
+    { min: 6, max: 20, message: '密码长度为6-20个字符', trigger: 'blur' }
   ],
-  role: [{ required: true, message: "请选择角色", trigger: "change" }],
-};
-
-const validateConfirmPassword = (
-  _rule: any,
-  value: string,
-  callback: Function
-) => {
+  role: [{ required: true, message: '请选择角色', trigger: 'change' }]
+}
+
+const validateConfirmPassword = (_rule: any, value: string, callback: Function) => {
   if (value !== resetForm.password) {
-    callback(new Error("两次输入的密码不一致"));
+    callback(new Error('两次输入的密码不一致'))
   } else {
-    callback();
+    callback()
   }
-};
+}
 
 const resetRules: FormRules = {
   password: [
-    { required: true, message: "请输入新密码", trigger: "blur" },
-    { min: 6, max: 20, message: "密码长度为6-20个字符", trigger: "blur" },
+    { required: true, message: '请输入新密码', trigger: 'blur' },
+    { min: 6, max: 20, message: '密码长度为6-20个字符', trigger: 'blur' }
   ],
   confirmPassword: [
-    { required: true, message: "请再次输入密码", trigger: "blur" },
-    { validator: validateConfirmPassword, trigger: "blur" },
-  ],
-};
+    { required: true, message: '请再次输入密码', trigger: 'blur' },
+    { validator: validateConfirmPassword, trigger: 'blur' }
+  ]
+}
 
 function getRoleLabel(role: string) {
   const map: Record<string, string> = {
-    admin: "管理员",
-    operator: "操作员",
-    viewer: "观察者",
-  };
-  return map[role] || role;
+    admin: '管理员',
+    operator: '操作员',
+    viewer: '观察者'
+  }
+  return map[role] || role
 }
 
 function getRoleTagType(role: string) {
-  const map: Record<string, "" | "success" | "warning" | "info" | "danger"> = {
-    admin: "danger",
-    operator: "warning",
-    viewer: "info",
-  };
-  return map[role] || "info";
+  const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
+    admin: 'danger',
+    operator: 'warning',
+    viewer: 'info'
+  }
+  return map[role] || 'info'
 }
 
 function formatTime(time?: string) {
-  if (!time) return "-";
-  return new Date(time).toLocaleString("zh-CN");
+  if (!time) return '-'
+  return new Date(time).toLocaleString('zh-CN')
 }
 
 async function getList() {
-  loading.value = true;
+  loading.value = true
   try {
     // Filter out empty strings to avoid validation errors
     const params: any = {
       page: queryParams.page,
-      pageSize: queryParams.pageSize,
-    };
-    if (queryParams.search) params.search = queryParams.search;
-    if (queryParams.role) params.role = queryParams.role;
+      pageSize: queryParams.pageSize
+    }
+    if (queryParams.search) params.search = queryParams.search
+    if (queryParams.role) params.role = queryParams.role
 
-    const res = await listUsers(params);
+    const res = await listUsers(params)
     if (res.code === 200) {
-      userList.value = res.data?.rows;
-      total.value = res.data?.total;
+      userList.value = res.data?.rows
+      total.value = res.data?.total
     }
   } finally {
-    loading.value = false;
+    loading.value = false
   }
 }
 
 function handleQuery() {
-  queryParams.page = 1;
-  getList();
+  queryParams.page = 1
+  getList()
 }
 
 function resetQuery() {
-  queryParams.page = 1;
-  queryParams.search = "";
-  queryParams.role = "";
-  getList();
+  queryParams.page = 1
+  queryParams.search = ''
+  queryParams.role = ''
+  getList()
 }
 
 function handleAdd() {
   Object.assign(form, {
     id: undefined,
-    username: "",
-    password: "",
-    role: "viewer",
-  });
-  dialogVisible.value = true;
+    username: '',
+    password: '',
+    role: 'viewer'
+  })
+  dialogVisible.value = true
 }
 
 function handleEdit(row: User) {
   Object.assign(form, {
     id: row.id,
     username: row.username,
-    password: "",
-    role: row.role,
-  });
-  dialogVisible.value = true;
+    password: '',
+    role: row.role
+  })
+  dialogVisible.value = true
 }
 
 function handleResetPassword(row: User) {
-  currentUserId.value = row.id;
-  resetForm.password = "";
-  resetForm.confirmPassword = "";
-  resetDialogVisible.value = true;
+  currentUserId.value = row.id
+  resetForm.password = ''
+  resetForm.confirmPassword = ''
+  resetDialogVisible.value = true
 }
 
 async function handleDelete(row: User) {
   try {
-    await ElMessageBox.confirm(
-      `确定要删除用户 "${row.username}" 吗?`,
-      "提示",
-      {
-        type: "warning",
-      }
-    );
-    const res = await deleteUser(row.id);
+    await ElMessageBox.confirm(`确定要删除用户 "${row.username}" 吗?`, '提示', {
+      type: 'warning'
+    })
+    const res = await deleteUser(row.id)
     if (res.code === 200) {
-      ElMessage.success("删除成功");
-      getList();
+      ElMessage.success('删除成功')
+      getList()
     }
   } catch (error) {
-    if (error !== "cancel") {
-      console.error("删除失败", error);
+    if (error !== 'cancel') {
+      console.error('删除失败', error)
     }
   }
 }
 
 async function handleSubmit() {
-  if (!formRef.value) return;
+  if (!formRef.value) return
 
   await formRef.value.validate(async (valid) => {
     if (valid) {
-      submitLoading.value = true;
+      submitLoading.value = true
       try {
-        let res;
+        let res
         if (form.id) {
-          const { password, ...updateData } = form;
-          res = await updateUser(form.id, updateData);
+          const { password, ...updateData } = form
+          res = await updateUser(form.id, updateData)
         } else {
-          res = await createUser(form);
+          res = await createUser(form)
         }
         if (res.code === 200) {
-          ElMessage.success(form.id ? "修改成功" : "新增成功");
-          dialogVisible.value = false;
-          getList();
+          ElMessage.success(form.id ? '修改成功' : '新增成功')
+          dialogVisible.value = false
+          getList()
         }
       } finally {
-        submitLoading.value = false;
+        submitLoading.value = false
       }
     }
-  });
+  })
 }
 
 async function handleResetSubmit() {
-  if (!resetFormRef.value) return;
+  if (!resetFormRef.value) return
 
   await resetFormRef.value.validate(async (valid) => {
     if (valid) {
-      resetLoading.value = true;
+      resetLoading.value = true
       try {
-        const res = await resetPassword(
-          currentUserId.value,
-          resetForm.password
-        );
+        const res = await resetPassword(currentUserId.value, resetForm.password)
         if (res.code === 200) {
-          ElMessage.success("密码重置成功");
-          resetDialogVisible.value = false;
+          ElMessage.success('密码重置成功')
+          resetDialogVisible.value = false
         }
       } finally {
-        resetLoading.value = false;
+        resetLoading.value = false
       }
     }
-  });
+  })
 }
 
 onMounted(() => {
-  getList();
-});
+  getList()
+})
 </script>
 
 <style lang="scss" scoped>

+ 14 - 0
tests/e2e/example.spec.ts

@@ -0,0 +1,14 @@
+import { test, expect } from '@playwright/test'
+
+test.describe('Example E2E Tests', () => {
+  test('should load the login page', async ({ page }) => {
+    await page.goto('/login')
+    await expect(page).toHaveTitle(/摄像头管理系统/)
+  })
+
+  test('should have login form elements', async ({ page }) => {
+    await page.goto('/login')
+    await expect(page.locator('input[type="text"], input[placeholder*="用户名"]')).toBeVisible()
+    await expect(page.locator('input[type="password"]')).toBeVisible()
+  })
+})

+ 18 - 0
tests/unit/example.spec.ts

@@ -0,0 +1,18 @@
+import { describe, it, expect } from 'vitest'
+
+describe('Example Test Suite', () => {
+  it('should pass a basic test', () => {
+    expect(1 + 1).toBe(2)
+  })
+
+  it('should handle strings', () => {
+    const str = 'Hello World'
+    expect(str).toContain('World')
+  })
+
+  it('should handle arrays', () => {
+    const arr = [1, 2, 3]
+    expect(arr).toHaveLength(3)
+    expect(arr).toContain(2)
+  })
+})

+ 1397 - 1744
tg-live-game.postman_collection.json

@@ -1,1745 +1,1398 @@
 {
-	"info": {
-		"_postman_id": "a670efda-9591-4304-af67-a8eb87c8b938",
-		"name": "tg-live-game",
-		"description": "TG Live Game Backend API Collection",
-		"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
-		"_exporter_id": "42537936"
-	},
-	"item": [
-		{
-			"name": "tg-live-game-hono",
-			"item": [
-				{
-					"name": "auth",
-					"item": [
-						{
-							"name": "register",
-							"event": [
-								{
-									"listen": "test",
-									"script": {
-										"exec": [
-											"pm.environment.set(\"accessToken\", pm.response.json().data.accessToken);",
-											"pm.environment.set(\"refreshToken\", pm.response.json().data.refreshToken);"
-										],
-										"type": "text/javascript",
-										"packages": {},
-										"requests": {}
-									}
-								}
-							],
-							"request": {
-								"method": "POST",
-								"header": [
-									{
-										"key": "Content-Type",
-										"value": "application/json"
-									}
-								],
-								"body": {
-									"mode": "raw",
-									"raw": "{\n  \"username\": \"pwtk003\",\n  \"password\": \"test123456\",\n  \"email\": \"pwtk003@pwtk.cc\"\n}"
-								},
-								"url": {
-									"raw": "{{baseUrl}}/api/auth/register",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"auth",
-										"register"
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "login",
-							"event": [
-								{
-									"listen": "test",
-									"script": {
-										"exec": [
-											"var jsonData = pm.response.json();",
-											"if (jsonData.code === 200 && jsonData.data) {",
-											"    pm.collectionVariables.set('accessToken', jsonData.data.accessToken);",
-											"    pm.collectionVariables.set('refreshToken', jsonData.data.refreshToken);",
-											"}"
-										],
-										"type": "text/javascript",
-										"packages": {},
-										"requests": {}
-									}
-								}
-							],
-							"request": {
-								"method": "POST",
-								"header": [
-									{
-										"key": "Content-Type",
-										"value": "application/json"
-									}
-								],
-								"body": {
-									"mode": "raw",
-									"raw": "{\n  \"username\": \"pwtk001\",\n  \"password\": \"test123456\"\n}"
-								},
-								"url": {
-									"raw": "{{baseUrl}}/api/auth/login",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"auth",
-										"login"
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "refresh",
-							"event": [
-								{
-									"listen": "test",
-									"script": {
-										"exec": [
-											"var jsonData = pm.response.json();",
-											"if (jsonData.code === 200 && jsonData.data) {",
-											"    pm.collectionVariables.set('accessToken', jsonData.data.accessToken);",
-											"    pm.collectionVariables.set('refreshToken', jsonData.data.refreshToken);",
-											"}"
-										],
-										"type": "text/javascript"
-									}
-								}
-							],
-							"request": {
-								"method": "POST",
-								"header": [
-									{
-										"key": "Content-Type",
-										"value": "application/json"
-									}
-								],
-								"body": {
-									"mode": "raw",
-									"raw": "{\n  \"refreshToken\": \"{{refreshToken}}\"\n}"
-								},
-								"url": {
-									"raw": "{{baseUrl}}/api/auth/refresh",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"auth",
-										"refresh"
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "me",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/auth/me",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"auth",
-										"me"
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "change-password",
-							"request": {
-								"method": "POST",
-								"header": [
-									{
-										"key": "Content-Type",
-										"value": "application/json"
-									}
-								],
-								"body": {
-									"mode": "raw",
-									"raw": "{\n  \"oldPassword\": \"admin123\",\n  \"newPassword\": \"newpassword123\"\n}"
-								},
-								"url": {
-									"raw": "{{baseUrl}}/api/auth/change-password",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"auth",
-										"change-password"
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "logout",
-							"request": {
-								"method": "POST",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/auth/logout",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"auth",
-										"logout"
-									]
-								}
-							},
-							"response": []
-						}
-					]
-				},
-				{
-					"name": "users",
-					"item": [
-						{
-							"name": "permissions",
-							"item": [
-								{
-									"name": "list",
-									"request": {
-										"method": "GET",
-										"header": [],
-										"url": {
-											"raw": "{{baseUrl}}/api/users/:id/permissions",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"users",
-												":id",
-												"permissions"
-											],
-											"variable": [
-												{
-													"key": "id",
-													"value": "69270add987591d84a5385ecea3d5ab0"
-												}
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "add",
-									"request": {
-										"method": "POST",
-										"header": [
-											{
-												"key": "Content-Type",
-												"value": "application/json"
-											}
-										],
-										"body": {
-											"mode": "raw",
-											"raw": "{\n  \"camera_id\": \"camera_id_here\",\n  \"permission\": \"view\"\n}"
-										},
-										"url": {
-											"raw": "{{baseUrl}}/api/users/:id/permissions",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"users",
-												":id",
-												"permissions"
-											],
-											"variable": [
-												{
-													"key": "id",
-													"value": "user_id_here"
-												}
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "delete",
-									"request": {
-										"method": "DELETE",
-										"header": [],
-										"url": {
-											"raw": "{{baseUrl}}/api/users/:id/permissions/:permissionId",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"users",
-												":id",
-												"permissions",
-												":permissionId"
-											],
-											"variable": [
-												{
-													"key": "id",
-													"value": "user_id_here"
-												},
-												{
-													"key": "permissionId",
-													"value": "permission_id_here"
-												}
-											]
-										}
-									},
-									"response": []
-								}
-							]
-						},
-						{
-							"name": "list",
-							"request": {
-								"auth": {
-									"type": "bearer",
-									"bearer": [
-										{
-											"key": "token",
-											"value": "{{accessToken}}",
-											"type": "string"
-										}
-									]
-								},
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/users?page=1&pageSize=20",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"users"
-									],
-									"query": [
-										{
-											"key": "page",
-											"value": "1"
-										},
-										{
-											"key": "pageSize",
-											"value": "20"
-										},
-										{
-											"key": "role",
-											"value": "",
-											"disabled": true
-										},
-										{
-											"key": "status",
-											"value": "",
-											"disabled": true
-										},
-										{
-											"key": "search",
-											"value": "",
-											"disabled": true
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "get",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/users/:id",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"users",
-										":id"
-									],
-									"variable": [
-										{
-											"key": "id",
-											"value": "cdee69f27a05ae30d7b7622879ce1ddf"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "create",
-							"request": {
-								"method": "POST",
-								"header": [
-									{
-										"key": "Content-Type",
-										"value": "application/json"
-									}
-								],
-								"body": {
-									"mode": "raw",
-									"raw": "{\n  \"username\": \"newuser1\",\n  \"password\": \"password123\",\n  \"email\": \"newuser@example.com\",\n  \"role\": \"viewer\"\n}"
-								},
-								"url": {
-									"raw": "{{baseUrl}}/api/users",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"users"
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "update",
-							"request": {
-								"method": "PUT",
-								"header": [
-									{
-										"key": "Content-Type",
-										"value": "application/json"
-									}
-								],
-								"body": {
-									"mode": "raw",
-									"raw": "{\n  \"email\": \"updated@example.com\",\n  \"role\": \"operator\",\n  \"status\": \"active\"\n}"
-								},
-								"url": {
-									"raw": "{{baseUrl}}/api/users/:id",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"users",
-										":id"
-									],
-									"variable": [
-										{
-											"key": "id",
-											"value": "db09b553ed4e1070be2f065c12e4fe81"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "delete",
-							"request": {
-								"method": "DELETE",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/users/:id",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"users",
-										":id"
-									],
-									"variable": [
-										{
-											"key": "id",
-											"value": "db09b553ed4e1070be2f065c12e4fe81"
-										}
-									]
-								}
-							},
-							"response": []
-						}
-					]
-				},
-				{
-					"name": "cameras",
-					"item": [
-						{
-							"name": "list",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/cameras?page=1&pageSize=20",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"cameras"
-									],
-									"query": [
-										{
-											"key": "page",
-											"value": "1"
-										},
-										{
-											"key": "pageSize",
-											"value": "20"
-										},
-										{
-											"key": "status",
-											"value": "",
-											"disabled": true
-										},
-										{
-											"key": "type",
-											"value": "",
-											"disabled": true
-										},
-										{
-											"key": "search",
-											"value": "",
-											"disabled": true
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "get",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/cameras/:id",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"cameras",
-										":id"
-									],
-									"variable": [
-										{
-											"key": "id",
-											"value": "camera_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "create",
-							"request": {
-								"method": "POST",
-								"header": [
-									{
-										"key": "Content-Type",
-										"value": "application/json"
-									}
-								],
-								"body": {
-									"mode": "raw",
-									"raw": "{\n  \"name\": \"Camera 1\",\n  \"type\": \"rtsp\",\n  \"protocol\": \"rtmps\",\n  \"rtsp_url\": \"rtsp://example.com/stream\",\n  \"location\": \"Room 101\"\n}"
-								},
-								"url": {
-									"raw": "{{baseUrl}}/api/cameras",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"cameras"
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "update",
-							"request": {
-								"method": "PUT",
-								"header": [
-									{
-										"key": "Content-Type",
-										"value": "application/json"
-									}
-								],
-								"body": {
-									"mode": "raw",
-									"raw": "{\n  \"name\": \"Camera 1 Updated\",\n  \"location\": \"Room 102\",\n  \"status\": \"online\"\n}"
-								},
-								"url": {
-									"raw": "{{baseUrl}}/api/cameras/:id",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"cameras",
-										":id"
-									],
-									"variable": [
-										{
-											"key": "id",
-											"value": "camera_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "delete",
-							"request": {
-								"method": "DELETE",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/cameras/:id",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"cameras",
-										":id"
-									],
-									"variable": [
-										{
-											"key": "id",
-											"value": "camera_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "sessions",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/cameras/:id/sessions?page=1&pageSize=20",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"cameras",
-										":id",
-										"sessions"
-									],
-									"query": [
-										{
-											"key": "page",
-											"value": "1"
-										},
-										{
-											"key": "pageSize",
-											"value": "20"
-										},
-										{
-											"key": "status",
-											"value": "",
-											"disabled": true
-										}
-									],
-									"variable": [
-										{
-											"key": "id",
-											"value": "camera_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						}
-					]
-				},
-				{
-					"name": "sessions",
-					"item": [
-						{
-							"name": "list",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/sessions?page=1&pageSize=20",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"sessions"
-									],
-									"query": [
-										{
-											"key": "page",
-											"value": "1"
-										},
-										{
-											"key": "pageSize",
-											"value": "20"
-										},
-										{
-											"key": "status",
-											"value": "",
-											"disabled": true
-										},
-										{
-											"key": "camera_id",
-											"value": "",
-											"disabled": true
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "live",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/sessions/live",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"sessions",
-										"live"
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "get",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/sessions/:id",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"sessions",
-										":id"
-									],
-									"variable": [
-										{
-											"key": "id",
-											"value": "session_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "start",
-							"request": {
-								"method": "POST",
-								"header": [
-									{
-										"key": "Content-Type",
-										"value": "application/json"
-									}
-								],
-								"body": {
-									"mode": "raw",
-									"raw": "{\n  \"camera_id\": \"camera_id_here\",\n  \"live_input_id\": \"live_input_id_here\"\n}"
-								},
-								"url": {
-									"raw": "{{baseUrl}}/api/sessions",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"sessions"
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "end",
-							"request": {
-								"method": "PUT",
-								"header": [
-									{
-										"key": "Content-Type",
-										"value": "application/json"
-									}
-								],
-								"body": {
-									"mode": "raw",
-									"raw": "{\n  \"recording_id\": \"recording_id_here\"\n}"
-								},
-								"url": {
-									"raw": "{{baseUrl}}/api/sessions/:id/end",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"sessions",
-										":id",
-										"end"
-									],
-									"variable": [
-										{
-											"key": "id",
-											"value": "session_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "update-viewers",
-							"request": {
-								"method": "PUT",
-								"header": [
-									{
-										"key": "Content-Type",
-										"value": "application/json"
-									}
-								],
-								"body": {
-									"mode": "raw",
-									"raw": "{\n  \"viewer_count\": 100\n}"
-								},
-								"url": {
-									"raw": "{{baseUrl}}/api/sessions/:id/viewers",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"sessions",
-										":id",
-										"viewers"
-									],
-									"variable": [
-										{
-											"key": "id",
-											"value": "session_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "stats",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/sessions/:id/stats",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"sessions",
-										":id",
-										"stats"
-									],
-									"variable": [
-										{
-											"key": "id",
-											"value": "session_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "delete",
-							"request": {
-								"method": "DELETE",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/sessions/:id",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"sessions",
-										":id"
-									],
-									"variable": [
-										{
-											"key": "id",
-											"value": "session_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						}
-					]
-				},
-				{
-					"name": "stats",
-					"item": [
-						{
-							"name": "view",
-							"item": [
-								{
-									"name": "start",
-									"request": {
-										"auth": {
-											"type": "noauth"
-										},
-										"method": "POST",
-										"header": [
-											{
-												"key": "Content-Type",
-												"value": "application/json"
-											}
-										],
-										"body": {
-											"mode": "raw",
-											"raw": "{\n  \"video_id\": \"video_id_here\"\n}"
-										},
-										"url": {
-											"raw": "{{baseUrl}}/api/stats/view/start",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stats",
-												"view",
-												"start"
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "end",
-									"request": {
-										"auth": {
-											"type": "noauth"
-										},
-										"method": "POST",
-										"header": [
-											{
-												"key": "Content-Type",
-												"value": "application/json"
-											}
-										],
-										"body": {
-											"mode": "raw",
-											"raw": "{\n  \"view_id\": \"view_id_here\",\n  \"watch_duration\": 300\n}"
-										},
-										"url": {
-											"raw": "{{baseUrl}}/api/stats/view/end",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stats",
-												"view",
-												"end"
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "heartbeat",
-									"request": {
-										"auth": {
-											"type": "noauth"
-										},
-										"method": "POST",
-										"header": [
-											{
-												"key": "Content-Type",
-												"value": "application/json"
-											}
-										],
-										"body": {
-											"mode": "raw",
-											"raw": "{\n  \"view_id\": \"view_id_here\",\n  \"watch_duration\": 60\n}"
-										},
-										"url": {
-											"raw": "{{baseUrl}}/api/stats/view/heartbeat",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stats",
-												"view",
-												"heartbeat"
-											]
-										}
-									},
-									"response": []
-								}
-							]
-						},
-						{
-							"name": "video",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/stats/video/:videoId",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"stats",
-										"video",
-										":videoId"
-									],
-									"variable": [
-										{
-											"key": "videoId",
-											"value": "video_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "session",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/stats/session/:sessionId",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"stats",
-										"session",
-										":sessionId"
-									],
-									"variable": [
-										{
-											"key": "sessionId",
-											"value": "session_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "overview",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/stats/overview?days=7",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"stats",
-										"overview"
-									],
-									"query": [
-										{
-											"key": "days",
-											"value": "7"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "views",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/stats/views?page=1&pageSize=50",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"stats",
-										"views"
-									],
-									"query": [
-										{
-											"key": "page",
-											"value": "1"
-										},
-										{
-											"key": "pageSize",
-											"value": "50"
-										},
-										{
-											"key": "video_id",
-											"value": "",
-											"disabled": true
-										},
-										{
-											"key": "session_id",
-											"value": "",
-											"disabled": true
-										},
-										{
-											"key": "user_id",
-											"value": "",
-											"disabled": true
-										}
-									]
-								}
-							},
-							"response": []
-						}
-					]
-				},
-				{
-					"name": "audit-logs",
-					"item": [
-						{
-							"name": "stats",
-							"item": [
-								{
-									"name": "summary",
-									"request": {
-										"method": "GET",
-										"header": [],
-										"url": {
-											"raw": "{{baseUrl}}/api/audit-logs/stats/summary?days=7",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"audit-logs",
-												"stats",
-												"summary"
-											],
-											"query": [
-												{
-													"key": "days",
-													"value": "7"
-												}
-											]
-										}
-									},
-									"response": []
-								}
-							]
-						},
-						{
-							"name": "list",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/audit-logs?page=1&pageSize=50",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"audit-logs"
-									],
-									"query": [
-										{
-											"key": "page",
-											"value": "1"
-										},
-										{
-											"key": "pageSize",
-											"value": "50"
-										},
-										{
-											"key": "action",
-											"value": "",
-											"disabled": true
-										},
-										{
-											"key": "resource",
-											"value": "",
-											"disabled": true
-										},
-										{
-											"key": "user_id",
-											"value": "",
-											"disabled": true
-										},
-										{
-											"key": "start_date",
-											"value": "",
-											"disabled": true
-										},
-										{
-											"key": "end_date",
-											"value": "",
-											"disabled": true
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "get",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/audit-logs/:id",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"audit-logs",
-										":id"
-									],
-									"variable": [
-										{
-											"key": "id",
-											"value": "log_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "user",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/audit-logs/user/:userId?page=1&pageSize=20",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"audit-logs",
-										"user",
-										":userId"
-									],
-									"query": [
-										{
-											"key": "page",
-											"value": "1"
-										},
-										{
-											"key": "pageSize",
-											"value": "20"
-										}
-									],
-									"variable": [
-										{
-											"key": "userId",
-											"value": "user_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						},
-						{
-							"name": "resource",
-							"request": {
-								"method": "GET",
-								"header": [],
-								"url": {
-									"raw": "{{baseUrl}}/api/audit-logs/resource/:resource/:resourceId?page=1&pageSize=20",
-									"host": [
-										"{{baseUrl}}"
-									],
-									"path": [
-										"api",
-										"audit-logs",
-										"resource",
-										":resource",
-										":resourceId"
-									],
-									"query": [
-										{
-											"key": "page",
-											"value": "1"
-										},
-										{
-											"key": "pageSize",
-											"value": "20"
-										}
-									],
-									"variable": [
-										{
-											"key": "resource",
-											"value": "camera"
-										},
-										{
-											"key": "resourceId",
-											"value": "resource_id_here"
-										}
-									]
-								}
-							},
-							"response": []
-						}
-					]
-				},
-				{
-					"name": "stream",
-					"item": [
-						{
-							"name": "video",
-							"item": [
-								{
-									"name": "list",
-									"request": {
-										"method": "GET",
-										"header": [],
-										"url": {
-											"raw": "{{baseUrl}}/api/stream/video/list",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stream",
-												"video",
-												"list"
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "get",
-									"request": {
-										"method": "GET",
-										"header": [],
-										"url": {
-											"raw": "{{baseUrl}}/api/stream/video/:videoId",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stream",
-												"video",
-												":videoId"
-											],
-											"variable": [
-												{
-													"key": "videoId",
-													"value": "video_id_here"
-												}
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "delete",
-									"request": {
-										"method": "DELETE",
-										"header": [],
-										"url": {
-											"raw": "{{baseUrl}}/api/stream/video/:videoId",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stream",
-												"video",
-												":videoId"
-											],
-											"variable": [
-												{
-													"key": "videoId",
-													"value": "video_id_here"
-												}
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "import",
-									"request": {
-										"method": "POST",
-										"header": [
-											{
-												"key": "Content-Type",
-												"value": "application/json"
-											}
-										],
-										"body": {
-											"mode": "raw",
-											"raw": "{\n  \"url\": \"https://example.com/video.mp4\",\n  \"meta\": {\n    \"name\": \"My Video\"\n  }\n}"
-										},
-										"url": {
-											"raw": "{{baseUrl}}/api/stream/video/import",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stream",
-												"video",
-												"import"
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "upload-url",
-									"request": {
-										"method": "POST",
-										"header": [
-											{
-												"key": "Content-Type",
-												"value": "application/json"
-											}
-										],
-										"body": {
-											"mode": "raw",
-											"raw": "{\n  \"maxDurationSeconds\": 3600,\n  \"meta\": {\n    \"name\": \"My Upload\"\n  }\n}"
-										},
-										"url": {
-											"raw": "{{baseUrl}}/api/stream/video/upload-url",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stream",
-												"video",
-												"upload-url"
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "playback",
-									"request": {
-										"method": "GET",
-										"header": [],
-										"url": {
-											"raw": "{{baseUrl}}/api/stream/video/:videoId/playback",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stream",
-												"video",
-												":videoId",
-												"playback"
-											],
-											"variable": [
-												{
-													"key": "videoId",
-													"value": "video_id_here"
-												}
-											]
-										}
-									},
-									"response": []
-								}
-							]
-						},
-						{
-							"name": "live",
-							"item": [
-								{
-									"name": "list",
-									"request": {
-										"method": "GET",
-										"header": [],
-										"url": {
-											"raw": "{{baseUrl}}/api/stream/live/list",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stream",
-												"live",
-												"list"
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "create",
-									"request": {
-										"method": "POST",
-										"header": [
-											{
-												"key": "Content-Type",
-												"value": "application/json"
-											}
-										],
-										"body": {
-											"mode": "raw",
-											"raw": "{\n  \"meta\": {\n    \"name\": \"My Live Stream\"\n  },\n  \"recording\": {\n    \"mode\": \"automatic\"\n  }\n}"
-										},
-										"url": {
-											"raw": "{{baseUrl}}/api/stream/live",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stream",
-												"live"
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "get",
-									"request": {
-										"method": "GET",
-										"header": [],
-										"url": {
-											"raw": "{{baseUrl}}/api/stream/live/:liveInputId",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stream",
-												"live",
-												":liveInputId"
-											],
-											"variable": [
-												{
-													"key": "liveInputId",
-													"value": "live_input_id_here"
-												}
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "update",
-									"request": {
-										"method": "PUT",
-										"header": [
-											{
-												"key": "Content-Type",
-												"value": "application/json"
-											}
-										],
-										"body": {
-											"mode": "raw",
-											"raw": "{\n  \"meta\": {\n    \"name\": \"Updated Live Stream\"\n  }\n}"
-										},
-										"url": {
-											"raw": "{{baseUrl}}/api/stream/live/:liveInputId",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stream",
-												"live",
-												":liveInputId"
-											],
-											"variable": [
-												{
-													"key": "liveInputId",
-													"value": "live_input_id_here"
-												}
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "delete",
-									"request": {
-										"method": "DELETE",
-										"header": [],
-										"url": {
-											"raw": "{{baseUrl}}/api/stream/live/:liveInputId",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stream",
-												"live",
-												":liveInputId"
-											],
-											"variable": [
-												{
-													"key": "liveInputId",
-													"value": "live_input_id_here"
-												}
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "playback",
-									"request": {
-										"method": "GET",
-										"header": [],
-										"url": {
-											"raw": "{{baseUrl}}/api/stream/live/:liveInputId/playback",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stream",
-												"live",
-												":liveInputId",
-												"playback"
-											],
-											"variable": [
-												{
-													"key": "liveInputId",
-													"value": "live_input_id_here"
-												}
-											]
-										}
-									},
-									"response": []
-								},
-								{
-									"name": "recordings",
-									"request": {
-										"method": "GET",
-										"header": [],
-										"url": {
-											"raw": "{{baseUrl}}/api/stream/live/:liveInputId/recordings",
-											"host": [
-												"{{baseUrl}}"
-											],
-											"path": [
-												"api",
-												"stream",
-												"live",
-												":liveInputId",
-												"recordings"
-											],
-											"variable": [
-												{
-													"key": "liveInputId",
-													"value": "live_input_id_here"
-												}
-											]
-										}
-									},
-									"response": []
-								}
-							]
-						}
-					]
-				}
-			]
-		}
-	],
-	"auth": {
-		"type": "bearer",
-		"bearer": [
-			{
-				"key": "token",
-				"value": "{{accessToken}}",
-				"type": "string"
-			}
-		]
-	},
-	"event": [
-		{
-			"listen": "prerequest",
-			"script": {
-				"type": "text/javascript",
-				"packages": {},
-				"requests": {},
-				"exec": [
-					""
-				]
-			}
-		},
-		{
-			"listen": "test",
-			"script": {
-				"type": "text/javascript",
-				"packages": {},
-				"requests": {},
-				"exec": [
-					""
-				]
-			}
-		}
-	],
-	"variable": [
-		{
-			"key": "baseUrl",
-			"value": "http://localhost:8787"
-		},
-		{
-			"key": "accessToken",
-			"value": ""
-		},
-		{
-			"key": "refreshToken",
-			"value": ""
-		}
-	]
-}
+  "info": {
+    "_postman_id": "a670efda-9591-4304-af67-a8eb87c8b938",
+    "name": "tg-live-game",
+    "description": "TG Live Game Backend API Collection",
+    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
+    "_exporter_id": "42537936"
+  },
+  "item": [
+    {
+      "name": "tg-live-game-hono",
+      "item": [
+        {
+          "name": "auth",
+          "item": [
+            {
+              "name": "register",
+              "event": [
+                {
+                  "listen": "test",
+                  "script": {
+                    "exec": [
+                      "pm.environment.set(\"accessToken\", pm.response.json().data.accessToken);",
+                      "pm.environment.set(\"refreshToken\", pm.response.json().data.refreshToken);"
+                    ],
+                    "type": "text/javascript",
+                    "packages": {},
+                    "requests": {}
+                  }
+                }
+              ],
+              "request": {
+                "method": "POST",
+                "header": [
+                  {
+                    "key": "Content-Type",
+                    "value": "application/json"
+                  }
+                ],
+                "body": {
+                  "mode": "raw",
+                  "raw": "{\n  \"username\": \"pwtk003\",\n  \"password\": \"test123456\",\n  \"email\": \"pwtk003@pwtk.cc\"\n}"
+                },
+                "url": {
+                  "raw": "{{baseUrl}}/api/auth/register",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "auth", "register"]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "login",
+              "event": [
+                {
+                  "listen": "test",
+                  "script": {
+                    "exec": [
+                      "var jsonData = pm.response.json();",
+                      "if (jsonData.code === 200 && jsonData.data) {",
+                      "    pm.collectionVariables.set('accessToken', jsonData.data.accessToken);",
+                      "    pm.collectionVariables.set('refreshToken', jsonData.data.refreshToken);",
+                      "}"
+                    ],
+                    "type": "text/javascript",
+                    "packages": {},
+                    "requests": {}
+                  }
+                }
+              ],
+              "request": {
+                "method": "POST",
+                "header": [
+                  {
+                    "key": "Content-Type",
+                    "value": "application/json"
+                  }
+                ],
+                "body": {
+                  "mode": "raw",
+                  "raw": "{\n  \"username\": \"pwtk001\",\n  \"password\": \"test123456\"\n}"
+                },
+                "url": {
+                  "raw": "{{baseUrl}}/api/auth/login",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "auth", "login"]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "refresh",
+              "event": [
+                {
+                  "listen": "test",
+                  "script": {
+                    "exec": [
+                      "var jsonData = pm.response.json();",
+                      "if (jsonData.code === 200 && jsonData.data) {",
+                      "    pm.collectionVariables.set('accessToken', jsonData.data.accessToken);",
+                      "    pm.collectionVariables.set('refreshToken', jsonData.data.refreshToken);",
+                      "}"
+                    ],
+                    "type": "text/javascript"
+                  }
+                }
+              ],
+              "request": {
+                "method": "POST",
+                "header": [
+                  {
+                    "key": "Content-Type",
+                    "value": "application/json"
+                  }
+                ],
+                "body": {
+                  "mode": "raw",
+                  "raw": "{\n  \"refreshToken\": \"{{refreshToken}}\"\n}"
+                },
+                "url": {
+                  "raw": "{{baseUrl}}/api/auth/refresh",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "auth", "refresh"]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "me",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/auth/me",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "auth", "me"]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "change-password",
+              "request": {
+                "method": "POST",
+                "header": [
+                  {
+                    "key": "Content-Type",
+                    "value": "application/json"
+                  }
+                ],
+                "body": {
+                  "mode": "raw",
+                  "raw": "{\n  \"oldPassword\": \"admin123\",\n  \"newPassword\": \"newpassword123\"\n}"
+                },
+                "url": {
+                  "raw": "{{baseUrl}}/api/auth/change-password",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "auth", "change-password"]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "logout",
+              "request": {
+                "method": "POST",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/auth/logout",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "auth", "logout"]
+                }
+              },
+              "response": []
+            }
+          ]
+        },
+        {
+          "name": "users",
+          "item": [
+            {
+              "name": "permissions",
+              "item": [
+                {
+                  "name": "list",
+                  "request": {
+                    "method": "GET",
+                    "header": [],
+                    "url": {
+                      "raw": "{{baseUrl}}/api/users/:id/permissions",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "users", ":id", "permissions"],
+                      "variable": [
+                        {
+                          "key": "id",
+                          "value": "69270add987591d84a5385ecea3d5ab0"
+                        }
+                      ]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "add",
+                  "request": {
+                    "method": "POST",
+                    "header": [
+                      {
+                        "key": "Content-Type",
+                        "value": "application/json"
+                      }
+                    ],
+                    "body": {
+                      "mode": "raw",
+                      "raw": "{\n  \"camera_id\": \"camera_id_here\",\n  \"permission\": \"view\"\n}"
+                    },
+                    "url": {
+                      "raw": "{{baseUrl}}/api/users/:id/permissions",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "users", ":id", "permissions"],
+                      "variable": [
+                        {
+                          "key": "id",
+                          "value": "user_id_here"
+                        }
+                      ]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "delete",
+                  "request": {
+                    "method": "DELETE",
+                    "header": [],
+                    "url": {
+                      "raw": "{{baseUrl}}/api/users/:id/permissions/:permissionId",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "users", ":id", "permissions", ":permissionId"],
+                      "variable": [
+                        {
+                          "key": "id",
+                          "value": "user_id_here"
+                        },
+                        {
+                          "key": "permissionId",
+                          "value": "permission_id_here"
+                        }
+                      ]
+                    }
+                  },
+                  "response": []
+                }
+              ]
+            },
+            {
+              "name": "list",
+              "request": {
+                "auth": {
+                  "type": "bearer",
+                  "bearer": [
+                    {
+                      "key": "token",
+                      "value": "{{accessToken}}",
+                      "type": "string"
+                    }
+                  ]
+                },
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/users?page=1&pageSize=20",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "users"],
+                  "query": [
+                    {
+                      "key": "page",
+                      "value": "1"
+                    },
+                    {
+                      "key": "pageSize",
+                      "value": "20"
+                    },
+                    {
+                      "key": "role",
+                      "value": "",
+                      "disabled": true
+                    },
+                    {
+                      "key": "status",
+                      "value": "",
+                      "disabled": true
+                    },
+                    {
+                      "key": "search",
+                      "value": "",
+                      "disabled": true
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "get",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/users/:id",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "users", ":id"],
+                  "variable": [
+                    {
+                      "key": "id",
+                      "value": "cdee69f27a05ae30d7b7622879ce1ddf"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "create",
+              "request": {
+                "method": "POST",
+                "header": [
+                  {
+                    "key": "Content-Type",
+                    "value": "application/json"
+                  }
+                ],
+                "body": {
+                  "mode": "raw",
+                  "raw": "{\n  \"username\": \"newuser1\",\n  \"password\": \"password123\",\n  \"email\": \"newuser@example.com\",\n  \"role\": \"viewer\"\n}"
+                },
+                "url": {
+                  "raw": "{{baseUrl}}/api/users",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "users"]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "update",
+              "request": {
+                "method": "PUT",
+                "header": [
+                  {
+                    "key": "Content-Type",
+                    "value": "application/json"
+                  }
+                ],
+                "body": {
+                  "mode": "raw",
+                  "raw": "{\n  \"email\": \"updated@example.com\",\n  \"role\": \"operator\",\n  \"status\": \"active\"\n}"
+                },
+                "url": {
+                  "raw": "{{baseUrl}}/api/users/:id",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "users", ":id"],
+                  "variable": [
+                    {
+                      "key": "id",
+                      "value": "db09b553ed4e1070be2f065c12e4fe81"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "delete",
+              "request": {
+                "method": "DELETE",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/users/:id",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "users", ":id"],
+                  "variable": [
+                    {
+                      "key": "id",
+                      "value": "db09b553ed4e1070be2f065c12e4fe81"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            }
+          ]
+        },
+        {
+          "name": "cameras",
+          "item": [
+            {
+              "name": "list",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/cameras?page=1&pageSize=20",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "cameras"],
+                  "query": [
+                    {
+                      "key": "page",
+                      "value": "1"
+                    },
+                    {
+                      "key": "pageSize",
+                      "value": "20"
+                    },
+                    {
+                      "key": "status",
+                      "value": "",
+                      "disabled": true
+                    },
+                    {
+                      "key": "type",
+                      "value": "",
+                      "disabled": true
+                    },
+                    {
+                      "key": "search",
+                      "value": "",
+                      "disabled": true
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "get",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/cameras/:id",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "cameras", ":id"],
+                  "variable": [
+                    {
+                      "key": "id",
+                      "value": "camera_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "create",
+              "request": {
+                "method": "POST",
+                "header": [
+                  {
+                    "key": "Content-Type",
+                    "value": "application/json"
+                  }
+                ],
+                "body": {
+                  "mode": "raw",
+                  "raw": "{\n  \"name\": \"Camera 1\",\n  \"type\": \"rtsp\",\n  \"protocol\": \"rtmps\",\n  \"rtsp_url\": \"rtsp://example.com/stream\",\n  \"location\": \"Room 101\"\n}"
+                },
+                "url": {
+                  "raw": "{{baseUrl}}/api/cameras",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "cameras"]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "update",
+              "request": {
+                "method": "PUT",
+                "header": [
+                  {
+                    "key": "Content-Type",
+                    "value": "application/json"
+                  }
+                ],
+                "body": {
+                  "mode": "raw",
+                  "raw": "{\n  \"name\": \"Camera 1 Updated\",\n  \"location\": \"Room 102\",\n  \"status\": \"online\"\n}"
+                },
+                "url": {
+                  "raw": "{{baseUrl}}/api/cameras/:id",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "cameras", ":id"],
+                  "variable": [
+                    {
+                      "key": "id",
+                      "value": "camera_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "delete",
+              "request": {
+                "method": "DELETE",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/cameras/:id",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "cameras", ":id"],
+                  "variable": [
+                    {
+                      "key": "id",
+                      "value": "camera_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "sessions",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/cameras/:id/sessions?page=1&pageSize=20",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "cameras", ":id", "sessions"],
+                  "query": [
+                    {
+                      "key": "page",
+                      "value": "1"
+                    },
+                    {
+                      "key": "pageSize",
+                      "value": "20"
+                    },
+                    {
+                      "key": "status",
+                      "value": "",
+                      "disabled": true
+                    }
+                  ],
+                  "variable": [
+                    {
+                      "key": "id",
+                      "value": "camera_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            }
+          ]
+        },
+        {
+          "name": "sessions",
+          "item": [
+            {
+              "name": "list",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/sessions?page=1&pageSize=20",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "sessions"],
+                  "query": [
+                    {
+                      "key": "page",
+                      "value": "1"
+                    },
+                    {
+                      "key": "pageSize",
+                      "value": "20"
+                    },
+                    {
+                      "key": "status",
+                      "value": "",
+                      "disabled": true
+                    },
+                    {
+                      "key": "camera_id",
+                      "value": "",
+                      "disabled": true
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "live",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/sessions/live",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "sessions", "live"]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "get",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/sessions/:id",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "sessions", ":id"],
+                  "variable": [
+                    {
+                      "key": "id",
+                      "value": "session_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "start",
+              "request": {
+                "method": "POST",
+                "header": [
+                  {
+                    "key": "Content-Type",
+                    "value": "application/json"
+                  }
+                ],
+                "body": {
+                  "mode": "raw",
+                  "raw": "{\n  \"camera_id\": \"camera_id_here\",\n  \"live_input_id\": \"live_input_id_here\"\n}"
+                },
+                "url": {
+                  "raw": "{{baseUrl}}/api/sessions",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "sessions"]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "end",
+              "request": {
+                "method": "PUT",
+                "header": [
+                  {
+                    "key": "Content-Type",
+                    "value": "application/json"
+                  }
+                ],
+                "body": {
+                  "mode": "raw",
+                  "raw": "{\n  \"recording_id\": \"recording_id_here\"\n}"
+                },
+                "url": {
+                  "raw": "{{baseUrl}}/api/sessions/:id/end",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "sessions", ":id", "end"],
+                  "variable": [
+                    {
+                      "key": "id",
+                      "value": "session_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "update-viewers",
+              "request": {
+                "method": "PUT",
+                "header": [
+                  {
+                    "key": "Content-Type",
+                    "value": "application/json"
+                  }
+                ],
+                "body": {
+                  "mode": "raw",
+                  "raw": "{\n  \"viewer_count\": 100\n}"
+                },
+                "url": {
+                  "raw": "{{baseUrl}}/api/sessions/:id/viewers",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "sessions", ":id", "viewers"],
+                  "variable": [
+                    {
+                      "key": "id",
+                      "value": "session_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "stats",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/sessions/:id/stats",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "sessions", ":id", "stats"],
+                  "variable": [
+                    {
+                      "key": "id",
+                      "value": "session_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "delete",
+              "request": {
+                "method": "DELETE",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/sessions/:id",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "sessions", ":id"],
+                  "variable": [
+                    {
+                      "key": "id",
+                      "value": "session_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            }
+          ]
+        },
+        {
+          "name": "stats",
+          "item": [
+            {
+              "name": "view",
+              "item": [
+                {
+                  "name": "start",
+                  "request": {
+                    "auth": {
+                      "type": "noauth"
+                    },
+                    "method": "POST",
+                    "header": [
+                      {
+                        "key": "Content-Type",
+                        "value": "application/json"
+                      }
+                    ],
+                    "body": {
+                      "mode": "raw",
+                      "raw": "{\n  \"video_id\": \"video_id_here\"\n}"
+                    },
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stats/view/start",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stats", "view", "start"]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "end",
+                  "request": {
+                    "auth": {
+                      "type": "noauth"
+                    },
+                    "method": "POST",
+                    "header": [
+                      {
+                        "key": "Content-Type",
+                        "value": "application/json"
+                      }
+                    ],
+                    "body": {
+                      "mode": "raw",
+                      "raw": "{\n  \"view_id\": \"view_id_here\",\n  \"watch_duration\": 300\n}"
+                    },
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stats/view/end",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stats", "view", "end"]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "heartbeat",
+                  "request": {
+                    "auth": {
+                      "type": "noauth"
+                    },
+                    "method": "POST",
+                    "header": [
+                      {
+                        "key": "Content-Type",
+                        "value": "application/json"
+                      }
+                    ],
+                    "body": {
+                      "mode": "raw",
+                      "raw": "{\n  \"view_id\": \"view_id_here\",\n  \"watch_duration\": 60\n}"
+                    },
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stats/view/heartbeat",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stats", "view", "heartbeat"]
+                    }
+                  },
+                  "response": []
+                }
+              ]
+            },
+            {
+              "name": "video",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/stats/video/:videoId",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "stats", "video", ":videoId"],
+                  "variable": [
+                    {
+                      "key": "videoId",
+                      "value": "video_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "session",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/stats/session/:sessionId",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "stats", "session", ":sessionId"],
+                  "variable": [
+                    {
+                      "key": "sessionId",
+                      "value": "session_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "overview",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/stats/overview?days=7",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "stats", "overview"],
+                  "query": [
+                    {
+                      "key": "days",
+                      "value": "7"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "views",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/stats/views?page=1&pageSize=50",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "stats", "views"],
+                  "query": [
+                    {
+                      "key": "page",
+                      "value": "1"
+                    },
+                    {
+                      "key": "pageSize",
+                      "value": "50"
+                    },
+                    {
+                      "key": "video_id",
+                      "value": "",
+                      "disabled": true
+                    },
+                    {
+                      "key": "session_id",
+                      "value": "",
+                      "disabled": true
+                    },
+                    {
+                      "key": "user_id",
+                      "value": "",
+                      "disabled": true
+                    }
+                  ]
+                }
+              },
+              "response": []
+            }
+          ]
+        },
+        {
+          "name": "audit-logs",
+          "item": [
+            {
+              "name": "stats",
+              "item": [
+                {
+                  "name": "summary",
+                  "request": {
+                    "method": "GET",
+                    "header": [],
+                    "url": {
+                      "raw": "{{baseUrl}}/api/audit-logs/stats/summary?days=7",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "audit-logs", "stats", "summary"],
+                      "query": [
+                        {
+                          "key": "days",
+                          "value": "7"
+                        }
+                      ]
+                    }
+                  },
+                  "response": []
+                }
+              ]
+            },
+            {
+              "name": "list",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/audit-logs?page=1&pageSize=50",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "audit-logs"],
+                  "query": [
+                    {
+                      "key": "page",
+                      "value": "1"
+                    },
+                    {
+                      "key": "pageSize",
+                      "value": "50"
+                    },
+                    {
+                      "key": "action",
+                      "value": "",
+                      "disabled": true
+                    },
+                    {
+                      "key": "resource",
+                      "value": "",
+                      "disabled": true
+                    },
+                    {
+                      "key": "user_id",
+                      "value": "",
+                      "disabled": true
+                    },
+                    {
+                      "key": "start_date",
+                      "value": "",
+                      "disabled": true
+                    },
+                    {
+                      "key": "end_date",
+                      "value": "",
+                      "disabled": true
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "get",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/audit-logs/:id",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "audit-logs", ":id"],
+                  "variable": [
+                    {
+                      "key": "id",
+                      "value": "log_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "user",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/audit-logs/user/:userId?page=1&pageSize=20",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "audit-logs", "user", ":userId"],
+                  "query": [
+                    {
+                      "key": "page",
+                      "value": "1"
+                    },
+                    {
+                      "key": "pageSize",
+                      "value": "20"
+                    }
+                  ],
+                  "variable": [
+                    {
+                      "key": "userId",
+                      "value": "user_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            },
+            {
+              "name": "resource",
+              "request": {
+                "method": "GET",
+                "header": [],
+                "url": {
+                  "raw": "{{baseUrl}}/api/audit-logs/resource/:resource/:resourceId?page=1&pageSize=20",
+                  "host": ["{{baseUrl}}"],
+                  "path": ["api", "audit-logs", "resource", ":resource", ":resourceId"],
+                  "query": [
+                    {
+                      "key": "page",
+                      "value": "1"
+                    },
+                    {
+                      "key": "pageSize",
+                      "value": "20"
+                    }
+                  ],
+                  "variable": [
+                    {
+                      "key": "resource",
+                      "value": "camera"
+                    },
+                    {
+                      "key": "resourceId",
+                      "value": "resource_id_here"
+                    }
+                  ]
+                }
+              },
+              "response": []
+            }
+          ]
+        },
+        {
+          "name": "stream",
+          "item": [
+            {
+              "name": "video",
+              "item": [
+                {
+                  "name": "list",
+                  "request": {
+                    "method": "GET",
+                    "header": [],
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stream/video/list",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stream", "video", "list"]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "get",
+                  "request": {
+                    "method": "GET",
+                    "header": [],
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stream/video/:videoId",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stream", "video", ":videoId"],
+                      "variable": [
+                        {
+                          "key": "videoId",
+                          "value": "video_id_here"
+                        }
+                      ]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "delete",
+                  "request": {
+                    "method": "DELETE",
+                    "header": [],
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stream/video/:videoId",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stream", "video", ":videoId"],
+                      "variable": [
+                        {
+                          "key": "videoId",
+                          "value": "video_id_here"
+                        }
+                      ]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "import",
+                  "request": {
+                    "method": "POST",
+                    "header": [
+                      {
+                        "key": "Content-Type",
+                        "value": "application/json"
+                      }
+                    ],
+                    "body": {
+                      "mode": "raw",
+                      "raw": "{\n  \"url\": \"https://example.com/video.mp4\",\n  \"meta\": {\n    \"name\": \"My Video\"\n  }\n}"
+                    },
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stream/video/import",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stream", "video", "import"]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "upload-url",
+                  "request": {
+                    "method": "POST",
+                    "header": [
+                      {
+                        "key": "Content-Type",
+                        "value": "application/json"
+                      }
+                    ],
+                    "body": {
+                      "mode": "raw",
+                      "raw": "{\n  \"maxDurationSeconds\": 3600,\n  \"meta\": {\n    \"name\": \"My Upload\"\n  }\n}"
+                    },
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stream/video/upload-url",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stream", "video", "upload-url"]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "playback",
+                  "request": {
+                    "method": "GET",
+                    "header": [],
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stream/video/:videoId/playback",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stream", "video", ":videoId", "playback"],
+                      "variable": [
+                        {
+                          "key": "videoId",
+                          "value": "video_id_here"
+                        }
+                      ]
+                    }
+                  },
+                  "response": []
+                }
+              ]
+            },
+            {
+              "name": "live",
+              "item": [
+                {
+                  "name": "list",
+                  "request": {
+                    "method": "GET",
+                    "header": [],
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stream/live/list",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stream", "live", "list"]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "create",
+                  "request": {
+                    "method": "POST",
+                    "header": [
+                      {
+                        "key": "Content-Type",
+                        "value": "application/json"
+                      }
+                    ],
+                    "body": {
+                      "mode": "raw",
+                      "raw": "{\n  \"meta\": {\n    \"name\": \"My Live Stream\"\n  },\n  \"recording\": {\n    \"mode\": \"automatic\"\n  }\n}"
+                    },
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stream/live",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stream", "live"]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "get",
+                  "request": {
+                    "method": "GET",
+                    "header": [],
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stream/live/:liveInputId",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stream", "live", ":liveInputId"],
+                      "variable": [
+                        {
+                          "key": "liveInputId",
+                          "value": "live_input_id_here"
+                        }
+                      ]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "update",
+                  "request": {
+                    "method": "PUT",
+                    "header": [
+                      {
+                        "key": "Content-Type",
+                        "value": "application/json"
+                      }
+                    ],
+                    "body": {
+                      "mode": "raw",
+                      "raw": "{\n  \"meta\": {\n    \"name\": \"Updated Live Stream\"\n  }\n}"
+                    },
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stream/live/:liveInputId",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stream", "live", ":liveInputId"],
+                      "variable": [
+                        {
+                          "key": "liveInputId",
+                          "value": "live_input_id_here"
+                        }
+                      ]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "delete",
+                  "request": {
+                    "method": "DELETE",
+                    "header": [],
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stream/live/:liveInputId",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stream", "live", ":liveInputId"],
+                      "variable": [
+                        {
+                          "key": "liveInputId",
+                          "value": "live_input_id_here"
+                        }
+                      ]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "playback",
+                  "request": {
+                    "method": "GET",
+                    "header": [],
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stream/live/:liveInputId/playback",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stream", "live", ":liveInputId", "playback"],
+                      "variable": [
+                        {
+                          "key": "liveInputId",
+                          "value": "live_input_id_here"
+                        }
+                      ]
+                    }
+                  },
+                  "response": []
+                },
+                {
+                  "name": "recordings",
+                  "request": {
+                    "method": "GET",
+                    "header": [],
+                    "url": {
+                      "raw": "{{baseUrl}}/api/stream/live/:liveInputId/recordings",
+                      "host": ["{{baseUrl}}"],
+                      "path": ["api", "stream", "live", ":liveInputId", "recordings"],
+                      "variable": [
+                        {
+                          "key": "liveInputId",
+                          "value": "live_input_id_here"
+                        }
+                      ]
+                    }
+                  },
+                  "response": []
+                }
+              ]
+            }
+          ]
+        }
+      ]
+    }
+  ],
+  "auth": {
+    "type": "bearer",
+    "bearer": [
+      {
+        "key": "token",
+        "value": "{{accessToken}}",
+        "type": "string"
+      }
+    ]
+  },
+  "event": [
+    {
+      "listen": "prerequest",
+      "script": {
+        "type": "text/javascript",
+        "packages": {},
+        "requests": {},
+        "exec": [""]
+      }
+    },
+    {
+      "listen": "test",
+      "script": {
+        "type": "text/javascript",
+        "packages": {},
+        "requests": {},
+        "exec": [""]
+      }
+    }
+  ],
+  "variable": [
+    {
+      "key": "baseUrl",
+      "value": "http://localhost:8787"
+    },
+    {
+      "key": "accessToken",
+      "value": ""
+    },
+    {
+      "key": "refreshToken",
+      "value": ""
+    }
+  ]
+}

+ 2 - 1
tsconfig.json

@@ -2,6 +2,7 @@
   "files": [],
   "references": [
     { "path": "./tsconfig.app.json" },
-    { "path": "./tsconfig.node.json" }
+    { "path": "./tsconfig.node.json" },
+    { "path": "./tsconfig.test.json" }
   ]
 }

+ 2 - 1
tsconfig.node.json

@@ -20,5 +20,6 @@
     "noUnusedParameters": true,
     "noFallthroughCasesInSwitch": true
   },
-  "include": ["vite.config.ts"]
+  "include": ["vite.config.ts"],
+  "exclude": ["vitest.config.ts", "playwright.config.ts"]
 }

+ 7 - 0
tsconfig.test.json

@@ -0,0 +1,7 @@
+{
+  "extends": "./tsconfig.app.json",
+  "compilerOptions": {
+    "types": ["vitest/globals", "node"]
+  },
+  "include": ["tests/**/*"]
+}

+ 28 - 4
vite.config.ts

@@ -1,10 +1,29 @@
 import { defineConfig } from 'vite'
 import vue from '@vitejs/plugin-vue'
 import { resolve } from 'path'
+import AutoImport from 'unplugin-auto-import/vite'
+import Components from 'unplugin-vue-components/vite'
+import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
 import pkg from './package.json'
 
 export default defineConfig({
-  plugins: [vue()],
+  plugins: [
+    vue(),
+    AutoImport({
+      imports: ['vue', 'vue-router', 'pinia'],
+      resolvers: [ElementPlusResolver()],
+      dts: 'src/auto-imports.d.ts',
+      eslintrc: {
+        enabled: true,
+        filepath: './.eslintrc-auto-import.json',
+        globalsPropValue: true
+      }
+    }),
+    Components({
+      resolvers: [ElementPlusResolver()],
+      dts: 'src/components.d.ts'
+    })
+  ],
   define: {
     __APP_VERSION__: JSON.stringify(pkg.version)
   },
@@ -40,9 +59,14 @@ export default defineConfig({
         manualChunks(id) {
           if (id.includes('node_modules')) {
             // Vue 核心和 Element Plus 放在一起,避免循环依赖
-            if (id.includes('vue') || id.includes('@vue') ||
-                id.includes('element-plus') || id.includes('@element-plus') ||
-                id.includes('pinia') || id.includes('vue-router')) {
+            if (
+              id.includes('vue') ||
+              id.includes('@vue') ||
+              id.includes('element-plus') ||
+              id.includes('@element-plus') ||
+              id.includes('pinia') ||
+              id.includes('vue-router')
+            ) {
               return 'vendor'
             }
           }

+ 24 - 0
vitest.config.ts

@@ -0,0 +1,24 @@
+import { defineConfig } from 'vitest/config'
+import vue from '@vitejs/plugin-vue'
+import { resolve } from 'path'
+
+export default defineConfig({
+  plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': resolve(__dirname, 'src')
+    }
+  },
+  test: {
+    environment: 'happy-dom',
+    include: ['tests/unit/**/*.{test,spec}.ts'],
+    globals: true,
+    coverage: {
+      provider: 'v8',
+      reporter: ['text', 'text-summary', 'html', 'lcov'],
+      reportsDirectory: './coverage',
+      include: ['src/**/*.ts', 'src/**/*.vue'],
+      exclude: ['src/main.ts', 'src/env.d.ts', 'src/**/*.d.ts']
+    }
+  }
+})

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.