Forráskód Böngészése

feat: 医梦统一入口客户端 Web Demo 初始化

三栏布局 UI Shell + 对话入口 + 6 种挂号卡片流 + SSE 解析器 + Demo 模式。
T6-T8 前端闭环已验收,单测 25/25 通过,e2e 4/4 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
zhaohan 1 hónapja
commit
598fb66a0b
44 módosított fájl, 4338 hozzáadás és 0 törlés
  1. 7 0
      .gitignore
  2. 150 0
      TEST_REPORT.md
  3. 12 0
      index.html
  4. 32 0
      package.json
  5. 24 0
      playwright.config.ts
  6. 2454 0
      pnpm-lock.yaml
  7. 7 0
      src/App.vue
  8. 49 0
      src/api/client.ts
  9. 40 0
      src/api/demoFixtures.ts
  10. 19 0
      src/api/hmac.ts
  11. 28 0
      src/api/sse.ts
  12. 45 0
      src/api/types.ts
  13. 24 0
      src/assets/doctor-assistant.svg
  14. 28 0
      src/cards/AppointmentSuccessCard.vue
  15. 23 0
      src/cards/CardRenderer.vue
  16. 41 0
      src/cards/ConfirmAppointmentCard.vue
  17. 72 0
      src/cards/DepartmentSelectionCard.vue
  18. 39 0
      src/cards/DoctorSelectionCard.vue
  19. 21 0
      src/cards/ErrorCard.vue
  20. 29 0
      src/cards/PaymentQrCard.vue
  21. 35 0
      src/cards/TimeSlotSelectionCard.vue
  22. 71 0
      src/chat/ChatInput.vue
  23. 101 0
      src/chat/ConversationPanel.vue
  24. 34 0
      src/chat/MessageBubble.vue
  25. 5 0
      src/demo/demoMode.ts
  26. 7 0
      src/env.d.ts
  27. 72 0
      src/layouts/AppShell.vue
  28. 126 0
      src/layouts/AssistantContextPanel.vue
  29. 92 0
      src/layouts/ContextPanel.vue
  30. 53 0
      src/layouts/DoctorAssistantFigure.vue
  31. 69 0
      src/layouts/RegistrationProgress.vue
  32. 6 0
      src/main.ts
  33. 68 0
      src/state/terminalStore.ts
  34. 37 0
      src/theme/tokens.css
  35. 31 0
      tests/e2e/registration-flow.spec.ts
  36. 16 0
      tests/e2e/visual-layout.spec.ts
  37. 20 0
      tests/unit/apiClient.spec.ts
  38. 146 0
      tests/unit/cardRenderer.spec.ts
  39. 18 0
      tests/unit/doctorAssistantFigure.spec.ts
  40. 53 0
      tests/unit/sse.spec.ts
  41. 94 0
      tests/unit/terminalStore.spec.ts
  42. 19 0
      tsconfig.json
  43. 11 0
      tsconfig.node.json
  44. 10 0
      vite.config.ts

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+node_modules/
+dist/
+.vite/
+test-results/
+*.local
+.DS_Store
+Thumbs.db

+ 150 - 0
TEST_REPORT.md

@@ -0,0 +1,150 @@
+# 医梦统一入口客户端 Web Demo — 测试报告
+
+**日期**: 2026-06-02  
+**版本**: 0.1.0  
+**分支**: 统一入口客户端演示  
+**测试环境**: Windows 11 / Node.js 20.19.4 / Chrome (本地)
+
+---
+
+## 概览
+
+| 层级 | 测试文件 | 测试用例 | 通过 | 失败 | 通过率 |
+|------|---------|---------|------|------|--------|
+| 单元测试 | 5 | 25 | 25 | 0 | 100% |
+| E2E 测试 | 2 | 4 | 4 | 0 | 100% |
+| **合计** | **7** | **29** | **29** | **0** | **100%** |
+
+---
+
+## 一、单元测试(Vitest)
+
+```
+PASS  tests/unit/sse.spec.ts                        (6 tests / 8ms)
+PASS  tests/unit/apiClient.spec.ts                   (3 tests / 17ms)
+PASS  tests/unit/terminalStore.spec.ts               (7 tests / 15ms)
+PASS  tests/unit/doctorAssistantFigure.spec.ts       (1 test  / 42ms)
+PASS  tests/unit/cardRenderer.spec.ts               (8 tests / 53ms)
+─────────────────────────────────────────────────────────
+Test Files  5 passed (5)
+     Tests  25 passed (25)
+  Duration  2.47s
+```
+
+### 1.1 SSE 解析器 (`sse.spec.ts`) — 6 通过
+
+| # | 测试用例 | 状态 | 说明 |
+|---|---------|------|------|
+| 1 | parses named SSE events with JSON data and preserves remainder | PASS | 正确解析 `event:`/`data:` 配对并保留未完成片段 |
+| 2 | accumulates across multiple chunks | PASS | 跨 chunk 拼接,收到完整 `\n\n` 后再输出事件 |
+| 3 | handles multiple events in one chunk | PASS | 单次收到多个 `\n\n` 分隔的事件时全部解析 |
+| 4 | ignores malformed data events without throwing | PASS | data 非合法 JSON 时不抛异常,返回 error 类型事件 |
+| 5 | skips events without both event and data lines | PASS | 缺失 event 或 data 行的事件静默跳过 |
+| 6 | handles all six SSE event types | PASS | 全部 6 种类型 (task_updated / message_delta / message_completed / card_created / error / completed) 均正确解析 |
+
+### 1.2 HMAC 辅助函数 (`apiClient.spec.ts`) — 3 通过
+
+| # | 测试用例 | 状态 | 说明 |
+|---|---------|------|------|
+| 1 | hashes body as lowercase hex sha256 | PASS | `{"a":1}` 的 SHA-256 散列与预期一致 |
+| 2 | produces consistent hash for same input | PASS | 相同输入产生相同哈希 |
+| 3 | produces different hashes for different input | PASS | 不同输入产生不同哈希 |
+
+### 1.3 全局状态 Store (`terminalStore.spec.ts`) — 7 通过
+
+| # | 测试用例 | 状态 | 说明 |
+|---|---------|------|------|
+| 1 | starts in idle right-panel mode with no active card | PASS | 初始状态:idle,无 activeCard |
+| 2 | switches to task mode when a card is active | PASS | setActiveCard → contextPanelMode = task |
+| 3 | switches to completed mode for appointment-success card | PASS | 挂号成功卡片 → contextPanelMode = completed |
+| 4 | switches to error mode when error message is set | PASS | setError → contextPanelMode = error |
+| 5 | resets to idle clears task, card and error | PASS | resetToIdle 清空 task / activeCard / errorMessage |
+| 6 | appends assistant delta correctly | PASS | appendAssistantDelta 流式拼接文本 |
+| 7 | completes assistant message marks streaming false | PASS | completeAssistantMessage 标记流结束 |
+
+### 1.4 医生形象组件 (`doctorAssistantFigure.spec.ts`) — 1 通过
+
+| # | 测试用例 | 状态 | 说明 |
+|---|---------|------|------|
+| 1 | renders the static doctor assistant image and no voice controls | PASS | 渲染医生图片、文字说明,无语音/按钮交互,无"可听""可说" |
+
+### 1.5 卡片渲染器 (`cardRenderer.spec.ts`) — 8 通过
+
+| # | 测试用例 | 状态 | 说明 |
+|---|---------|------|------|
+| 1 | renders department card by cardKey | PASS | `department-selection` → 渲染"推荐科室""神经内科" |
+| 2 | renders doctor selection card | PASS | `doctor-selection` → 渲染"选择医生""李明""主任医师" |
+| 3 | renders time slot selection card | PASS | `time-slot-selection` → 渲染"选择时间""明天 09:30-09:45" |
+| 4 | renders confirm appointment card | PASS | `confirm-appointment` → 渲染"确认挂号信息""神经内科""25 元" |
+| 5 | renders payment QR card with mock label | PASS | `payment-qrcode` → 必须包含"联调演示 / Mock 支付" |
+| 6 | renders appointment success card with mock label | PASS | `appointment-success` → 渲染"A023",包含 mock 标签 |
+| 7 | renders error card for unknown card key | PASS | 未知 cardKey → 显示"暂不支持的卡片类型" |
+| 8 | emits action event when card button is clicked | PASS | 按钮点击正确向上 emit action 事件 |
+
+---
+
+## 二、E2E 测试(Playwright)
+
+```
+PASS  [desktop-1440]  visual-layout        (1.5s)
+PASS  [desktop-1920]  visual-layout        (1.8s)
+PASS  [desktop-1440]  registration-flow    (1.9s)
+PASS  [desktop-1920]  registration-flow    (2.2s)
+─────────────────────────────────────────────────────────
+4 passed (6.4s)
+```
+
+### 2.1 视觉布局验收 (`visual-layout.spec.ts`) — 2 通过
+
+| 视口 | 状态 | 验收点 |
+|------|------|--------|
+| 1440×900 | PASS | 标题"医梦门诊助手"可见;左侧"说出需求,我来判断下一步"可见;医生助手图片存在;"静态形象展示"文案存在;语音/发送按钮在中间区域;无"智能挂号"等大按钮;无横向滚动 |
+| 1920×1080 | PASS | 同上 |
+
+### 2.2 挂号全流程 (`registration-flow.spec.ts`) — 2 通过
+
+| 视口 | 状态 | 验收点 |
+|------|------|--------|
+| 1440×900 | PASS | 输入症状 → 科室推荐 → 选择神经内科 → 选择李明医生 → 选择 09:30 → 确认挂号 → Mock 支付 → 挂号成功(A023) |
+| 1920×1080 | PASS | 同上 |
+
+---
+
+## 三、构建验证
+
+```
+pnpm build   PASS   (vue-tsc --noEmit + vite build)
+```
+
+产物:
+```
+dist/index.html                 0.41 kB (gzip: 0.30 kB)
+dist/assets/index-gsDAE_p7.css  10.44 kB (gzip: 2.28 kB)
+dist/assets/index-9PUkZENd.js   84.33 kB (gzip: 32.96 kB)
+```
+
+---
+
+## 四、覆盖范围分析
+
+| 模块 | 单元测试 | E2E | 覆盖状态 |
+|------|:---:|:---:|------|
+| SSE 解析器 (`sse.ts`) | 6 | - | 完全覆盖 |
+| HMAC 辅助 (`hmac.ts`) | 3 | - | 完全覆盖 |
+| API 客户端 (`client.ts`) | - | - | Demo 模式已间接覆盖,真实模式待后端联调 |
+| 状态 Store | 7 | - | 完全覆盖 |
+| 医生形象组件 | 1 | 2 | 完全覆盖 |
+| 卡片渲染器 | 8 | 2 | 完全覆盖 |
+| 对话面板 | - | 2 | E2E 间接覆盖 |
+| 三栏布局 Shell | - | 2 | E2E 间接覆盖 |
+| 挂号进度组件 | - | 2 | E2E 间接覆盖 |
+
+---
+
+## 五、已知缺口
+
+1. **API 客户端真实模式** — `client.ts` 的真实 HTTP 请求分支需要后端 SSE 接口就绪后才能验证
+2. **医生 PNG 图片** — 当前使用 SVG 占位,设计稿指定了真实 PNG(macOS 路径未同步至 Windows 环境)
+3. **语音功能** — 按设计在 `out of scope` 范围内,当前仅展示按钮并给出提示
+4. **hmac.ts 真实签名** — Demo 签名仅用于本地演示,真实 HMAC 签名待联调时接入
+5. **应用启动引导** — 设备注册、心跳、场景加载流程待后端就绪后覆盖

+ 12 - 0
index.html

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>医梦门诊助手</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 32 - 0
package.json

@@ -0,0 +1,32 @@
+{
+  "name": "emoon-terminal-client",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite --host 127.0.0.1",
+    "build": "vue-tsc --noEmit && vite build",
+    "preview": "vite preview --host 127.0.0.1",
+    "test:unit": "vitest run",
+    "test:e2e": "playwright test",
+    "test": "pnpm test:unit && pnpm test:e2e"
+  },
+  "dependencies": {
+    "@vitejs/plugin-vue": "^5.2.0",
+    "pinia": "^2.3.1",
+    "vue": "^3.5.13"
+  },
+  "devDependencies": {
+    "@playwright/test": "^1.49.1",
+    "@types/node": "^22.10.2",
+    "@vue/test-utils": "^2.4.6",
+    "jsdom": "^25.0.1",
+    "typescript": "^5.7.2",
+    "vite": "^6.0.5",
+    "vitest": "^2.1.8",
+    "vue-tsc": "^2.2.0"
+  },
+  "pnpm": {
+    "onlyBuiltDependencies": ["esbuild", "vue-demi"]
+  }
+}

+ 24 - 0
playwright.config.ts

@@ -0,0 +1,24 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+  testDir: './tests/e2e',
+  webServer: {
+    command: 'pnpm dev',
+    url: 'http://127.0.0.1:5173',
+    reuseExistingServer: true
+  },
+  use: {
+    baseURL: 'http://127.0.0.1:5173',
+    trace: 'retain-on-failure'
+  },
+  projects: [
+    {
+      name: 'desktop-1440',
+      use: { ...devices['Desktop Chrome'], channel: 'chrome', viewport: { width: 1440, height: 900 } }
+    },
+    {
+      name: 'desktop-1920',
+      use: { ...devices['Desktop Chrome'], channel: 'chrome', viewport: { width: 1920, height: 1080 } }
+    }
+  ]
+});

+ 2454 - 0
pnpm-lock.yaml

@@ -0,0 +1,2454 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    dependencies:
+      '@vitejs/plugin-vue':
+        specifier: ^5.2.0
+        version: 5.2.4(vite@6.4.3(@types/node@22.19.19))(vue@3.5.35(typescript@5.9.3))
+      pinia:
+        specifier: ^2.3.1
+        version: 2.3.1(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))
+      vue:
+        specifier: ^3.5.13
+        version: 3.5.35(typescript@5.9.3)
+    devDependencies:
+      '@playwright/test':
+        specifier: ^1.49.1
+        version: 1.60.0
+      '@types/node':
+        specifier: ^22.10.2
+        version: 22.19.19
+      '@vue/test-utils':
+        specifier: ^2.4.6
+        version: 2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3))
+      jsdom:
+        specifier: ^25.0.1
+        version: 25.0.1
+      typescript:
+        specifier: ^5.7.2
+        version: 5.9.3
+      vite:
+        specifier: ^6.0.5
+        version: 6.4.3(@types/node@22.19.19)
+      vitest:
+        specifier: ^2.1.8
+        version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1)
+      vue-tsc:
+        specifier: ^2.2.0
+        version: 2.2.12(typescript@5.9.3)
+
+packages:
+
+  '@asamuzakjp/css-color@3.2.0':
+    resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
+
+  '@babel/helper-string-parser@7.29.7':
+    resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-validator-identifier@7.29.7':
+    resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/parser@7.29.7':
+    resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+    engines: {node: '>=6.0.0'}
+    hasBin: true
+
+  '@babel/types@7.29.7':
+    resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+    engines: {node: '>=6.9.0'}
+
+  '@csstools/color-helpers@5.1.0':
+    resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
+    engines: {node: '>=18'}
+
+  '@csstools/css-calc@2.1.4':
+    resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@csstools/css-parser-algorithms': ^3.0.5
+      '@csstools/css-tokenizer': ^3.0.4
+
+  '@csstools/css-color-parser@3.1.0':
+    resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@csstools/css-parser-algorithms': ^3.0.5
+      '@csstools/css-tokenizer': ^3.0.4
+
+  '@csstools/css-parser-algorithms@3.0.5':
+    resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@csstools/css-tokenizer': ^3.0.4
+
+  '@csstools/css-tokenizer@3.0.4':
+    resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
+    engines: {node: '>=18'}
+
+  '@esbuild/aix-ppc64@0.21.5':
+    resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
+    engines: {node: '>=12'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/aix-ppc64@0.25.12':
+    resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/android-arm64@0.21.5':
+    resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm64@0.25.12':
+    resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm@0.21.5':
+    resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
+    engines: {node: '>=12'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-arm@0.25.12':
+    resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-x64@0.21.5':
+    resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/android-x64@0.25.12':
+    resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/darwin-arm64@0.21.5':
+    resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-arm64@0.25.12':
+    resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.21.5':
+    resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.25.12':
+    resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/freebsd-arm64@0.21.5':
+    resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-arm64@0.25.12':
+    resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.21.5':
+    resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.25.12':
+    resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/linux-arm64@0.21.5':
+    resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm64@0.25.12':
+    resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.21.5':
+    resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
+    engines: {node: '>=12'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.25.12':
+    resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.21.5':
+    resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
+    engines: {node: '>=12'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.25.12':
+    resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.21.5':
+    resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
+    engines: {node: '>=12'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.25.12':
+    resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+    engines: {node: '>=18'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.21.5':
+    resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
+    engines: {node: '>=12'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.25.12':
+    resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+    engines: {node: '>=18'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.21.5':
+    resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
+    engines: {node: '>=12'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.25.12':
+    resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.21.5':
+    resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
+    engines: {node: '>=12'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.25.12':
+    resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+    engines: {node: '>=18'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.21.5':
+    resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
+    engines: {node: '>=12'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.25.12':
+    resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+    engines: {node: '>=18'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.21.5':
+    resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.25.12':
+    resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/netbsd-arm64@0.25.12':
+    resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.21.5':
+    resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.25.12':
+    resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/openbsd-arm64@0.25.12':
+    resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.21.5':
+    resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.25.12':
+    resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openharmony-arm64@0.25.12':
+    resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/sunos-x64@0.21.5':
+    resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/sunos-x64@0.25.12':
+    resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/win32-arm64@0.21.5':
+    resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-arm64@0.25.12':
+    resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.21.5':
+    resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
+    engines: {node: '>=12'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.25.12':
+    resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.21.5':
+    resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.25.12':
+    resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@isaacs/cliui@8.0.2':
+    resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
+    engines: {node: '>=12'}
+
+  '@jridgewell/sourcemap-codec@1.5.5':
+    resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+  '@one-ini/wasm@0.1.1':
+    resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
+
+  '@pkgjs/parseargs@0.11.0':
+    resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
+    engines: {node: '>=14'}
+
+  '@playwright/test@1.60.0':
+    resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  '@rollup/rollup-android-arm-eabi@4.61.0':
+    resolution: {integrity: sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==}
+    cpu: [arm]
+    os: [android]
+
+  '@rollup/rollup-android-arm64@4.61.0':
+    resolution: {integrity: sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==}
+    cpu: [arm64]
+    os: [android]
+
+  '@rollup/rollup-darwin-arm64@4.61.0':
+    resolution: {integrity: sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@rollup/rollup-darwin-x64@4.61.0':
+    resolution: {integrity: sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@rollup/rollup-freebsd-arm64@4.61.0':
+    resolution: {integrity: sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@rollup/rollup-freebsd-x64@4.61.0':
+    resolution: {integrity: sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.61.0':
+    resolution: {integrity: sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm-musleabihf@4.61.0':
+    resolution: {integrity: sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==}
+    cpu: [arm]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-arm64-gnu@4.61.0':
+    resolution: {integrity: sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm64-musl@4.61.0':
+    resolution: {integrity: sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-loong64-gnu@4.61.0':
+    resolution: {integrity: sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-loong64-musl@4.61.0':
+    resolution: {integrity: sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-ppc64-gnu@4.61.0':
+    resolution: {integrity: sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-ppc64-musl@4.61.0':
+    resolution: {integrity: sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-riscv64-gnu@4.61.0':
+    resolution: {integrity: sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-musl@4.61.0':
+    resolution: {integrity: sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-s390x-gnu@4.61.0':
+    resolution: {integrity: sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-gnu@4.61.0':
+    resolution: {integrity: sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-musl@4.61.0':
+    resolution: {integrity: sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-openbsd-x64@4.61.0':
+    resolution: {integrity: sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@rollup/rollup-openharmony-arm64@4.61.0':
+    resolution: {integrity: sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@rollup/rollup-win32-arm64-msvc@4.61.0':
+    resolution: {integrity: sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==}
+    cpu: [arm64]
+    os: [win32]
+
+  '@rollup/rollup-win32-ia32-msvc@4.61.0':
+    resolution: {integrity: sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==}
+    cpu: [ia32]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-gnu@4.61.0':
+    resolution: {integrity: sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==}
+    cpu: [x64]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-msvc@4.61.0':
+    resolution: {integrity: sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==}
+    cpu: [x64]
+    os: [win32]
+
+  '@types/estree@1.0.9':
+    resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+  '@types/node@22.19.19':
+    resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==}
+
+  '@vitejs/plugin-vue@5.2.4':
+    resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
+    engines: {node: ^18.0.0 || >=20.0.0}
+    peerDependencies:
+      vite: ^5.0.0 || ^6.0.0
+      vue: ^3.2.25
+
+  '@vitest/expect@2.1.9':
+    resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
+
+  '@vitest/mocker@2.1.9':
+    resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
+    peerDependencies:
+      msw: ^2.4.9
+      vite: ^5.0.0
+    peerDependenciesMeta:
+      msw:
+        optional: true
+      vite:
+        optional: true
+
+  '@vitest/pretty-format@2.1.9':
+    resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
+
+  '@vitest/runner@2.1.9':
+    resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
+
+  '@vitest/snapshot@2.1.9':
+    resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
+
+  '@vitest/spy@2.1.9':
+    resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
+
+  '@vitest/utils@2.1.9':
+    resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
+
+  '@volar/language-core@2.4.15':
+    resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
+
+  '@volar/source-map@2.4.15':
+    resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
+
+  '@volar/typescript@2.4.15':
+    resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
+
+  '@vue/compiler-core@3.5.35':
+    resolution: {integrity: sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==}
+
+  '@vue/compiler-dom@3.5.35':
+    resolution: {integrity: sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==}
+
+  '@vue/compiler-sfc@3.5.35':
+    resolution: {integrity: sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==}
+
+  '@vue/compiler-ssr@3.5.35':
+    resolution: {integrity: sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==}
+
+  '@vue/compiler-vue2@2.7.16':
+    resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
+
+  '@vue/devtools-api@6.6.4':
+    resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
+
+  '@vue/language-core@2.2.12':
+    resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
+    peerDependencies:
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  '@vue/reactivity@3.5.35':
+    resolution: {integrity: sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==}
+
+  '@vue/runtime-core@3.5.35':
+    resolution: {integrity: sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==}
+
+  '@vue/runtime-dom@3.5.35':
+    resolution: {integrity: sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==}
+
+  '@vue/server-renderer@3.5.35':
+    resolution: {integrity: sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==}
+    peerDependencies:
+      vue: 3.5.35
+
+  '@vue/shared@3.5.35':
+    resolution: {integrity: sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==}
+
+  '@vue/test-utils@2.4.10':
+    resolution: {integrity: sha512-SmoZ5EA1kYiAFs9NkYdiFFQF+cSnUwnvlYEbY+DogWQZUiqOm/Y29eSbc5T6yi75SgSF9863SBeXniIEoPajCA==}
+    peerDependencies:
+      '@vue/compiler-dom': 3.x
+      '@vue/server-renderer': 3.x
+      vue: 3.x
+    peerDependenciesMeta:
+      '@vue/server-renderer':
+        optional: true
+
+  abbrev@2.0.0:
+    resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+
+  agent-base@7.1.4:
+    resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
+    engines: {node: '>= 14'}
+
+  alien-signals@1.0.13:
+    resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
+
+  ansi-regex@5.0.1:
+    resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+    engines: {node: '>=8'}
+
+  ansi-regex@6.2.2:
+    resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
+    engines: {node: '>=12'}
+
+  ansi-styles@4.3.0:
+    resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+    engines: {node: '>=8'}
+
+  ansi-styles@6.2.3:
+    resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
+    engines: {node: '>=12'}
+
+  assertion-error@2.0.1:
+    resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+    engines: {node: '>=12'}
+
+  asynckit@0.4.0:
+    resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
+  balanced-match@1.0.2:
+    resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+  brace-expansion@2.1.1:
+    resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
+
+  cac@6.7.14:
+    resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
+    engines: {node: '>=8'}
+
+  call-bind-apply-helpers@1.0.2:
+    resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+    engines: {node: '>= 0.4'}
+
+  chai@5.3.3:
+    resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
+    engines: {node: '>=18'}
+
+  check-error@2.1.3:
+    resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
+    engines: {node: '>= 16'}
+
+  color-convert@2.0.1:
+    resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+    engines: {node: '>=7.0.0'}
+
+  color-name@1.1.4:
+    resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+  combined-stream@1.0.8:
+    resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+    engines: {node: '>= 0.8'}
+
+  commander@10.0.1:
+    resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
+    engines: {node: '>=14'}
+
+  config-chain@1.1.13:
+    resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
+
+  cross-spawn@7.0.6:
+    resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+    engines: {node: '>= 8'}
+
+  cssstyle@4.6.0:
+    resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
+    engines: {node: '>=18'}
+
+  csstype@3.2.3:
+    resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+  data-urls@5.0.0:
+    resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
+    engines: {node: '>=18'}
+
+  de-indent@1.0.2:
+    resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
+
+  debug@4.4.3:
+    resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+    engines: {node: '>=6.0'}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  decimal.js@10.6.0:
+    resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
+  deep-eql@5.0.2:
+    resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
+    engines: {node: '>=6'}
+
+  delayed-stream@1.0.0:
+    resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+    engines: {node: '>=0.4.0'}
+
+  dunder-proto@1.0.1:
+    resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+    engines: {node: '>= 0.4'}
+
+  eastasianwidth@0.2.0:
+    resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+
+  editorconfig@1.0.7:
+    resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
+    engines: {node: '>=14'}
+    hasBin: true
+
+  emoji-regex@8.0.0:
+    resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
+  emoji-regex@9.2.2:
+    resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+
+  entities@6.0.1:
+    resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+    engines: {node: '>=0.12'}
+
+  entities@7.0.1:
+    resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+    engines: {node: '>=0.12'}
+
+  es-define-property@1.0.1:
+    resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+    engines: {node: '>= 0.4'}
+
+  es-errors@1.3.0:
+    resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+    engines: {node: '>= 0.4'}
+
+  es-module-lexer@1.7.0:
+    resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+
+  es-object-atoms@1.1.2:
+    resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+    engines: {node: '>= 0.4'}
+
+  es-set-tostringtag@2.1.0:
+    resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+    engines: {node: '>= 0.4'}
+
+  esbuild@0.21.5:
+    resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
+    engines: {node: '>=12'}
+    hasBin: true
+
+  esbuild@0.25.12:
+    resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  estree-walker@2.0.2:
+    resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+  estree-walker@3.0.3:
+    resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+  expect-type@1.3.0:
+    resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+    engines: {node: '>=12.0.0'}
+
+  fdir@6.5.0:
+    resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+    engines: {node: '>=12.0.0'}
+    peerDependencies:
+      picomatch: ^3 || ^4
+    peerDependenciesMeta:
+      picomatch:
+        optional: true
+
+  foreground-child@3.3.1:
+    resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
+    engines: {node: '>=14'}
+
+  form-data@4.0.5:
+    resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
+    engines: {node: '>= 6'}
+
+  fsevents@2.3.2:
+    resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
+  fsevents@2.3.3:
+    resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
+  function-bind@1.1.2:
+    resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+  get-intrinsic@1.3.0:
+    resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+    engines: {node: '>= 0.4'}
+
+  get-proto@1.0.1:
+    resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+    engines: {node: '>= 0.4'}
+
+  glob@10.4.5:
+    resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
+    hasBin: true
+
+  gopd@1.2.0:
+    resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+    engines: {node: '>= 0.4'}
+
+  has-symbols@1.1.0:
+    resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+    engines: {node: '>= 0.4'}
+
+  has-tostringtag@1.0.2:
+    resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+    engines: {node: '>= 0.4'}
+
+  hasown@2.0.4:
+    resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+    engines: {node: '>= 0.4'}
+
+  he@1.2.0:
+    resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
+    hasBin: true
+
+  html-encoding-sniffer@4.0.0:
+    resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
+    engines: {node: '>=18'}
+
+  http-proxy-agent@7.0.2:
+    resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
+    engines: {node: '>= 14'}
+
+  https-proxy-agent@7.0.6:
+    resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+    engines: {node: '>= 14'}
+
+  iconv-lite@0.6.3:
+    resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+    engines: {node: '>=0.10.0'}
+
+  ini@1.3.8:
+    resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
+
+  is-fullwidth-code-point@3.0.0:
+    resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+    engines: {node: '>=8'}
+
+  is-potential-custom-element-name@1.0.1:
+    resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
+  isexe@2.0.0:
+    resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+  jackspeak@3.4.3:
+    resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
+
+  js-beautify@1.15.4:
+    resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
+    engines: {node: '>=14'}
+    hasBin: true
+
+  js-cookie@3.0.8:
+    resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==}
+
+  jsdom@25.0.1:
+    resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      canvas: ^2.11.2
+    peerDependenciesMeta:
+      canvas:
+        optional: true
+
+  loupe@3.2.1:
+    resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+
+  lru-cache@10.4.3:
+    resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+
+  magic-string@0.30.21:
+    resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+  math-intrinsics@1.1.0:
+    resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+    engines: {node: '>= 0.4'}
+
+  mime-db@1.52.0:
+    resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+    engines: {node: '>= 0.6'}
+
+  mime-types@2.1.35:
+    resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+    engines: {node: '>= 0.6'}
+
+  minimatch@9.0.9:
+    resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
+    engines: {node: '>=16 || 14 >=14.17'}
+
+  minipass@7.1.3:
+    resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
+    engines: {node: '>=16 || 14 >=14.17'}
+
+  ms@2.1.3:
+    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+  muggle-string@0.4.1:
+    resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
+
+  nanoid@3.3.12:
+    resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+    engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+    hasBin: true
+
+  nopt@7.2.1:
+    resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    hasBin: true
+
+  nwsapi@2.2.23:
+    resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
+
+  package-json-from-dist@1.0.1:
+    resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
+
+  parse5@7.3.0:
+    resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+
+  path-browserify@1.0.1:
+    resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
+
+  path-key@3.1.1:
+    resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+    engines: {node: '>=8'}
+
+  path-scurry@1.11.1:
+    resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
+    engines: {node: '>=16 || 14 >=14.18'}
+
+  pathe@1.1.2:
+    resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
+
+  pathval@2.0.1:
+    resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
+    engines: {node: '>= 14.16'}
+
+  picocolors@1.1.1:
+    resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+  picomatch@4.0.4:
+    resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+    engines: {node: '>=12'}
+
+  pinia@2.3.1:
+    resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==}
+    peerDependencies:
+      typescript: '>=4.4.4'
+      vue: ^2.7.0 || ^3.5.11
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  playwright-core@1.60.0:
+    resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  playwright@1.60.0:
+    resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  postcss@8.5.15:
+    resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+    engines: {node: ^10 || ^12 || >=14}
+
+  proto-list@1.2.4:
+    resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
+
+  punycode@2.3.1:
+    resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+    engines: {node: '>=6'}
+
+  rollup@4.61.0:
+    resolution: {integrity: sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==}
+    engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+    hasBin: true
+
+  rrweb-cssom@0.7.1:
+    resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==}
+
+  rrweb-cssom@0.8.0:
+    resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
+
+  safer-buffer@2.1.2:
+    resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+  saxes@6.0.0:
+    resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+    engines: {node: '>=v12.22.7'}
+
+  semver@7.8.1:
+    resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  shebang-command@2.0.0:
+    resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+    engines: {node: '>=8'}
+
+  shebang-regex@3.0.0:
+    resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+    engines: {node: '>=8'}
+
+  siginfo@2.0.0:
+    resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
+  signal-exit@4.1.0:
+    resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
+    engines: {node: '>=14'}
+
+  source-map-js@1.2.1:
+    resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+    engines: {node: '>=0.10.0'}
+
+  stackback@0.0.2:
+    resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+  std-env@3.10.0:
+    resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+
+  string-width@4.2.3:
+    resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+    engines: {node: '>=8'}
+
+  string-width@5.1.2:
+    resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
+    engines: {node: '>=12'}
+
+  strip-ansi@6.0.1:
+    resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+    engines: {node: '>=8'}
+
+  strip-ansi@7.2.0:
+    resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
+    engines: {node: '>=12'}
+
+  symbol-tree@3.2.4:
+    resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
+  tinybench@2.9.0:
+    resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+  tinyexec@0.3.2:
+    resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
+
+  tinyglobby@0.2.17:
+    resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+    engines: {node: '>=12.0.0'}
+
+  tinypool@1.1.1:
+    resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
+    engines: {node: ^18.0.0 || >=20.0.0}
+
+  tinyrainbow@1.2.0:
+    resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
+    engines: {node: '>=14.0.0'}
+
+  tinyspy@3.0.2:
+    resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
+    engines: {node: '>=14.0.0'}
+
+  tldts-core@6.1.86:
+    resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
+
+  tldts@6.1.86:
+    resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
+    hasBin: true
+
+  tough-cookie@5.1.2:
+    resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
+    engines: {node: '>=16'}
+
+  tr46@5.1.1:
+    resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
+    engines: {node: '>=18'}
+
+  typescript@5.9.3:
+    resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+    engines: {node: '>=14.17'}
+    hasBin: true
+
+  undici-types@6.21.0:
+    resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+  vite-node@2.1.9:
+    resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
+    engines: {node: ^18.0.0 || >=20.0.0}
+    hasBin: true
+
+  vite@5.4.21:
+    resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
+    engines: {node: ^18.0.0 || >=20.0.0}
+    hasBin: true
+    peerDependencies:
+      '@types/node': ^18.0.0 || >=20.0.0
+      less: '*'
+      lightningcss: ^1.21.0
+      sass: '*'
+      sass-embedded: '*'
+      stylus: '*'
+      sugarss: '*'
+      terser: ^5.4.0
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+      less:
+        optional: true
+      lightningcss:
+        optional: true
+      sass:
+        optional: true
+      sass-embedded:
+        optional: true
+      stylus:
+        optional: true
+      sugarss:
+        optional: true
+      terser:
+        optional: true
+
+  vite@6.4.3:
+    resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
+    engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+    hasBin: true
+    peerDependencies:
+      '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+      jiti: '>=1.21.0'
+      less: '*'
+      lightningcss: ^1.21.0
+      sass: '*'
+      sass-embedded: '*'
+      stylus: '*'
+      sugarss: '*'
+      terser: ^5.16.0
+      tsx: ^4.8.1
+      yaml: ^2.4.2
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+      jiti:
+        optional: true
+      less:
+        optional: true
+      lightningcss:
+        optional: true
+      sass:
+        optional: true
+      sass-embedded:
+        optional: true
+      stylus:
+        optional: true
+      sugarss:
+        optional: true
+      terser:
+        optional: true
+      tsx:
+        optional: true
+      yaml:
+        optional: true
+
+  vitest@2.1.9:
+    resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
+    engines: {node: ^18.0.0 || >=20.0.0}
+    hasBin: true
+    peerDependencies:
+      '@edge-runtime/vm': '*'
+      '@types/node': ^18.0.0 || >=20.0.0
+      '@vitest/browser': 2.1.9
+      '@vitest/ui': 2.1.9
+      happy-dom: '*'
+      jsdom: '*'
+    peerDependenciesMeta:
+      '@edge-runtime/vm':
+        optional: true
+      '@types/node':
+        optional: true
+      '@vitest/browser':
+        optional: true
+      '@vitest/ui':
+        optional: true
+      happy-dom:
+        optional: true
+      jsdom:
+        optional: true
+
+  vscode-uri@3.1.0:
+    resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
+
+  vue-component-type-helpers@3.3.3:
+    resolution: {integrity: sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==}
+
+  vue-demi@0.14.10:
+    resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
+    engines: {node: '>=12'}
+    hasBin: true
+    peerDependencies:
+      '@vue/composition-api': ^1.0.0-rc.1
+      vue: ^3.0.0-0 || ^2.6.0
+    peerDependenciesMeta:
+      '@vue/composition-api':
+        optional: true
+
+  vue-tsc@2.2.12:
+    resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
+    hasBin: true
+    peerDependencies:
+      typescript: '>=5.0.0'
+
+  vue@3.5.35:
+    resolution: {integrity: sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==}
+    peerDependencies:
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  w3c-xmlserializer@5.0.0:
+    resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+    engines: {node: '>=18'}
+
+  webidl-conversions@7.0.0:
+    resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+    engines: {node: '>=12'}
+
+  whatwg-encoding@3.1.1:
+    resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
+    engines: {node: '>=18'}
+    deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
+
+  whatwg-mimetype@4.0.0:
+    resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
+    engines: {node: '>=18'}
+
+  whatwg-url@14.2.0:
+    resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
+    engines: {node: '>=18'}
+
+  which@2.0.2:
+    resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+    engines: {node: '>= 8'}
+    hasBin: true
+
+  why-is-node-running@2.3.0:
+    resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+    engines: {node: '>=8'}
+    hasBin: true
+
+  wrap-ansi@7.0.0:
+    resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+    engines: {node: '>=10'}
+
+  wrap-ansi@8.1.0:
+    resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
+    engines: {node: '>=12'}
+
+  ws@8.21.0:
+    resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: '>=5.0.2'
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+
+  xml-name-validator@5.0.0:
+    resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+    engines: {node: '>=18'}
+
+  xmlchars@2.2.0:
+    resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
+snapshots:
+
+  '@asamuzakjp/css-color@3.2.0':
+    dependencies:
+      '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-tokenizer': 3.0.4
+      lru-cache: 10.4.3
+
+  '@babel/helper-string-parser@7.29.7': {}
+
+  '@babel/helper-validator-identifier@7.29.7': {}
+
+  '@babel/parser@7.29.7':
+    dependencies:
+      '@babel/types': 7.29.7
+
+  '@babel/types@7.29.7':
+    dependencies:
+      '@babel/helper-string-parser': 7.29.7
+      '@babel/helper-validator-identifier': 7.29.7
+
+  '@csstools/color-helpers@5.1.0': {}
+
+  '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
+    dependencies:
+      '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-tokenizer': 3.0.4
+
+  '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
+    dependencies:
+      '@csstools/color-helpers': 5.1.0
+      '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-tokenizer': 3.0.4
+
+  '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
+    dependencies:
+      '@csstools/css-tokenizer': 3.0.4
+
+  '@csstools/css-tokenizer@3.0.4': {}
+
+  '@esbuild/aix-ppc64@0.21.5':
+    optional: true
+
+  '@esbuild/aix-ppc64@0.25.12':
+    optional: true
+
+  '@esbuild/android-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/android-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/android-arm@0.21.5':
+    optional: true
+
+  '@esbuild/android-arm@0.25.12':
+    optional: true
+
+  '@esbuild/android-x64@0.21.5':
+    optional: true
+
+  '@esbuild/android-x64@0.25.12':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/darwin-x64@0.21.5':
+    optional: true
+
+  '@esbuild/darwin-x64@0.25.12':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.21.5':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-arm@0.21.5':
+    optional: true
+
+  '@esbuild/linux-arm@0.25.12':
+    optional: true
+
+  '@esbuild/linux-ia32@0.21.5':
+    optional: true
+
+  '@esbuild/linux-ia32@0.25.12':
+    optional: true
+
+  '@esbuild/linux-loong64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-loong64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.21.5':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.25.12':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-s390x@0.21.5':
+    optional: true
+
+  '@esbuild/linux-s390x@0.25.12':
+    optional: true
+
+  '@esbuild/linux-x64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-x64@0.25.12':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.21.5':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.21.5':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/sunos-x64@0.21.5':
+    optional: true
+
+  '@esbuild/sunos-x64@0.25.12':
+    optional: true
+
+  '@esbuild/win32-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/win32-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/win32-ia32@0.21.5':
+    optional: true
+
+  '@esbuild/win32-ia32@0.25.12':
+    optional: true
+
+  '@esbuild/win32-x64@0.21.5':
+    optional: true
+
+  '@esbuild/win32-x64@0.25.12':
+    optional: true
+
+  '@isaacs/cliui@8.0.2':
+    dependencies:
+      string-width: 5.1.2
+      string-width-cjs: string-width@4.2.3
+      strip-ansi: 7.2.0
+      strip-ansi-cjs: strip-ansi@6.0.1
+      wrap-ansi: 8.1.0
+      wrap-ansi-cjs: wrap-ansi@7.0.0
+
+  '@jridgewell/sourcemap-codec@1.5.5': {}
+
+  '@one-ini/wasm@0.1.1': {}
+
+  '@pkgjs/parseargs@0.11.0':
+    optional: true
+
+  '@playwright/test@1.60.0':
+    dependencies:
+      playwright: 1.60.0
+
+  '@rollup/rollup-android-arm-eabi@4.61.0':
+    optional: true
+
+  '@rollup/rollup-android-arm64@4.61.0':
+    optional: true
+
+  '@rollup/rollup-darwin-arm64@4.61.0':
+    optional: true
+
+  '@rollup/rollup-darwin-x64@4.61.0':
+    optional: true
+
+  '@rollup/rollup-freebsd-arm64@4.61.0':
+    optional: true
+
+  '@rollup/rollup-freebsd-x64@4.61.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.61.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm-musleabihf@4.61.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-gnu@4.61.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-musl@4.61.0':
+    optional: true
+
+  '@rollup/rollup-linux-loong64-gnu@4.61.0':
+    optional: true
+
+  '@rollup/rollup-linux-loong64-musl@4.61.0':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-gnu@4.61.0':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-musl@4.61.0':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-gnu@4.61.0':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-musl@4.61.0':
+    optional: true
+
+  '@rollup/rollup-linux-s390x-gnu@4.61.0':
+    optional: true
+
+  '@rollup/rollup-linux-x64-gnu@4.61.0':
+    optional: true
+
+  '@rollup/rollup-linux-x64-musl@4.61.0':
+    optional: true
+
+  '@rollup/rollup-openbsd-x64@4.61.0':
+    optional: true
+
+  '@rollup/rollup-openharmony-arm64@4.61.0':
+    optional: true
+
+  '@rollup/rollup-win32-arm64-msvc@4.61.0':
+    optional: true
+
+  '@rollup/rollup-win32-ia32-msvc@4.61.0':
+    optional: true
+
+  '@rollup/rollup-win32-x64-gnu@4.61.0':
+    optional: true
+
+  '@rollup/rollup-win32-x64-msvc@4.61.0':
+    optional: true
+
+  '@types/estree@1.0.9': {}
+
+  '@types/node@22.19.19':
+    dependencies:
+      undici-types: 6.21.0
+
+  '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.19.19))(vue@3.5.35(typescript@5.9.3))':
+    dependencies:
+      vite: 6.4.3(@types/node@22.19.19)
+      vue: 3.5.35(typescript@5.9.3)
+
+  '@vitest/expect@2.1.9':
+    dependencies:
+      '@vitest/spy': 2.1.9
+      '@vitest/utils': 2.1.9
+      chai: 5.3.3
+      tinyrainbow: 1.2.0
+
+  '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19))':
+    dependencies:
+      '@vitest/spy': 2.1.9
+      estree-walker: 3.0.3
+      magic-string: 0.30.21
+    optionalDependencies:
+      vite: 5.4.21(@types/node@22.19.19)
+
+  '@vitest/pretty-format@2.1.9':
+    dependencies:
+      tinyrainbow: 1.2.0
+
+  '@vitest/runner@2.1.9':
+    dependencies:
+      '@vitest/utils': 2.1.9
+      pathe: 1.1.2
+
+  '@vitest/snapshot@2.1.9':
+    dependencies:
+      '@vitest/pretty-format': 2.1.9
+      magic-string: 0.30.21
+      pathe: 1.1.2
+
+  '@vitest/spy@2.1.9':
+    dependencies:
+      tinyspy: 3.0.2
+
+  '@vitest/utils@2.1.9':
+    dependencies:
+      '@vitest/pretty-format': 2.1.9
+      loupe: 3.2.1
+      tinyrainbow: 1.2.0
+
+  '@volar/language-core@2.4.15':
+    dependencies:
+      '@volar/source-map': 2.4.15
+
+  '@volar/source-map@2.4.15': {}
+
+  '@volar/typescript@2.4.15':
+    dependencies:
+      '@volar/language-core': 2.4.15
+      path-browserify: 1.0.1
+      vscode-uri: 3.1.0
+
+  '@vue/compiler-core@3.5.35':
+    dependencies:
+      '@babel/parser': 7.29.7
+      '@vue/shared': 3.5.35
+      entities: 7.0.1
+      estree-walker: 2.0.2
+      source-map-js: 1.2.1
+
+  '@vue/compiler-dom@3.5.35':
+    dependencies:
+      '@vue/compiler-core': 3.5.35
+      '@vue/shared': 3.5.35
+
+  '@vue/compiler-sfc@3.5.35':
+    dependencies:
+      '@babel/parser': 7.29.7
+      '@vue/compiler-core': 3.5.35
+      '@vue/compiler-dom': 3.5.35
+      '@vue/compiler-ssr': 3.5.35
+      '@vue/shared': 3.5.35
+      estree-walker: 2.0.2
+      magic-string: 0.30.21
+      postcss: 8.5.15
+      source-map-js: 1.2.1
+
+  '@vue/compiler-ssr@3.5.35':
+    dependencies:
+      '@vue/compiler-dom': 3.5.35
+      '@vue/shared': 3.5.35
+
+  '@vue/compiler-vue2@2.7.16':
+    dependencies:
+      de-indent: 1.0.2
+      he: 1.2.0
+
+  '@vue/devtools-api@6.6.4': {}
+
+  '@vue/language-core@2.2.12(typescript@5.9.3)':
+    dependencies:
+      '@volar/language-core': 2.4.15
+      '@vue/compiler-dom': 3.5.35
+      '@vue/compiler-vue2': 2.7.16
+      '@vue/shared': 3.5.35
+      alien-signals: 1.0.13
+      minimatch: 9.0.9
+      muggle-string: 0.4.1
+      path-browserify: 1.0.1
+    optionalDependencies:
+      typescript: 5.9.3
+
+  '@vue/reactivity@3.5.35':
+    dependencies:
+      '@vue/shared': 3.5.35
+
+  '@vue/runtime-core@3.5.35':
+    dependencies:
+      '@vue/reactivity': 3.5.35
+      '@vue/shared': 3.5.35
+
+  '@vue/runtime-dom@3.5.35':
+    dependencies:
+      '@vue/reactivity': 3.5.35
+      '@vue/runtime-core': 3.5.35
+      '@vue/shared': 3.5.35
+      csstype: 3.2.3
+
+  '@vue/server-renderer@3.5.35(vue@3.5.35(typescript@5.9.3))':
+    dependencies:
+      '@vue/compiler-ssr': 3.5.35
+      '@vue/shared': 3.5.35
+      vue: 3.5.35(typescript@5.9.3)
+
+  '@vue/shared@3.5.35': {}
+
+  '@vue/test-utils@2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3))':
+    dependencies:
+      '@vue/compiler-dom': 3.5.35
+      js-beautify: 1.15.4
+      vue: 3.5.35(typescript@5.9.3)
+      vue-component-type-helpers: 3.3.3
+    optionalDependencies:
+      '@vue/server-renderer': 3.5.35(vue@3.5.35(typescript@5.9.3))
+
+  abbrev@2.0.0: {}
+
+  agent-base@7.1.4: {}
+
+  alien-signals@1.0.13: {}
+
+  ansi-regex@5.0.1: {}
+
+  ansi-regex@6.2.2: {}
+
+  ansi-styles@4.3.0:
+    dependencies:
+      color-convert: 2.0.1
+
+  ansi-styles@6.2.3: {}
+
+  assertion-error@2.0.1: {}
+
+  asynckit@0.4.0: {}
+
+  balanced-match@1.0.2: {}
+
+  brace-expansion@2.1.1:
+    dependencies:
+      balanced-match: 1.0.2
+
+  cac@6.7.14: {}
+
+  call-bind-apply-helpers@1.0.2:
+    dependencies:
+      es-errors: 1.3.0
+      function-bind: 1.1.2
+
+  chai@5.3.3:
+    dependencies:
+      assertion-error: 2.0.1
+      check-error: 2.1.3
+      deep-eql: 5.0.2
+      loupe: 3.2.1
+      pathval: 2.0.1
+
+  check-error@2.1.3: {}
+
+  color-convert@2.0.1:
+    dependencies:
+      color-name: 1.1.4
+
+  color-name@1.1.4: {}
+
+  combined-stream@1.0.8:
+    dependencies:
+      delayed-stream: 1.0.0
+
+  commander@10.0.1: {}
+
+  config-chain@1.1.13:
+    dependencies:
+      ini: 1.3.8
+      proto-list: 1.2.4
+
+  cross-spawn@7.0.6:
+    dependencies:
+      path-key: 3.1.1
+      shebang-command: 2.0.0
+      which: 2.0.2
+
+  cssstyle@4.6.0:
+    dependencies:
+      '@asamuzakjp/css-color': 3.2.0
+      rrweb-cssom: 0.8.0
+
+  csstype@3.2.3: {}
+
+  data-urls@5.0.0:
+    dependencies:
+      whatwg-mimetype: 4.0.0
+      whatwg-url: 14.2.0
+
+  de-indent@1.0.2: {}
+
+  debug@4.4.3:
+    dependencies:
+      ms: 2.1.3
+
+  decimal.js@10.6.0: {}
+
+  deep-eql@5.0.2: {}
+
+  delayed-stream@1.0.0: {}
+
+  dunder-proto@1.0.1:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-errors: 1.3.0
+      gopd: 1.2.0
+
+  eastasianwidth@0.2.0: {}
+
+  editorconfig@1.0.7:
+    dependencies:
+      '@one-ini/wasm': 0.1.1
+      commander: 10.0.1
+      minimatch: 9.0.9
+      semver: 7.8.1
+
+  emoji-regex@8.0.0: {}
+
+  emoji-regex@9.2.2: {}
+
+  entities@6.0.1: {}
+
+  entities@7.0.1: {}
+
+  es-define-property@1.0.1: {}
+
+  es-errors@1.3.0: {}
+
+  es-module-lexer@1.7.0: {}
+
+  es-object-atoms@1.1.2:
+    dependencies:
+      es-errors: 1.3.0
+
+  es-set-tostringtag@2.1.0:
+    dependencies:
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      has-tostringtag: 1.0.2
+      hasown: 2.0.4
+
+  esbuild@0.21.5:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.21.5
+      '@esbuild/android-arm': 0.21.5
+      '@esbuild/android-arm64': 0.21.5
+      '@esbuild/android-x64': 0.21.5
+      '@esbuild/darwin-arm64': 0.21.5
+      '@esbuild/darwin-x64': 0.21.5
+      '@esbuild/freebsd-arm64': 0.21.5
+      '@esbuild/freebsd-x64': 0.21.5
+      '@esbuild/linux-arm': 0.21.5
+      '@esbuild/linux-arm64': 0.21.5
+      '@esbuild/linux-ia32': 0.21.5
+      '@esbuild/linux-loong64': 0.21.5
+      '@esbuild/linux-mips64el': 0.21.5
+      '@esbuild/linux-ppc64': 0.21.5
+      '@esbuild/linux-riscv64': 0.21.5
+      '@esbuild/linux-s390x': 0.21.5
+      '@esbuild/linux-x64': 0.21.5
+      '@esbuild/netbsd-x64': 0.21.5
+      '@esbuild/openbsd-x64': 0.21.5
+      '@esbuild/sunos-x64': 0.21.5
+      '@esbuild/win32-arm64': 0.21.5
+      '@esbuild/win32-ia32': 0.21.5
+      '@esbuild/win32-x64': 0.21.5
+
+  esbuild@0.25.12:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.25.12
+      '@esbuild/android-arm': 0.25.12
+      '@esbuild/android-arm64': 0.25.12
+      '@esbuild/android-x64': 0.25.12
+      '@esbuild/darwin-arm64': 0.25.12
+      '@esbuild/darwin-x64': 0.25.12
+      '@esbuild/freebsd-arm64': 0.25.12
+      '@esbuild/freebsd-x64': 0.25.12
+      '@esbuild/linux-arm': 0.25.12
+      '@esbuild/linux-arm64': 0.25.12
+      '@esbuild/linux-ia32': 0.25.12
+      '@esbuild/linux-loong64': 0.25.12
+      '@esbuild/linux-mips64el': 0.25.12
+      '@esbuild/linux-ppc64': 0.25.12
+      '@esbuild/linux-riscv64': 0.25.12
+      '@esbuild/linux-s390x': 0.25.12
+      '@esbuild/linux-x64': 0.25.12
+      '@esbuild/netbsd-arm64': 0.25.12
+      '@esbuild/netbsd-x64': 0.25.12
+      '@esbuild/openbsd-arm64': 0.25.12
+      '@esbuild/openbsd-x64': 0.25.12
+      '@esbuild/openharmony-arm64': 0.25.12
+      '@esbuild/sunos-x64': 0.25.12
+      '@esbuild/win32-arm64': 0.25.12
+      '@esbuild/win32-ia32': 0.25.12
+      '@esbuild/win32-x64': 0.25.12
+
+  estree-walker@2.0.2: {}
+
+  estree-walker@3.0.3:
+    dependencies:
+      '@types/estree': 1.0.9
+
+  expect-type@1.3.0: {}
+
+  fdir@6.5.0(picomatch@4.0.4):
+    optionalDependencies:
+      picomatch: 4.0.4
+
+  foreground-child@3.3.1:
+    dependencies:
+      cross-spawn: 7.0.6
+      signal-exit: 4.1.0
+
+  form-data@4.0.5:
+    dependencies:
+      asynckit: 0.4.0
+      combined-stream: 1.0.8
+      es-set-tostringtag: 2.1.0
+      hasown: 2.0.4
+      mime-types: 2.1.35
+
+  fsevents@2.3.2:
+    optional: true
+
+  fsevents@2.3.3:
+    optional: true
+
+  function-bind@1.1.2: {}
+
+  get-intrinsic@1.3.0:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-define-property: 1.0.1
+      es-errors: 1.3.0
+      es-object-atoms: 1.1.2
+      function-bind: 1.1.2
+      get-proto: 1.0.1
+      gopd: 1.2.0
+      has-symbols: 1.1.0
+      hasown: 2.0.4
+      math-intrinsics: 1.1.0
+
+  get-proto@1.0.1:
+    dependencies:
+      dunder-proto: 1.0.1
+      es-object-atoms: 1.1.2
+
+  glob@10.4.5:
+    dependencies:
+      foreground-child: 3.3.1
+      jackspeak: 3.4.3
+      minimatch: 9.0.9
+      minipass: 7.1.3
+      package-json-from-dist: 1.0.1
+      path-scurry: 1.11.1
+
+  gopd@1.2.0: {}
+
+  has-symbols@1.1.0: {}
+
+  has-tostringtag@1.0.2:
+    dependencies:
+      has-symbols: 1.1.0
+
+  hasown@2.0.4:
+    dependencies:
+      function-bind: 1.1.2
+
+  he@1.2.0: {}
+
+  html-encoding-sniffer@4.0.0:
+    dependencies:
+      whatwg-encoding: 3.1.1
+
+  http-proxy-agent@7.0.2:
+    dependencies:
+      agent-base: 7.1.4
+      debug: 4.4.3
+    transitivePeerDependencies:
+      - supports-color
+
+  https-proxy-agent@7.0.6:
+    dependencies:
+      agent-base: 7.1.4
+      debug: 4.4.3
+    transitivePeerDependencies:
+      - supports-color
+
+  iconv-lite@0.6.3:
+    dependencies:
+      safer-buffer: 2.1.2
+
+  ini@1.3.8: {}
+
+  is-fullwidth-code-point@3.0.0: {}
+
+  is-potential-custom-element-name@1.0.1: {}
+
+  isexe@2.0.0: {}
+
+  jackspeak@3.4.3:
+    dependencies:
+      '@isaacs/cliui': 8.0.2
+    optionalDependencies:
+      '@pkgjs/parseargs': 0.11.0
+
+  js-beautify@1.15.4:
+    dependencies:
+      config-chain: 1.1.13
+      editorconfig: 1.0.7
+      glob: 10.4.5
+      js-cookie: 3.0.8
+      nopt: 7.2.1
+
+  js-cookie@3.0.8: {}
+
+  jsdom@25.0.1:
+    dependencies:
+      cssstyle: 4.6.0
+      data-urls: 5.0.0
+      decimal.js: 10.6.0
+      form-data: 4.0.5
+      html-encoding-sniffer: 4.0.0
+      http-proxy-agent: 7.0.2
+      https-proxy-agent: 7.0.6
+      is-potential-custom-element-name: 1.0.1
+      nwsapi: 2.2.23
+      parse5: 7.3.0
+      rrweb-cssom: 0.7.1
+      saxes: 6.0.0
+      symbol-tree: 3.2.4
+      tough-cookie: 5.1.2
+      w3c-xmlserializer: 5.0.0
+      webidl-conversions: 7.0.0
+      whatwg-encoding: 3.1.1
+      whatwg-mimetype: 4.0.0
+      whatwg-url: 14.2.0
+      ws: 8.21.0
+      xml-name-validator: 5.0.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  loupe@3.2.1: {}
+
+  lru-cache@10.4.3: {}
+
+  magic-string@0.30.21:
+    dependencies:
+      '@jridgewell/sourcemap-codec': 1.5.5
+
+  math-intrinsics@1.1.0: {}
+
+  mime-db@1.52.0: {}
+
+  mime-types@2.1.35:
+    dependencies:
+      mime-db: 1.52.0
+
+  minimatch@9.0.9:
+    dependencies:
+      brace-expansion: 2.1.1
+
+  minipass@7.1.3: {}
+
+  ms@2.1.3: {}
+
+  muggle-string@0.4.1: {}
+
+  nanoid@3.3.12: {}
+
+  nopt@7.2.1:
+    dependencies:
+      abbrev: 2.0.0
+
+  nwsapi@2.2.23: {}
+
+  package-json-from-dist@1.0.1: {}
+
+  parse5@7.3.0:
+    dependencies:
+      entities: 6.0.1
+
+  path-browserify@1.0.1: {}
+
+  path-key@3.1.1: {}
+
+  path-scurry@1.11.1:
+    dependencies:
+      lru-cache: 10.4.3
+      minipass: 7.1.3
+
+  pathe@1.1.2: {}
+
+  pathval@2.0.1: {}
+
+  picocolors@1.1.1: {}
+
+  picomatch@4.0.4: {}
+
+  pinia@2.3.1(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3)):
+    dependencies:
+      '@vue/devtools-api': 6.6.4
+      vue: 3.5.35(typescript@5.9.3)
+      vue-demi: 0.14.10(vue@3.5.35(typescript@5.9.3))
+    optionalDependencies:
+      typescript: 5.9.3
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+
+  playwright-core@1.60.0: {}
+
+  playwright@1.60.0:
+    dependencies:
+      playwright-core: 1.60.0
+    optionalDependencies:
+      fsevents: 2.3.2
+
+  postcss@8.5.15:
+    dependencies:
+      nanoid: 3.3.12
+      picocolors: 1.1.1
+      source-map-js: 1.2.1
+
+  proto-list@1.2.4: {}
+
+  punycode@2.3.1: {}
+
+  rollup@4.61.0:
+    dependencies:
+      '@types/estree': 1.0.9
+    optionalDependencies:
+      '@rollup/rollup-android-arm-eabi': 4.61.0
+      '@rollup/rollup-android-arm64': 4.61.0
+      '@rollup/rollup-darwin-arm64': 4.61.0
+      '@rollup/rollup-darwin-x64': 4.61.0
+      '@rollup/rollup-freebsd-arm64': 4.61.0
+      '@rollup/rollup-freebsd-x64': 4.61.0
+      '@rollup/rollup-linux-arm-gnueabihf': 4.61.0
+      '@rollup/rollup-linux-arm-musleabihf': 4.61.0
+      '@rollup/rollup-linux-arm64-gnu': 4.61.0
+      '@rollup/rollup-linux-arm64-musl': 4.61.0
+      '@rollup/rollup-linux-loong64-gnu': 4.61.0
+      '@rollup/rollup-linux-loong64-musl': 4.61.0
+      '@rollup/rollup-linux-ppc64-gnu': 4.61.0
+      '@rollup/rollup-linux-ppc64-musl': 4.61.0
+      '@rollup/rollup-linux-riscv64-gnu': 4.61.0
+      '@rollup/rollup-linux-riscv64-musl': 4.61.0
+      '@rollup/rollup-linux-s390x-gnu': 4.61.0
+      '@rollup/rollup-linux-x64-gnu': 4.61.0
+      '@rollup/rollup-linux-x64-musl': 4.61.0
+      '@rollup/rollup-openbsd-x64': 4.61.0
+      '@rollup/rollup-openharmony-arm64': 4.61.0
+      '@rollup/rollup-win32-arm64-msvc': 4.61.0
+      '@rollup/rollup-win32-ia32-msvc': 4.61.0
+      '@rollup/rollup-win32-x64-gnu': 4.61.0
+      '@rollup/rollup-win32-x64-msvc': 4.61.0
+      fsevents: 2.3.3
+
+  rrweb-cssom@0.7.1: {}
+
+  rrweb-cssom@0.8.0: {}
+
+  safer-buffer@2.1.2: {}
+
+  saxes@6.0.0:
+    dependencies:
+      xmlchars: 2.2.0
+
+  semver@7.8.1: {}
+
+  shebang-command@2.0.0:
+    dependencies:
+      shebang-regex: 3.0.0
+
+  shebang-regex@3.0.0: {}
+
+  siginfo@2.0.0: {}
+
+  signal-exit@4.1.0: {}
+
+  source-map-js@1.2.1: {}
+
+  stackback@0.0.2: {}
+
+  std-env@3.10.0: {}
+
+  string-width@4.2.3:
+    dependencies:
+      emoji-regex: 8.0.0
+      is-fullwidth-code-point: 3.0.0
+      strip-ansi: 6.0.1
+
+  string-width@5.1.2:
+    dependencies:
+      eastasianwidth: 0.2.0
+      emoji-regex: 9.2.2
+      strip-ansi: 7.2.0
+
+  strip-ansi@6.0.1:
+    dependencies:
+      ansi-regex: 5.0.1
+
+  strip-ansi@7.2.0:
+    dependencies:
+      ansi-regex: 6.2.2
+
+  symbol-tree@3.2.4: {}
+
+  tinybench@2.9.0: {}
+
+  tinyexec@0.3.2: {}
+
+  tinyglobby@0.2.17:
+    dependencies:
+      fdir: 6.5.0(picomatch@4.0.4)
+      picomatch: 4.0.4
+
+  tinypool@1.1.1: {}
+
+  tinyrainbow@1.2.0: {}
+
+  tinyspy@3.0.2: {}
+
+  tldts-core@6.1.86: {}
+
+  tldts@6.1.86:
+    dependencies:
+      tldts-core: 6.1.86
+
+  tough-cookie@5.1.2:
+    dependencies:
+      tldts: 6.1.86
+
+  tr46@5.1.1:
+    dependencies:
+      punycode: 2.3.1
+
+  typescript@5.9.3: {}
+
+  undici-types@6.21.0: {}
+
+  vite-node@2.1.9(@types/node@22.19.19):
+    dependencies:
+      cac: 6.7.14
+      debug: 4.4.3
+      es-module-lexer: 1.7.0
+      pathe: 1.1.2
+      vite: 5.4.21(@types/node@22.19.19)
+    transitivePeerDependencies:
+      - '@types/node'
+      - less
+      - lightningcss
+      - sass
+      - sass-embedded
+      - stylus
+      - sugarss
+      - supports-color
+      - terser
+
+  vite@5.4.21(@types/node@22.19.19):
+    dependencies:
+      esbuild: 0.21.5
+      postcss: 8.5.15
+      rollup: 4.61.0
+    optionalDependencies:
+      '@types/node': 22.19.19
+      fsevents: 2.3.3
+
+  vite@6.4.3(@types/node@22.19.19):
+    dependencies:
+      esbuild: 0.25.12
+      fdir: 6.5.0(picomatch@4.0.4)
+      picomatch: 4.0.4
+      postcss: 8.5.15
+      rollup: 4.61.0
+      tinyglobby: 0.2.17
+    optionalDependencies:
+      '@types/node': 22.19.19
+      fsevents: 2.3.3
+
+  vitest@2.1.9(@types/node@22.19.19)(jsdom@25.0.1):
+    dependencies:
+      '@vitest/expect': 2.1.9
+      '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19))
+      '@vitest/pretty-format': 2.1.9
+      '@vitest/runner': 2.1.9
+      '@vitest/snapshot': 2.1.9
+      '@vitest/spy': 2.1.9
+      '@vitest/utils': 2.1.9
+      chai: 5.3.3
+      debug: 4.4.3
+      expect-type: 1.3.0
+      magic-string: 0.30.21
+      pathe: 1.1.2
+      std-env: 3.10.0
+      tinybench: 2.9.0
+      tinyexec: 0.3.2
+      tinypool: 1.1.1
+      tinyrainbow: 1.2.0
+      vite: 5.4.21(@types/node@22.19.19)
+      vite-node: 2.1.9(@types/node@22.19.19)
+      why-is-node-running: 2.3.0
+    optionalDependencies:
+      '@types/node': 22.19.19
+      jsdom: 25.0.1
+    transitivePeerDependencies:
+      - less
+      - lightningcss
+      - msw
+      - sass
+      - sass-embedded
+      - stylus
+      - sugarss
+      - supports-color
+      - terser
+
+  vscode-uri@3.1.0: {}
+
+  vue-component-type-helpers@3.3.3: {}
+
+  vue-demi@0.14.10(vue@3.5.35(typescript@5.9.3)):
+    dependencies:
+      vue: 3.5.35(typescript@5.9.3)
+
+  vue-tsc@2.2.12(typescript@5.9.3):
+    dependencies:
+      '@volar/typescript': 2.4.15
+      '@vue/language-core': 2.2.12(typescript@5.9.3)
+      typescript: 5.9.3
+
+  vue@3.5.35(typescript@5.9.3):
+    dependencies:
+      '@vue/compiler-dom': 3.5.35
+      '@vue/compiler-sfc': 3.5.35
+      '@vue/runtime-dom': 3.5.35
+      '@vue/server-renderer': 3.5.35(vue@3.5.35(typescript@5.9.3))
+      '@vue/shared': 3.5.35
+    optionalDependencies:
+      typescript: 5.9.3
+
+  w3c-xmlserializer@5.0.0:
+    dependencies:
+      xml-name-validator: 5.0.0
+
+  webidl-conversions@7.0.0: {}
+
+  whatwg-encoding@3.1.1:
+    dependencies:
+      iconv-lite: 0.6.3
+
+  whatwg-mimetype@4.0.0: {}
+
+  whatwg-url@14.2.0:
+    dependencies:
+      tr46: 5.1.1
+      webidl-conversions: 7.0.0
+
+  which@2.0.2:
+    dependencies:
+      isexe: 2.0.0
+
+  why-is-node-running@2.3.0:
+    dependencies:
+      siginfo: 2.0.0
+      stackback: 0.0.2
+
+  wrap-ansi@7.0.0:
+    dependencies:
+      ansi-styles: 4.3.0
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+
+  wrap-ansi@8.1.0:
+    dependencies:
+      ansi-styles: 6.2.3
+      string-width: 5.1.2
+      strip-ansi: 7.2.0
+
+  ws@8.21.0: {}
+
+  xml-name-validator@5.0.0: {}
+
+  xmlchars@2.2.0: {}

+ 7 - 0
src/App.vue

@@ -0,0 +1,7 @@
+<template>
+  <AppShell />
+</template>
+
+<script setup lang="ts">
+import AppShell from './layouts/AppShell.vue';
+</script>

+ 49 - 0
src/api/client.ts

@@ -0,0 +1,49 @@
+import { demoCards } from './demoFixtures';
+import { buildDemoAuthHeaders } from './hmac';
+import type { CardInstance } from './types';
+
+const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api/v1';
+const DEMO_MODE = import.meta.env.VITE_DEMO_MODE !== 'false';
+
+export async function fetchCard(cardInstanceId: string): Promise<CardInstance> {
+  if (DEMO_MODE) {
+    const card = demoCards[cardInstanceId];
+    if (!card) throw new Error(`演示卡片不存在: ${cardInstanceId}`);
+    return card;
+  }
+
+  const path = `/cards/${cardInstanceId}`;
+  const headers = await buildDemoAuthHeaders('GET', `/api/v1${path}`, '');
+  const response = await fetch(`${API_BASE}${path}`, { headers });
+  const body = await response.json();
+  if (!response.ok || body.code !== 200) throw new Error(body.msg ?? '查询卡片失败');
+  return body.data;
+}
+
+export async function submitCardAction(
+  cardInstanceId: string,
+  actionName: string,
+  payload: Record<string, unknown>,
+  confirm = true
+) {
+  const body = JSON.stringify({
+    idempotencyKey: `${cardInstanceId}-${actionName}-${JSON.stringify(payload)}`,
+    confirm,
+    payload
+  });
+
+  if (DEMO_MODE) {
+    return { status: 'completed', actionId: `action_${Date.now()}` };
+  }
+
+  const path = `/cards/${cardInstanceId}/actions/${actionName}`;
+  const headers = await buildDemoAuthHeaders('POST', `/api/v1${path}`, body);
+  const response = await fetch(`${API_BASE}${path}`, {
+    method: 'POST',
+    headers: { ...headers, 'Content-Type': 'application/json' },
+    body
+  });
+  const data = await response.json();
+  if (!response.ok || data.code !== 200) throw new Error(data.msg ?? '提交卡片动作失败');
+  return data.data;
+}

+ 40 - 0
src/api/demoFixtures.ts

@@ -0,0 +1,40 @@
+import type { CardInstance } from './types';
+
+export const demoCards: Record<string, CardInstance> = {
+  card_department: {
+    cardInstanceId: 'card_department',
+    cardKey: 'department-selection',
+    status: 'active',
+    cardData: {
+      departments: [
+        { departmentId: 'neurology', name: '神经内科', reason: '头痛伴恶心,建议优先排查神经系统相关问题。' },
+        { departmentId: 'general_internal', name: '普通内科', reason: '可作为综合初筛科室。' }
+      ]
+    },
+    actions: [{ actionName: 'select_department', label: '选择科室' }]
+  },
+  card_doctor: {
+    cardInstanceId: 'card_doctor',
+    cardKey: 'doctor-selection',
+    status: 'active',
+    cardData: {
+      doctors: [
+        { doctorId: 'DOC001', name: '李明', title: '主任医师', specialty: '头痛、眩晕、脑血管病', room: '门诊三楼 302' },
+        { doctorId: 'DOC002', name: '王佳', title: '副主任医师', specialty: '慢性头痛、神经系统常见病', room: '门诊三楼 306' }
+      ]
+    },
+    actions: [{ actionName: 'select_doctor', label: '选择医生' }]
+  },
+  card_slot: {
+    cardInstanceId: 'card_slot',
+    cardKey: 'time-slot-selection',
+    status: 'active',
+    cardData: {
+      slots: [
+        { slotId: 'slot_0930', timePeriod: '明天 09:30-09:45', remaining: 3 },
+        { slotId: 'slot_0945', timePeriod: '明天 09:45-10:00', remaining: 2 }
+      ]
+    },
+    actions: [{ actionName: 'select_time_slot', label: '选择时间' }]
+  }
+};

+ 19 - 0
src/api/hmac.ts

@@ -0,0 +1,19 @@
+export async function sha256Hex(data: string): Promise<string> {
+  const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data));
+  return Array.from(new Uint8Array(hashBuffer))
+    .map((value) => value.toString(16).padStart(2, '0'))
+    .join('');
+}
+
+export async function buildDemoAuthHeaders(method: string, path: string, body: string): Promise<Record<string, string>> {
+  const timestamp = String(Date.now());
+  const nonce = crypto.randomUUID();
+  const bodyHash = await sha256Hex(body);
+
+  return {
+    'X-Emoon-Access-Key': 'pk-web-demo',
+    'X-Emoon-Timestamp': timestamp,
+    'X-Emoon-Nonce': nonce,
+    'X-Emoon-Signature': `demo-signature:${method}:${path}:${bodyHash}`
+  };
+}

+ 28 - 0
src/api/sse.ts

@@ -0,0 +1,28 @@
+import type { SseEventType } from './types';
+
+export interface ParsedSseEvent {
+  type: SseEventType;
+  data: Record<string, unknown>;
+}
+
+export function parseSseChunk(previous: string, chunk: string): { events: ParsedSseEvent[]; remainder: string } {
+  const buffer = previous + chunk;
+  const rawEvents = buffer.split('\n\n');
+  const remainder = rawEvents.pop() ?? '';
+  const events: ParsedSseEvent[] = [];
+
+  for (const raw of rawEvents) {
+    const eventMatch = raw.match(/^event:\s*(.+)$/m);
+    const dataMatch = raw.match(/^data:\s*(.+)$/m);
+    if (!eventMatch || !dataMatch) continue;
+
+    const type = eventMatch[1].trim() as SseEventType;
+    try {
+      events.push({ type, data: JSON.parse(dataMatch[1]) });
+    } catch {
+      events.push({ type: 'error', data: { message: 'SSE 数据解析失败' } });
+    }
+  }
+
+  return { events, remainder };
+}

+ 45 - 0
src/api/types.ts

@@ -0,0 +1,45 @@
+export type SseEventType =
+  | 'task_updated'
+  | 'message_delta'
+  | 'message_completed'
+  | 'card_created'
+  | 'error'
+  | 'completed';
+
+export interface ChatMessage {
+  id: string;
+  role: 'user' | 'assistant' | 'system';
+  content: string;
+  streaming?: boolean;
+}
+
+export interface CardAction {
+  actionName: string;
+  label: string;
+  requiredConfirm?: boolean;
+}
+
+export interface CardInstance {
+  cardInstanceId: string;
+  cardKey:
+    | 'department-selection'
+    | 'doctor-selection'
+    | 'time-slot-selection'
+    | 'confirm-appointment'
+    | 'payment-qrcode'
+    | 'appointment-success'
+    | 'route-card'
+    | 'tongue-capture'
+    | string;
+  status: 'active' | 'submitted' | 'processing' | 'completed' | 'expired' | 'failed';
+  expiresAt?: string;
+  cardData: Record<string, unknown>;
+  actions: CardAction[];
+}
+
+export interface TaskState {
+  taskId: string;
+  taskType: 'REGISTRATION' | 'GUIDE' | 'TRIAGE' | 'TONGUE_DIAGNOSIS' | string;
+  currentStep: string;
+  status: 'ACTIVE' | 'WAITING_CARD_ACTION' | 'PROCESSING' | 'COMPLETED' | 'CANCELLED' | 'FAILED' | string;
+}

+ 24 - 0
src/assets/doctor-assistant.svg

@@ -0,0 +1,24 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 280" fill="none">
+  <circle cx="140" cy="140" r="138" fill="url(#bg)" stroke="#2b1f99" stroke-width="2"/>
+  <defs>
+    <radialGradient id="bg" cx="40%" cy="30%">
+      <stop offset="0%" stop-color="rgba(58,212,216,0.2)"/>
+      <stop offset="42%" stop-color="#fff"/>
+      <stop offset="100%" stop-color="#eef8ff"/>
+    </radialGradient>
+  </defs>
+  <!-- face -->
+  <circle cx="140" cy="115" r="40" fill="#ffe4c4"/>
+  <!-- eyes -->
+  <circle cx="128" cy="108" r="5" fill="#2b1f99"/>
+  <circle cx="152" cy="108" r="5" fill="#2b1f99"/>
+  <!-- smile -->
+  <path d="M128 125 Q140 138 152 125" stroke="#2b1f99" stroke-width="2.5" fill="none" stroke-linecap="round"/>
+  <!-- body -->
+  <rect x="95" y="155" width="90" height="75" rx="8" fill="#fff" stroke="#2b1f99" stroke-width="1.5"/>
+  <!-- collar -->
+  <path d="M125 155 L140 172 L155 155" stroke="#3ad4d8" stroke-width="2" fill="none"/>
+  <!-- stethoscope -->
+  <path d="M180 170 Q200 165 200 145 Q200 128 188 128" stroke="#3ad4d8" stroke-width="3" fill="none" stroke-linecap="round"/>
+  <circle cx="188" cy="128" r="8" stroke="#3ad4d8" stroke-width="2.5"/>
+</svg>

+ 28 - 0
src/cards/AppointmentSuccessCard.vue

@@ -0,0 +1,28 @@
+<template>
+  <section class="business-card success">
+    <div class="mock-label" v-if="card.cardData.mock">联调演示</div>
+    <h2>挂号成功</h2>
+    <strong class="appointment-no">{{ card.cardData.appointmentNo }}</strong>
+    <p>{{ card.cardData.departmentName }} · {{ card.cardData.doctorName }}</p>
+    <p>{{ card.cardData.visitTime }}</p>
+    <p>{{ card.cardData.room }}</p>
+    <button @click="$emit('finish')">完成</button>
+  </section>
+</template>
+
+<script setup lang="ts">
+import type { CardInstance } from '../api/types';
+
+defineProps<{ card: CardInstance }>();
+defineEmits<{ finish: [] }>();
+</script>
+
+<style scoped>
+.business-card { padding: 16px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); text-align: center; }
+.success { border-color: rgba(58, 212, 216, 0.8); background: var(--soft-tint); }
+.mock-label { display: inline-flex; padding: 6px 10px; border-radius: 999px; background: var(--warning-bg); color: var(--warning-text); font-size: 12px; font-weight: 900; }
+h2 { margin: 14px 0 8px; color: var(--brand-primary); }
+.appointment-no { display: block; margin: 12px 0; font-size: 34px; color: var(--brand-primary); }
+p { color: var(--text-secondary); font-size: 13px; }
+button { width: 100%; border: 0; border-radius: 999px; padding: 11px 12px; background: var(--brand-primary); color: #fff; font-weight: 900; cursor: pointer; }
+</style>

+ 23 - 0
src/cards/CardRenderer.vue

@@ -0,0 +1,23 @@
+<template>
+  <DepartmentSelectionCard v-if="card.cardKey === 'department-selection'" :card="card" @action="emit('action', $event)" />
+  <DoctorSelectionCard v-else-if="card.cardKey === 'doctor-selection'" :card="card" @action="emit('action', $event)" />
+  <TimeSlotSelectionCard v-else-if="card.cardKey === 'time-slot-selection'" :card="card" @action="emit('action', $event)" />
+  <ConfirmAppointmentCard v-else-if="card.cardKey === 'confirm-appointment'" :card="card" @action="emit('action', $event)" />
+  <PaymentQrCard v-else-if="card.cardKey === 'payment-qrcode'" :card="card" @action="emit('action', $event)" />
+  <AppointmentSuccessCard v-else-if="card.cardKey === 'appointment-success'" :card="card" @finish="emit('finish')" />
+  <ErrorCard v-else message="暂不支持的卡片类型" :trace-id="card.cardInstanceId" />
+</template>
+
+<script setup lang="ts">
+import type { CardInstance } from '../api/types';
+import AppointmentSuccessCard from './AppointmentSuccessCard.vue';
+import ConfirmAppointmentCard from './ConfirmAppointmentCard.vue';
+import DepartmentSelectionCard from './DepartmentSelectionCard.vue';
+import DoctorSelectionCard from './DoctorSelectionCard.vue';
+import ErrorCard from './ErrorCard.vue';
+import PaymentQrCard from './PaymentQrCard.vue';
+import TimeSlotSelectionCard from './TimeSlotSelectionCard.vue';
+
+defineProps<{ card: CardInstance }>();
+const emit = defineEmits<{ action: [actionName: string]; finish: [] }>();
+</script>

+ 41 - 0
src/cards/ConfirmAppointmentCard.vue

@@ -0,0 +1,41 @@
+<template>
+  <section class="business-card">
+    <h2>确认挂号信息</h2>
+    <dl>
+      <div><dt>科室</dt><dd>{{ summary.departmentName }}</dd></div>
+      <div><dt>医生</dt><dd>{{ summary.doctorName }}</dd></div>
+      <div><dt>时间</dt><dd>{{ summary.visitTime }}</dd></div>
+      <div><dt>费用</dt><dd>{{ summary.amount }} 元</dd></div>
+    </dl>
+    <p class="notice">确认后将生成 Mock 支付订单。</p>
+    <button @click="$emit('action', 'confirm_appointment')">确认挂号</button>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import type { CardInstance } from '../api/types';
+
+const props = defineProps<{ card: CardInstance }>();
+defineEmits<{ action: [actionName: string] }>();
+
+interface Summary {
+  departmentName?: string;
+  doctorName?: string;
+  visitTime?: string;
+  amount?: number;
+}
+
+const summary = computed(() => (props.card.cardData.summary as Summary) ?? {});
+</script>
+
+<style scoped>
+.business-card { padding: 16px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); }
+h2 { margin: 0 0 12px; font-size: 18px; }
+dl { display: grid; gap: 10px; margin: 0; }
+dl div { display: flex; justify-content: space-between; gap: 12px; padding: 10px; border-radius: 10px; background: #f8fbff; }
+dt { color: var(--text-secondary); }
+dd { margin: 0; font-weight: 800; text-align: right; }
+.notice { padding: 10px; border-radius: 10px; background: var(--warning-bg); color: var(--warning-text); font-size: 12px; }
+button { width: 100%; border: 0; border-radius: 999px; padding: 11px 12px; background: var(--brand-primary); color: #fff; font-weight: 900; cursor: pointer; }
+</style>

+ 72 - 0
src/cards/DepartmentSelectionCard.vue

@@ -0,0 +1,72 @@
+<template>
+  <section class="business-card">
+    <h2>推荐科室</h2>
+    <article v-for="dept in departments" :key="dept.departmentId" class="option">
+      <h3>{{ dept.name }}</h3>
+      <p>{{ dept.reason }}</p>
+      <button @click="$emit('action', 'select_department')">选择{{ dept.name }}</button>
+    </article>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import type { CardInstance } from '../api/types';
+
+const props = defineProps<{ card: CardInstance }>();
+defineEmits<{ action: [actionName: string] }>();
+
+interface DepartmentOption {
+  departmentId: string;
+  name: string;
+  reason: string;
+}
+
+const departments = computed(() => (props.card.cardData.departments as DepartmentOption[]) ?? []);
+</script>
+
+<style scoped>
+.business-card {
+  min-height: 0;
+  overflow: auto;
+  padding: 16px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-panel);
+  background: var(--surface);
+}
+
+h2 {
+  margin: 0 0 12px;
+  font-size: 18px;
+}
+
+.option {
+  padding: 12px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-card);
+  margin-bottom: 10px;
+  background: var(--soft-tint);
+}
+
+h3 {
+  margin: 0;
+  color: var(--brand-primary);
+}
+
+p {
+  color: var(--text-secondary);
+  font-size: 12px;
+  line-height: 1.6;
+}
+
+button {
+  width: 100%;
+  border: 0;
+  border-radius: 999px;
+  padding: 9px 12px;
+  background: var(--brand-primary);
+  color: #ffffff;
+  font-weight: 900;
+  cursor: pointer;
+}
+</style>

+ 39 - 0
src/cards/DoctorSelectionCard.vue

@@ -0,0 +1,39 @@
+<template>
+  <section class="business-card">
+    <h2>选择医生</h2>
+    <article v-for="doctor in doctors" :key="doctor.doctorId" class="option">
+      <h3>{{ doctor.name }} <small>{{ doctor.title }}</small></h3>
+      <p>{{ doctor.specialty }}</p>
+      <p>{{ doctor.room }}</p>
+      <button @click="$emit('action', 'select_doctor')">选择{{ doctor.name }}</button>
+    </article>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import type { CardInstance } from '../api/types';
+
+const props = defineProps<{ card: CardInstance }>();
+defineEmits<{ action: [actionName: string] }>();
+
+interface DoctorOption {
+  doctorId: string;
+  name: string;
+  title: string;
+  specialty: string;
+  room: string;
+}
+
+const doctors = computed(() => (props.card.cardData.doctors as DoctorOption[]) ?? []);
+</script>
+
+<style scoped>
+.business-card { min-height: 0; overflow: auto; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); }
+h2 { margin: 0 0 12px; font-size: 18px; }
+.option { padding: 12px; border: 1px solid var(--border); border-radius: var(--radius-card); margin-bottom: 10px; background: var(--surface); }
+h3 { margin: 0; color: var(--brand-primary); }
+small { color: var(--text-secondary); font-size: 12px; }
+p { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
+button { width: 100%; border: 0; border-radius: 999px; padding: 9px 12px; background: var(--brand-primary); color: #fff; font-weight: 900; cursor: pointer; }
+</style>

+ 21 - 0
src/cards/ErrorCard.vue

@@ -0,0 +1,21 @@
+<template>
+  <section class="error-card">
+    <h2>处理异常</h2>
+    <p>{{ message }}</p>
+    <small v-if="traceId">traceId: {{ traceId }}</small>
+    <button @click="$emit('restart')">重新开始</button>
+  </section>
+</template>
+
+<script setup lang="ts">
+defineProps<{ message: string; traceId?: string }>();
+defineEmits<{ restart: [] }>();
+</script>
+
+<style scoped>
+.error-card { padding: 16px; border: 1px solid #f0d89c; border-radius: var(--radius-panel); background: var(--warning-bg); color: var(--warning-text); }
+h2 { margin: 0 0 10px; }
+p { line-height: 1.6; }
+small { display: block; margin: 12px 0; word-break: break-all; }
+button { width: 100%; border: 0; border-radius: 999px; padding: 10px 12px; background: var(--brand-primary); color: #fff; font-weight: 900; cursor: pointer; }
+</style>

+ 29 - 0
src/cards/PaymentQrCard.vue

@@ -0,0 +1,29 @@
+<template>
+  <section class="business-card">
+    <div class="mock-label">联调演示 / Mock 支付</div>
+    <h2>Mock 支付</h2>
+    <div class="qr">{{ qrText }}</div>
+    <p>订单号:{{ card.cardData.orderId }}</p>
+    <p>金额:{{ card.cardData.amount }} 元</p>
+    <button @click="$emit('action', 'mock_payment_paid')">模拟支付完成</button>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import type { CardInstance } from '../api/types';
+
+const props = defineProps<{ card: CardInstance }>();
+defineEmits<{ action: [actionName: string] }>();
+
+const qrText = computed(() => String(props.card.cardData.qrContent ?? 'demo-payment://orderId=unknown'));
+</script>
+
+<style scoped>
+.business-card { padding: 16px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); text-align: center; }
+.mock-label { display: inline-flex; padding: 6px 10px; border-radius: 999px; background: var(--warning-bg); color: var(--warning-text); font-size: 12px; font-weight: 900; }
+h2 { margin: 14px 0 12px; font-size: 18px; }
+.qr { display: grid; min-height: 128px; place-items: center; padding: 12px; border: 1px dashed var(--brand-primary); border-radius: 14px; background: var(--soft-tint); color: var(--brand-primary); font-size: 12px; word-break: break-all; }
+p { color: var(--text-secondary); font-size: 13px; }
+button { width: 100%; border: 0; border-radius: 999px; padding: 11px 12px; background: var(--brand-primary); color: #fff; font-weight: 900; cursor: pointer; }
+</style>

+ 35 - 0
src/cards/TimeSlotSelectionCard.vue

@@ -0,0 +1,35 @@
+<template>
+  <section class="business-card">
+    <h2>选择时间</h2>
+    <article v-for="slot in slots" :key="slot.slotId" class="option">
+      <h3>{{ slot.timePeriod }}</h3>
+      <p>剩余号源:{{ slot.remaining }}</p>
+      <button @click="$emit('action', 'select_time_slot')">选择{{ slot.timePeriod }}</button>
+    </article>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import type { CardInstance } from '../api/types';
+
+const props = defineProps<{ card: CardInstance }>();
+defineEmits<{ action: [actionName: string] }>();
+
+interface SlotOption {
+  slotId: string;
+  timePeriod: string;
+  remaining: number;
+}
+
+const slots = computed(() => (props.card.cardData.slots as SlotOption[]) ?? []);
+</script>
+
+<style scoped>
+.business-card { min-height: 0; overflow: auto; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); }
+h2 { margin: 0 0 12px; font-size: 18px; }
+.option { padding: 12px; border: 1px solid var(--border); border-radius: var(--radius-card); margin-bottom: 10px; background: var(--soft-tint); }
+h3 { margin: 0; color: var(--brand-primary); }
+p { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
+button { width: 100%; border: 0; border-radius: 999px; padding: 9px 12px; background: var(--brand-primary); color: #fff; font-weight: 900; cursor: pointer; }
+</style>

+ 71 - 0
src/chat/ChatInput.vue

@@ -0,0 +1,71 @@
+<template>
+  <form class="chat-input" @submit.prevent="submit">
+    <input v-model="store.inputText" placeholder="继续输入症状、科室、医生或时间..." />
+    <button type="button" class="voice" @click="showVoiceHint">语音</button>
+    <button type="submit" class="send" :disabled="!store.inputText.trim()">发送</button>
+  </form>
+</template>
+
+<script setup lang="ts">
+import { useTerminalStore } from '../state/terminalStore';
+
+const emit = defineEmits<{ submit: [text: string] }>();
+const store = useTerminalStore();
+
+function submit() {
+  const text = store.inputText.trim();
+  if (!text) return;
+  store.inputText = '';
+  emit('submit', text);
+}
+
+function showVoiceHint() {
+  store.addMessage({
+    id: crypto.randomUUID(),
+    role: 'system',
+    content: '语音能力将在后续版本接入,当前请使用文字输入。'
+  });
+}
+</script>
+
+<style scoped>
+.chat-input {
+  display: flex;
+  gap: 10px;
+  padding: 14px;
+  border-top: 1px solid #edf1f7;
+  background: var(--surface);
+}
+
+input {
+  flex: 1;
+  min-width: 0;
+  padding: 12px 14px;
+  border: 1px solid #dce7f7;
+  border-radius: 999px;
+  outline: none;
+}
+
+button {
+  border: 0;
+  border-radius: 999px;
+  padding: 0 18px;
+  font-weight: 900;
+  cursor: pointer;
+}
+
+.voice {
+  background: var(--brand-accent);
+  color: #10144a;
+}
+
+.send {
+  background: var(--brand-primary);
+  color: #ffffff;
+}
+
+.send:disabled {
+  cursor: not-allowed;
+  opacity: 0.45;
+}
+</style>

+ 101 - 0
src/chat/ConversationPanel.vue

@@ -0,0 +1,101 @@
+<template>
+  <main class="conversation-panel">
+    <header>
+      <div>
+        <h1>对话入口</h1>
+        <p>系统会识别意图并进入对应流程</p>
+      </div>
+      <span v-if="store.task" class="task-pill">{{ store.task.taskType }}</span>
+    </header>
+
+    <section class="messages" aria-label="对话消息">
+      <MessageBubble v-for="message in store.messages" :key="message.id" :message="message" />
+    </section>
+
+    <ChatInput @submit="handleSubmit" />
+  </main>
+</template>
+
+<script setup lang="ts">
+import { demoCards } from '../api/demoFixtures';
+import ChatInput from './ChatInput.vue';
+import MessageBubble from './MessageBubble.vue';
+import { useTerminalStore } from '../state/terminalStore';
+
+const store = useTerminalStore();
+
+if (store.messages.length === 0) {
+  store.addMessage({
+    id: 'welcome',
+    role: 'assistant',
+    content: '请直接告诉我你想办理什么,也可以描述症状。'
+  });
+}
+
+function handleSubmit(text: string) {
+  store.addMessage({ id: crypto.randomUUID(), role: 'user', content: text });
+  store.setTask({
+    taskId: 'task_demo_registration',
+    taskType: 'REGISTRATION',
+    currentStep: 'TRIAGE_RECOMMEND_DEPARTMENT',
+    status: 'ACTIVE'
+  });
+  store.addMessage({
+    id: crypto.randomUUID(),
+    role: 'assistant',
+    content: '已识别为挂号需求。根据描述,建议优先选择神经内科。'
+  });
+  store.setActiveCard(demoCards.card_department);
+}
+</script>
+
+<style scoped>
+.conversation-panel {
+  display: flex;
+  min-height: 0;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-panel);
+  background: var(--surface);
+}
+
+header {
+  display: flex;
+  justify-content: space-between;
+  padding: 16px;
+  border-bottom: 1px solid #edf1f7;
+}
+
+h1 {
+  margin: 0;
+  font-size: 22px;
+}
+
+p {
+  margin: 4px 0 0;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.task-pill {
+  align-self: flex-start;
+  padding: 7px 11px;
+  border-radius: 999px;
+  background: #f1f0ff;
+  color: var(--brand-primary);
+  font-size: 12px;
+  font-weight: 800;
+}
+
+.messages {
+  display: flex;
+  flex: 1;
+  min-height: 0;
+  flex-direction: column;
+  gap: 12px;
+  overflow-y: auto;
+  padding: 16px;
+  background: #fbfdff;
+}
+</style>

+ 34 - 0
src/chat/MessageBubble.vue

@@ -0,0 +1,34 @@
+<template>
+  <div class="bubble" :class="message.role">
+    {{ message.content }}
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { ChatMessage } from '../api/types';
+
+defineProps<{ message: ChatMessage }>();
+</script>
+
+<style scoped>
+.bubble {
+  max-width: 78%;
+  padding: 12px 14px;
+  border-radius: 14px;
+  line-height: 1.65;
+  word-break: break-word;
+}
+
+.assistant,
+.system {
+  align-self: flex-start;
+  border: 1px solid #e1e8f5;
+  background: #ffffff;
+}
+
+.user {
+  align-self: flex-end;
+  background: var(--brand-primary);
+  color: #ffffff;
+}
+</style>

+ 5 - 0
src/demo/demoMode.ts

@@ -0,0 +1,5 @@
+export const DEMO_MODE = import.meta.env.VITE_DEMO_MODE !== 'false';
+
+export function isDemoMode(): boolean {
+  return DEMO_MODE;
+}

+ 7 - 0
src/env.d.ts

@@ -0,0 +1,7 @@
+/// <reference types="vite/client" />
+
+declare module '*.vue' {
+  import type { DefineComponent } from 'vue';
+  const component: DefineComponent<object, object, unknown>;
+  export default component;
+}

+ 72 - 0
src/layouts/AppShell.vue

@@ -0,0 +1,72 @@
+<template>
+  <div class="app-shell">
+    <header class="header">
+      <div class="brand">
+        <span class="logo">医</span>
+        <strong>医梦门诊助手</strong>
+      </div>
+      <span class="env">Web Demo · 门诊大厅 · 联调演示</span>
+    </header>
+
+    <div class="workspace">
+      <AssistantContextPanel />
+      <ConversationPanel />
+      <ContextPanel />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import ConversationPanel from '../chat/ConversationPanel.vue';
+import AssistantContextPanel from './AssistantContextPanel.vue';
+import ContextPanel from './ContextPanel.vue';
+</script>
+
+<style scoped>
+.app-shell {
+  min-height: 100vh;
+  padding: 0;
+  background: var(--page-bg);
+}
+
+.header {
+  display: flex;
+  height: 58px;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 22px;
+  border-bottom: 1px solid #e7ebf5;
+  background: var(--surface);
+}
+
+.brand {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.logo {
+  display: grid;
+  width: 34px;
+  height: 34px;
+  place-items: center;
+  border-radius: 10px;
+  background: var(--brand-primary);
+  color: #ffffff;
+  font-weight: 900;
+}
+
+.env {
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.workspace {
+  display: grid;
+  grid-template-columns: 280px minmax(520px, 1fr) 390px;
+  gap: 16px;
+  height: calc(100vh - 58px);
+  min-height: 720px;
+  padding: 16px;
+}
+</style>

+ 126 - 0
src/layouts/AssistantContextPanel.vue

@@ -0,0 +1,126 @@
+<template>
+  <aside class="assistant-panel" aria-label="助手上下文">
+    <div class="identity">
+      <div class="avatar">AI</div>
+      <div>
+        <h2>你好,我在</h2>
+        <p>说出需求,我来判断下一步</p>
+      </div>
+    </div>
+
+    <section class="examples">
+      <p class="section-label">可以这样说</p>
+      <ul>
+        <li>"我头疼三天,想挂号"</li>
+        <li>"明天上午有神经内科的号吗"</li>
+        <li>"我想找李明医生"</li>
+      </ul>
+    </section>
+
+    <section class="capabilities">
+      <p class="section-label">当前可办理</p>
+      <div class="chips" aria-label="能力提示">
+        <span>挂号</span>
+        <span>导诊</span>
+        <span>医生查询</span>
+      </div>
+    </section>
+
+    <p class="mock-note">Mock 结果显示"联调演示",不伪装成真实业务。</p>
+  </aside>
+</template>
+
+<style scoped>
+.assistant-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  min-height: 0;
+  padding: 18px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-panel);
+  background: linear-gradient(180deg, #ffffff, #f1fbff);
+}
+
+.identity {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+}
+
+.avatar {
+  display: grid;
+  width: 70px;
+  height: 70px;
+  flex: 0 0 auto;
+  place-items: center;
+  border-radius: 50%;
+  background: radial-gradient(circle at 35% 30%, #ffffff, var(--brand-accent) 45%, var(--brand-primary));
+  color: #ffffff;
+  font-weight: 900;
+}
+
+h2 {
+  margin: 0;
+  font-size: 18px;
+}
+
+p {
+  margin: 4px 0 0;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.section-label {
+  margin-bottom: 10px;
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.examples,
+.capabilities {
+  padding: 14px;
+  border: 1px solid #dce7f8;
+  border-radius: var(--radius-card);
+  background: var(--surface);
+}
+
+ul {
+  display: grid;
+  gap: 8px;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+li {
+  padding: 9px;
+  border-radius: 10px;
+  background: #f4fbff;
+  font-size: 13px;
+}
+
+.chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.chips span {
+  padding: 6px 9px;
+  border: 1px solid #cceff1;
+  border-radius: 999px;
+  color: #156e83;
+  font-size: 12px;
+}
+
+.mock-note {
+  margin-top: auto;
+  padding: 12px;
+  border: 1px solid #f0d89c;
+  border-radius: 12px;
+  background: var(--warning-bg);
+  color: var(--warning-text);
+  line-height: 1.6;
+}
+</style>

+ 92 - 0
src/layouts/ContextPanel.vue

@@ -0,0 +1,92 @@
+<template>
+  <aside class="context-panel" aria-label="上下文面板">
+    <DoctorAssistantFigure v-if="store.contextPanelMode === 'idle'" />
+
+    <template v-else-if="store.contextPanelMode === 'task' || store.contextPanelMode === 'completed'">
+      <RegistrationProgress :current-step="store.task?.currentStep || ''" />
+      <CardRenderer v-if="store.activeCard" :card="store.activeCard" @action="handleAction" @finish="store.resetToIdle" />
+    </template>
+
+    <ErrorCard
+      v-else
+      :message="store.errorMessage"
+      :trace-id="store.currentTraceId"
+      @restart="store.resetToIdle"
+    />
+  </aside>
+</template>
+
+<script setup lang="ts">
+import CardRenderer from '../cards/CardRenderer.vue';
+import ErrorCard from '../cards/ErrorCard.vue';
+import { demoCards } from '../api/demoFixtures';
+import { useTerminalStore } from '../state/terminalStore';
+import DoctorAssistantFigure from './DoctorAssistantFigure.vue';
+import RegistrationProgress from './RegistrationProgress.vue';
+
+const store = useTerminalStore();
+
+function handleAction(actionName: string) {
+  store.pendingActionKey = `${store.activeCard?.cardInstanceId}-${actionName}`;
+  if (actionName === 'select_department') {
+    store.setActiveCard(demoCards.card_doctor);
+    if (store.task) store.task.currentStep = 'WAIT_SELECT_DOCTOR';
+  }
+  if (actionName === 'select_doctor') {
+    store.setActiveCard(demoCards.card_slot);
+    if (store.task) store.task.currentStep = 'WAIT_SELECT_TIME_SLOT';
+  }
+  if (actionName === 'select_time_slot') {
+    store.setActiveCard({
+      cardInstanceId: 'card_confirm',
+      cardKey: 'confirm-appointment',
+      status: 'active',
+      cardData: {
+        summary: {
+          departmentName: '神经内科',
+          doctorName: '李明',
+          visitTime: '明天 09:30-09:45',
+          amount: 25
+        }
+      },
+      actions: [{ actionName: 'confirm_appointment', label: '确认挂号', requiredConfirm: true }]
+    });
+    if (store.task) store.task.currentStep = 'WAIT_CONFIRM_APPOINTMENT';
+  }
+  if (actionName === 'confirm_appointment') {
+    store.setActiveCard({
+      cardInstanceId: 'card_payment',
+      cardKey: 'payment-qrcode',
+      status: 'active',
+      cardData: { orderId: 'PAY202606020001', amount: 25, qrContent: 'demo-payment://orderId=PAY202606020001', mock: true },
+      actions: [{ actionName: 'mock_payment_paid', label: '模拟支付完成', requiredConfirm: true }]
+    });
+    if (store.task) store.task.currentStep = 'WAIT_CONFIRM_APPOINTMENT';
+  }
+  if (actionName === 'mock_payment_paid') {
+    store.setActiveCard({
+      cardInstanceId: 'card_success',
+      cardKey: 'appointment-success',
+      status: 'completed',
+      cardData: {
+        appointmentNo: 'A023',
+        departmentName: '神经内科',
+        doctorName: '李明',
+        visitTime: '明天 09:30-09:45',
+        room: '门诊三楼 302 诊室',
+        mock: true
+      },
+      actions: []
+    });
+    if (store.task) store.task.currentStep = 'APPOINTMENT_SUCCESS';
+  }
+}
+</script>
+
+<style scoped>
+.context-panel {
+  display: grid;
+  min-height: 0;
+  gap: 12px;
+}
+</style>

+ 53 - 0
src/layouts/DoctorAssistantFigure.vue

@@ -0,0 +1,53 @@
+<template>
+  <div class="doctor-figure" aria-label="医生助手静态形象">
+    <div class="imageFrame">
+      <img src="../assets/doctor-assistant.svg" alt="医生助手" />
+    </div>
+    <h2>医生助手</h2>
+    <p>当前仅作为静态形象展示。语音与发送操作仍在中间输入区完成。</p>
+  </div>
+</template>
+
+<style scoped>
+.doctor-figure {
+  display: flex;
+  min-height: 100%;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 28px;
+  text-align: center;
+}
+
+.imageFrame {
+  display: grid;
+  width: min(280px, 72%);
+  aspect-ratio: 1 / 1;
+  place-items: center;
+  border: 1px solid var(--border);
+  border-radius: 50%;
+  background:
+    radial-gradient(circle at 40% 30%, rgba(58, 212, 216, 0.2), transparent 42%),
+    linear-gradient(180deg, #ffffff, #eef8ff);
+  box-shadow: var(--shadow-soft);
+}
+
+img {
+  max-width: 86%;
+  max-height: 86%;
+  object-fit: contain;
+}
+
+h2 {
+  margin: 22px 0 8px;
+  font-size: 24px;
+}
+
+p {
+  max-width: 280px;
+  margin: 0;
+  color: var(--text-secondary);
+  font-size: 14px;
+  line-height: 1.7;
+}
+</style>

+ 69 - 0
src/layouts/RegistrationProgress.vue

@@ -0,0 +1,69 @@
+<template>
+  <section class="progress-panel">
+    <div class="heading">
+      <strong>挂号进度</strong>
+      <span>{{ currentLabel }}</span>
+    </div>
+    <div class="bar" aria-label="挂号进度">
+      <i v-for="step in 5" :key="step" :class="{ active: step <= activeIndex }" />
+    </div>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+
+const props = defineProps<{ currentStep: string }>();
+
+const stepMap: Record<string, { index: number; label: string }> = {
+  TRIAGE_RECOMMEND_DEPARTMENT: { index: 1, label: '科室推荐' },
+  WAIT_SELECT_DOCTOR: { index: 2, label: '选择医生' },
+  WAIT_SELECT_TIME_SLOT: { index: 3, label: '选择时间' },
+  WAIT_CONFIRM_APPOINTMENT: { index: 4, label: '确认支付' },
+  APPOINTMENT_SUCCESS: { index: 5, label: '挂号成功' }
+};
+
+const current = computed(() => stepMap[props.currentStep] ?? stepMap.TRIAGE_RECOMMEND_DEPARTMENT);
+const activeIndex = computed(() => current.value.index);
+const currentLabel = computed(() => current.value.label);
+</script>
+
+<style scoped>
+.progress-panel {
+  padding: 14px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-panel);
+  background: var(--surface);
+}
+
+.heading {
+  display: flex;
+  justify-content: space-between;
+}
+
+.heading span {
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+.bar {
+  display: grid;
+  grid-template-columns: repeat(5, 1fr);
+  gap: 5px;
+  margin-top: 12px;
+}
+
+.bar i {
+  height: 8px;
+  border-radius: 999px;
+  background: var(--border);
+}
+
+.bar i.active:first-child {
+  background: var(--brand-primary);
+}
+
+.bar i.active {
+  background: var(--brand-accent);
+}
+</style>

+ 6 - 0
src/main.ts

@@ -0,0 +1,6 @@
+import { createPinia } from 'pinia';
+import { createApp } from 'vue';
+import App from './App.vue';
+import './theme/tokens.css';
+
+createApp(App).use(createPinia()).mount('#app');

+ 68 - 0
src/state/terminalStore.ts

@@ -0,0 +1,68 @@
+import { defineStore } from 'pinia';
+import type { CardInstance, ChatMessage, TaskState } from '../api/types';
+
+export type ContextPanelMode = 'idle' | 'task' | 'error' | 'completed';
+
+export const useTerminalStore = defineStore('terminal', {
+  state: () => ({
+    deviceId: 'EMOON-WEB-DEMO-001',
+    deviceType: 'self_service_kiosk',
+    conversationId: '' as string,
+    currentTraceId: '' as string,
+    messages: [] as ChatMessage[],
+    streaming: false,
+    task: null as TaskState | null,
+    activeCard: null as CardInstance | null,
+    errorMessage: '' as string,
+    inputText: '',
+    pendingActionKey: '',
+    demoMode: true
+  }),
+  getters: {
+    contextPanelMode(state): ContextPanelMode {
+      if (state.errorMessage) return 'error';
+      if (state.activeCard?.cardKey === 'appointment-success') return 'completed';
+      if (state.activeCard) return 'task';
+      return 'idle';
+    }
+  },
+  actions: {
+    addMessage(message: ChatMessage) {
+      this.messages.push(message);
+    },
+    appendAssistantDelta(text: string) {
+      const last = this.messages[this.messages.length - 1];
+      if (last?.role === 'assistant' && last.streaming) {
+        last.content += text;
+        return;
+      }
+      this.messages.push({
+        id: crypto.randomUUID(),
+        role: 'assistant',
+        content: text,
+        streaming: true
+      });
+    },
+    completeAssistantMessage() {
+      const last = this.messages[this.messages.length - 1];
+      if (last?.role === 'assistant') last.streaming = false;
+    },
+    setTask(task: TaskState) {
+      this.task = task;
+    },
+    setActiveCard(card: CardInstance) {
+      this.activeCard = card;
+      this.errorMessage = '';
+    },
+    setError(message: string, traceId = '') {
+      this.errorMessage = message;
+      this.currentTraceId = traceId;
+    },
+    resetToIdle() {
+      this.task = null;
+      this.activeCard = null;
+      this.errorMessage = '';
+      this.pendingActionKey = '';
+    }
+  }
+});

+ 37 - 0
src/theme/tokens.css

@@ -0,0 +1,37 @@
+:root {
+  --brand-primary: #2b1f99;
+  --brand-accent: #3ad4d8;
+  --page-bg: #f7fbff;
+  --surface: #ffffff;
+  --soft-tint: #f4fdff;
+  --border: #dfe7f7;
+  --text-primary: #16133a;
+  --text-secondary: #69708a;
+  --warning-bg: #fff8e8;
+  --warning-text: #735b14;
+  --shadow-soft: 0 20px 48px rgba(43, 31, 153, 0.12);
+  --radius-panel: 16px;
+  --radius-card: 14px;
+  font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html,
+body,
+#app {
+  width: 100%;
+  min-width: 1280px;
+  min-height: 100%;
+  margin: 0;
+  background: var(--page-bg);
+  color: var(--text-primary);
+}
+
+button,
+input,
+textarea {
+  font: inherit;
+}

+ 31 - 0
tests/e2e/registration-flow.spec.ts

@@ -0,0 +1,31 @@
+import { expect, test } from '@playwright/test';
+
+test('demo registration flow reaches mock appointment success', async ({ page }) => {
+  await page.goto('/');
+
+  const input = page.getByPlaceholder('继续输入症状、科室、医生或时间...');
+  await input.click();
+  await input.fill('我头疼三天,还有点恶心,想挂号');
+  const sendBtn = page.getByRole('button', { name: '发送' });
+  await expect(sendBtn).toBeEnabled();
+  await sendBtn.click();
+
+  await expect(page.getByText('推荐科室')).toBeVisible();
+  await page.getByRole('button', { name: '选择神经内科' }).click();
+
+  await expect(page.getByText('选择医生')).toBeVisible();
+  await page.getByRole('button', { name: /选择李明/ }).click();
+
+  await expect(page.getByText('选择时间')).toBeVisible();
+  await page.getByRole('button', { name: /09:30/ }).click();
+
+  await expect(page.getByText('确认挂号信息')).toBeVisible();
+  await page.getByRole('button', { name: '确认挂号' }).click();
+
+  await expect(page.getByText('联调演示 / Mock 支付')).toBeVisible();
+  await page.getByRole('button', { name: '模拟支付完成' }).click();
+
+  await expect(page.getByText('挂号成功')).toBeVisible();
+  await expect(page.getByText('A023')).toBeVisible();
+  await expect(page.getByText('联调演示', { exact: true })).toBeVisible();
+});

+ 16 - 0
tests/e2e/visual-layout.spec.ts

@@ -0,0 +1,16 @@
+import { expect, test } from '@playwright/test';
+
+test('desktop layout has no horizontal overflow and keeps right panel as static doctor idle state', async ({ page }) => {
+  await page.goto('/');
+
+  await expect(page.getByText('医梦门诊助手')).toBeVisible();
+  await expect(page.getByText('说出需求,我来判断下一步')).toBeVisible();
+  await expect(page.getByAltText('医生助手')).toBeVisible();
+  await expect(page.getByText('当前仅作为静态形象展示')).toBeVisible();
+  await expect(page.getByRole('button', { name: '语音' })).toBeVisible();
+  await expect(page.getByRole('button', { name: '发送' })).toBeVisible();
+  await expect(page.getByText('智能挂号')).toHaveCount(0);
+
+  const horizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth);
+  expect(horizontalOverflow).toBe(false);
+});

+ 20 - 0
tests/unit/apiClient.spec.ts

@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest';
+import { sha256Hex } from '../../src/api/hmac';
+
+describe('hmac helpers', () => {
+  it('hashes body as lowercase hex sha256', async () => {
+    await expect(sha256Hex('{"a":1}')).resolves.toBe('015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862');
+  });
+
+  it('produces consistent hash for same input', async () => {
+    const hash1 = await sha256Hex('hello');
+    const hash2 = await sha256Hex('hello');
+    expect(hash1).toBe(hash2);
+  });
+
+  it('produces different hashes for different input', async () => {
+    const hash1 = await sha256Hex('hello');
+    const hash2 = await sha256Hex('world');
+    expect(hash1).not.toBe(hash2);
+  });
+});

+ 146 - 0
tests/unit/cardRenderer.spec.ts

@@ -0,0 +1,146 @@
+// @vitest-environment jsdom
+
+import { mount } from '@vue/test-utils';
+import { describe, expect, it } from 'vitest';
+import CardRenderer from '../../src/cards/CardRenderer.vue';
+
+describe('CardRenderer', () => {
+  it('renders department card by cardKey', () => {
+    const wrapper = mount(CardRenderer, {
+      props: {
+        card: {
+          cardInstanceId: 'card_department',
+          cardKey: 'department-selection',
+          status: 'active',
+          cardData: { departments: [{ departmentId: 'neurology', name: '神经内科', reason: '头痛伴恶心' }] },
+          actions: [{ actionName: 'select_department', label: '选择科室' }]
+        }
+      }
+    });
+
+    expect(wrapper.text()).toContain('推荐科室');
+    expect(wrapper.text()).toContain('神经内科');
+  });
+
+  it('renders doctor selection card', () => {
+    const wrapper = mount(CardRenderer, {
+      props: {
+        card: {
+          cardInstanceId: 'card_doctor',
+          cardKey: 'doctor-selection',
+          status: 'active',
+          cardData: { doctors: [{ doctorId: 'DOC001', name: '李明', title: '主任医师', specialty: '头痛', room: '302' }] },
+          actions: [{ actionName: 'select_doctor', label: '选择医生' }]
+        }
+      }
+    });
+
+    expect(wrapper.text()).toContain('选择医生');
+    expect(wrapper.text()).toContain('李明');
+    expect(wrapper.text()).toContain('主任医师');
+  });
+
+  it('renders time slot selection card', () => {
+    const wrapper = mount(CardRenderer, {
+      props: {
+        card: {
+          cardInstanceId: 'card_slot',
+          cardKey: 'time-slot-selection',
+          status: 'active',
+          cardData: { slots: [{ slotId: 's1', timePeriod: '明天 09:30-09:45', remaining: 3 }] },
+          actions: [{ actionName: 'select_time_slot', label: '选择时间' }]
+        }
+      }
+    });
+
+    expect(wrapper.text()).toContain('选择时间');
+    expect(wrapper.text()).toContain('明天 09:30-09:45');
+  });
+
+  it('renders confirm appointment card', () => {
+    const wrapper = mount(CardRenderer, {
+      props: {
+        card: {
+          cardInstanceId: 'card_confirm',
+          cardKey: 'confirm-appointment',
+          status: 'active',
+          cardData: { summary: { departmentName: '神经内科', doctorName: '李明', visitTime: '明天 09:30', amount: 25 } },
+          actions: [{ actionName: 'confirm_appointment', label: '确认挂号', requiredConfirm: true }]
+        }
+      }
+    });
+
+    expect(wrapper.text()).toContain('确认挂号信息');
+    expect(wrapper.text()).toContain('神经内科');
+    expect(wrapper.text()).toContain('25 元');
+  });
+
+  it('renders payment QR card with mock label', () => {
+    const wrapper = mount(CardRenderer, {
+      props: {
+        card: {
+          cardInstanceId: 'card_payment',
+          cardKey: 'payment-qrcode',
+          status: 'active',
+          cardData: { orderId: 'PAY001', amount: 25, qrContent: 'demo-payment://test', mock: true },
+          actions: [{ actionName: 'mock_payment_paid', label: '模拟支付完成', requiredConfirm: true }]
+        }
+      }
+    });
+
+    expect(wrapper.text()).toContain('联调演示 / Mock 支付');
+    expect(wrapper.text()).toContain('模拟支付完成');
+  });
+
+  it('renders appointment success card with mock label', () => {
+    const wrapper = mount(CardRenderer, {
+      props: {
+        card: {
+          cardInstanceId: 'card_success',
+          cardKey: 'appointment-success',
+          status: 'completed',
+          cardData: { appointmentNo: 'A023', departmentName: '神经内科', doctorName: '李明', visitTime: '明天 09:30', room: '302', mock: true },
+          actions: []
+        }
+      }
+    });
+
+    expect(wrapper.text()).toContain('挂号成功');
+    expect(wrapper.text()).toContain('A023');
+    expect(wrapper.text()).toContain('联调演示');
+  });
+
+  it('renders error card for unknown card key', () => {
+    const wrapper = mount(CardRenderer, {
+      props: {
+        card: {
+          cardInstanceId: 'card_unknown',
+          cardKey: 'unknown-type',
+          status: 'active',
+          cardData: {},
+          actions: []
+        }
+      }
+    });
+
+    expect(wrapper.text()).toContain('暂不支持的卡片类型');
+  });
+
+  it('emits action event when card button is clicked', async () => {
+    const wrapper = mount(CardRenderer, {
+      props: {
+        card: {
+          cardInstanceId: 'card_department',
+          cardKey: 'department-selection',
+          status: 'active',
+          cardData: { departments: [{ departmentId: 'neurology', name: '神经内科', reason: '头痛伴恶心' }] },
+          actions: [{ actionName: 'select_department', label: '选择科室' }]
+        }
+      }
+    });
+
+    await wrapper.find('button').trigger('click');
+    expect(wrapper.emitted('action')).toBeTruthy();
+    expect(wrapper.emitted('action')![0]).toEqual(['select_department']);
+  });
+});

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

@@ -0,0 +1,18 @@
+// @vitest-environment jsdom
+
+import { mount } from '@vue/test-utils';
+import { describe, expect, it } from 'vitest';
+import DoctorAssistantFigure from '../../src/layouts/DoctorAssistantFigure.vue';
+
+describe('DoctorAssistantFigure', () => {
+  it('renders the static doctor assistant image and no voice controls', () => {
+    const wrapper = mount(DoctorAssistantFigure);
+
+    expect(wrapper.find('img[alt="医生助手"]').exists()).toBe(true);
+    expect(wrapper.text()).toContain('医生助手');
+    expect(wrapper.text()).toContain('静态形象展示');
+    expect(wrapper.text()).not.toContain('可听');
+    expect(wrapper.text()).not.toContain('可说');
+    expect(wrapper.find('button').exists()).toBe(false);
+  });
+});

+ 53 - 0
tests/unit/sse.spec.ts

@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest';
+import { parseSseChunk } from '../../src/api/sse';
+
+describe('parseSseChunk', () => {
+  it('parses named SSE events with JSON data and preserves remainder', () => {
+    const result = parseSseChunk('', 'event: message_delta\ndata: {"text":"好的"}\n\npartial');
+
+    expect(result.events).toEqual([{ type: 'message_delta', data: { text: '好的' } }]);
+    expect(result.remainder).toBe('partial');
+  });
+
+  it('accumulates across multiple chunks', () => {
+    const first = parseSseChunk('', 'event: message_delta\ndata: {"text":"好');
+
+    expect(first.events).toEqual([]);
+    expect(first.remainder).toBe('event: message_delta\ndata: {"text":"好');
+
+    const second = parseSseChunk(first.remainder, '的"}\n\n');
+
+    expect(second.events).toEqual([{ type: 'message_delta', data: { text: '好的' } }]);
+    expect(second.remainder).toBe('');
+  });
+
+  it('handles multiple events in one chunk', () => {
+    const result = parseSseChunk('', 'event: task_updated\ndata: {"taskId":"1"}\n\nevent: card_created\ndata: {"cardId":"c1"}\n\n');
+
+    expect(result.events).toHaveLength(2);
+    expect(result.events[0]).toEqual({ type: 'task_updated', data: { taskId: '1' } });
+    expect(result.events[1]).toEqual({ type: 'card_created', data: { cardId: 'c1' } });
+  });
+
+  it('ignores malformed data events without throwing', () => {
+    const result = parseSseChunk('', 'event: error\ndata: not-json\n\n');
+
+    expect(result.events).toEqual([{ type: 'error', data: { message: 'SSE 数据解析失败' } }]);
+  });
+
+  it('skips events without both event and data lines', () => {
+    const result = parseSseChunk('', 'event: something\n\n');
+
+    expect(result.events).toEqual([]);
+    expect(result.remainder).toBe('');
+  });
+
+  it('handles all six SSE event types', () => {
+    const types = ['task_updated', 'message_delta', 'message_completed', 'card_created', 'error', 'completed'];
+
+    for (const type of types) {
+      const result = parseSseChunk('', `event: ${type}\ndata: {"key":"val"}\n\n`);
+      expect(result.events[0].type).toBe(type);
+    }
+  });
+});

+ 94 - 0
tests/unit/terminalStore.spec.ts

@@ -0,0 +1,94 @@
+import { createPinia, setActivePinia } from 'pinia';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { useTerminalStore } from '../../src/state/terminalStore';
+
+describe('terminalStore', () => {
+  beforeEach(() => setActivePinia(createPinia()));
+
+  it('starts in idle right-panel mode with no active card', () => {
+    const store = useTerminalStore();
+
+    expect(store.contextPanelMode).toBe('idle');
+    expect(store.activeCard).toBeNull();
+  });
+
+  it('switches to task mode when a card is active', () => {
+    const store = useTerminalStore();
+
+    store.setActiveCard({
+      cardInstanceId: 'card_001',
+      cardKey: 'department-selection',
+      status: 'active',
+      cardData: { departments: [] },
+      actions: [{ actionName: 'select_department', label: '选择科室' }]
+    });
+
+    expect(store.contextPanelMode).toBe('task');
+  });
+
+  it('switches to completed mode for appointment-success card', () => {
+    const store = useTerminalStore();
+
+    store.setActiveCard({
+      cardInstanceId: 'card_005',
+      cardKey: 'appointment-success',
+      status: 'completed',
+      cardData: { appointmentNo: 'A023', mock: true },
+      actions: []
+    });
+
+    expect(store.contextPanelMode).toBe('completed');
+  });
+
+  it('switches to error mode when error message is set', () => {
+    const store = useTerminalStore();
+
+    store.setError('网络连接失败', 'trace_abc123');
+
+    expect(store.contextPanelMode).toBe('error');
+    expect(store.errorMessage).toBe('网络连接失败');
+    expect(store.currentTraceId).toBe('trace_abc123');
+  });
+
+  it('resets to idle clears task, card and error', () => {
+    const store = useTerminalStore();
+
+    store.setActiveCard({
+      cardInstanceId: 'card_001',
+      cardKey: 'department-selection',
+      status: 'active',
+      cardData: {},
+      actions: []
+    });
+    store.setError('something went wrong');
+
+    store.resetToIdle();
+
+    expect(store.contextPanelMode).toBe('idle');
+    expect(store.activeCard).toBeNull();
+    expect(store.task).toBeNull();
+    expect(store.errorMessage).toBe('');
+  });
+
+  it('appends assistant delta correctly', () => {
+    const store = useTerminalStore();
+
+    store.appendAssistantDelta('你好');
+    expect(store.messages).toHaveLength(1);
+    expect(store.messages[0].content).toBe('你好');
+    expect(store.messages[0].streaming).toBe(true);
+
+    store.appendAssistantDelta(',世界');
+    expect(store.messages).toHaveLength(1);
+    expect(store.messages[0].content).toBe('你好,世界');
+  });
+
+  it('completes assistant message marks streaming false', () => {
+    const store = useTerminalStore();
+
+    store.appendAssistantDelta('测试');
+    store.completeAssistantMessage();
+
+    expect(store.messages[0].streaming).toBe(false);
+  });
+});

+ 19 - 0
tsconfig.json

@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "target": "ES2021",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "strict": true,
+    "jsx": "preserve",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "esModuleInterop": true,
+    "lib": ["ES2021", "DOM", "DOM.Iterable"],
+    "skipLibCheck": true,
+    "noEmit": true,
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  },
+  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "tests/**/*.ts"]
+}

+ 11 - 0
tsconfig.node.json

@@ -0,0 +1,11 @@
+{
+  "compilerOptions": {
+    "target": "ES2021",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "strict": true,
+    "noEmit": true,
+    "allowSyntheticDefaultImports": true
+  },
+  "include": ["vite.config.ts", "playwright.config.ts"]
+}

+ 10 - 0
vite.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite';
+import vue from '@vitejs/plugin-vue';
+
+export default defineConfig({
+  plugins: [vue()],
+  server: {
+    host: '127.0.0.1',
+    port: 5173
+  }
+});