|
|
@@ -0,0 +1,2109 @@
|
|
|
+# Terminal Web Demo Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** Build a single Vue 3 Web demo for the unified entry terminal client that presents a dialogue-first AI hospital assistant and completes the outpatient registration card flow.
|
|
|
+
|
|
|
+**Architecture:** Create an independent frontend app in `emoon-terminal-client`, outside the backend Maven reactor. The app has a three-column desktop layout: left assistant context, center conversation input/stream, right context panel. It talks only to OpenPlatform terminal endpoints through an API client, supports local demo fixtures when backend is unavailable, and uses Playwright to verify desktop visual and interaction behavior.
|
|
|
+
|
|
|
+**Tech Stack:** Vue 3, TypeScript, Vite, Pinia, Vue Router only if needed, native `fetch` streaming parser for SSE, CSS variables for theme tokens, Vitest, Vue Test Utils, Playwright, generated/static TypeScript API types from backend contract.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Source Documents
|
|
|
+
|
|
|
+Read these before implementation:
|
|
|
+
|
|
|
+- `docs/superpowers/specs/2026-06-02-terminal-web-demo-design.md`
|
|
|
+- `docs/接口文档/terminal-client-mvp-contract.md`
|
|
|
+- `docs/架构文档/统一入口客户端前端工程启动指南.md`
|
|
|
+- `docs/架构文档/统一入口客户端前端接入指引_v1.0.md`
|
|
|
+- `docs/superpowers/plans/2026-06-02-unified-entry-registration-demo.md`
|
|
|
+
|
|
|
+## Target Repository
|
|
|
+
|
|
|
+Create or work in:
|
|
|
+
|
|
|
+```text
|
|
|
+/Users/destiny/dev/emoon/emoon-terminal-client
|
|
|
+```
|
|
|
+
|
|
|
+This frontend repository is intentionally separate from:
|
|
|
+
|
|
|
+```text
|
|
|
+/Users/destiny/dev/emoon/emoon-backend
|
|
|
+```
|
|
|
+
|
|
|
+Do not add frontend source files to the backend Maven reactor.
|
|
|
+
|
|
|
+## Target File Structure
|
|
|
+
|
|
|
+```text
|
|
|
+emoon-terminal-client
|
|
|
+├── package.json
|
|
|
+├── pnpm-lock.yaml
|
|
|
+├── vite.config.ts
|
|
|
+├── tsconfig.json
|
|
|
+├── index.html
|
|
|
+├── playwright.config.ts
|
|
|
+├── src
|
|
|
+│ ├── App.vue
|
|
|
+│ ├── main.ts
|
|
|
+│ ├── assets
|
|
|
+│ │ └── doctor-assistant.png
|
|
|
+│ ├── api
|
|
|
+│ │ ├── client.ts
|
|
|
+│ │ ├── demoFixtures.ts
|
|
|
+│ │ ├── hmac.ts
|
|
|
+│ │ ├── sse.ts
|
|
|
+│ │ └── types.ts
|
|
|
+│ ├── cards
|
|
|
+│ │ ├── CardRenderer.vue
|
|
|
+│ │ ├── DepartmentSelectionCard.vue
|
|
|
+│ │ ├── DoctorSelectionCard.vue
|
|
|
+│ │ ├── TimeSlotSelectionCard.vue
|
|
|
+│ │ ├── ConfirmAppointmentCard.vue
|
|
|
+│ │ ├── PaymentQrCard.vue
|
|
|
+│ │ ├── AppointmentSuccessCard.vue
|
|
|
+│ │ └── ErrorCard.vue
|
|
|
+│ ├── chat
|
|
|
+│ │ ├── ConversationPanel.vue
|
|
|
+│ │ ├── MessageBubble.vue
|
|
|
+│ │ └── ChatInput.vue
|
|
|
+│ ├── layouts
|
|
|
+│ │ ├── AppShell.vue
|
|
|
+│ │ ├── AssistantContextPanel.vue
|
|
|
+│ │ ├── ContextPanel.vue
|
|
|
+│ │ ├── DoctorAssistantFigure.vue
|
|
|
+│ │ └── RegistrationProgress.vue
|
|
|
+│ ├── state
|
|
|
+│ │ └── terminalStore.ts
|
|
|
+│ ├── theme
|
|
|
+│ │ └── tokens.css
|
|
|
+│ └── demo
|
|
|
+│ └── demoMode.ts
|
|
|
+├── tests
|
|
|
+│ ├── unit
|
|
|
+│ │ ├── cardRenderer.spec.ts
|
|
|
+│ │ ├── sse.spec.ts
|
|
|
+│ │ └── terminalStore.spec.ts
|
|
|
+│ └── e2e
|
|
|
+│ ├── registration-flow.spec.ts
|
|
|
+│ └── visual-layout.spec.ts
|
|
|
+└── README.md
|
|
|
+```
|
|
|
+
|
|
|
+## Task 1: Scaffold The Vue Web Demo App
|
|
|
+
|
|
|
+**Files:**
|
|
|
+
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/package.json`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/vite.config.ts`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/tsconfig.json`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/index.html`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/main.ts`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/App.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: Create package manifest**
|
|
|
+
|
|
|
+Use this dependency baseline:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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"
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Install dependencies**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /Users/destiny/dev/emoon/emoon-terminal-client
|
|
|
+pnpm install
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `pnpm-lock.yaml` is created and install succeeds.
|
|
|
+
|
|
|
+- [ ] **Step 3: Create minimal Vue entry**
|
|
|
+
|
|
|
+`src/main.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+import { createPinia } from 'pinia';
|
|
|
+import { createApp } from 'vue';
|
|
|
+import App from './App.vue';
|
|
|
+import './theme/tokens.css';
|
|
|
+
|
|
|
+createApp(App).use(createPinia()).mount('#app');
|
|
|
+```
|
|
|
+
|
|
|
+`src/App.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <AppShell />
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import AppShell from './layouts/AppShell.vue';
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Run build and verify failure until later layout files exist**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm build
|
|
|
+```
|
|
|
+
|
|
|
+Expected: fails with missing `./layouts/AppShell.vue`. This confirms the app entry is wired to the planned layout component.
|
|
|
+
|
|
|
+**Commit:** `feat: scaffold terminal web demo`
|
|
|
+
|
|
|
+## Task 2: Add Theme Tokens And Doctor Asset
|
|
|
+
|
|
|
+**Files:**
|
|
|
+
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/theme/tokens.css`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/assets/doctor-assistant.png`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/DoctorAssistantFigure.vue`
|
|
|
+- Test: `/Users/destiny/dev/emoon/emoon-terminal-client/tests/unit/doctorAssistantFigure.spec.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Copy generated doctor image**
|
|
|
+
|
|
|
+Copy, do not move or delete the original:
|
|
|
+
|
|
|
+```bash
|
|
|
+cp /Users/destiny/.codex/generated_images/019e8719-cee0-7723-83a4-ac1a9e0a3bd4/ig_037570e350a4f508016a1e882c0c188191b10d3dbe9b5f776a.png /Users/destiny/dev/emoon/emoon-terminal-client/src/assets/doctor-assistant.png
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `src/assets/doctor-assistant.png` exists.
|
|
|
+
|
|
|
+- [ ] **Step 2: Add CSS token file**
|
|
|
+
|
|
|
+`src/theme/tokens.css`:
|
|
|
+
|
|
|
+```css
|
|
|
+: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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Write failing doctor component test**
|
|
|
+
|
|
|
+`tests/unit/doctorAssistantFigure.spec.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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);
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Run test and verify failure**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm vitest run tests/unit/doctorAssistantFigure.spec.ts
|
|
|
+```
|
|
|
+
|
|
|
+Expected: fails because `DoctorAssistantFigure.vue` does not exist.
|
|
|
+
|
|
|
+- [ ] **Step 5: Implement static doctor figure**
|
|
|
+
|
|
|
+`src/layouts/DoctorAssistantFigure.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="doctor-figure" aria-label="医生助手静态形象">
|
|
|
+ <div class="imageFrame">
|
|
|
+ <img src="../assets/doctor-assistant.png" 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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Run test**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm vitest run tests/unit/doctorAssistantFigure.spec.ts
|
|
|
+```
|
|
|
+
|
|
|
+Expected: pass.
|
|
|
+
|
|
|
+**Commit:** `feat: add brand tokens and doctor assistant asset`
|
|
|
+
|
|
|
+## Task 3: Build Three-Column App Shell
|
|
|
+
|
|
|
+**Files:**
|
|
|
+
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/AppShell.vue`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/AssistantContextPanel.vue`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/ContextPanel.vue`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/RegistrationProgress.vue`
|
|
|
+- Test: `/Users/destiny/dev/emoon/emoon-terminal-client/tests/unit/appShell.spec.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing layout test**
|
|
|
+
|
|
|
+`tests/unit/appShell.spec.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+import { mount } from '@vue/test-utils';
|
|
|
+import { describe, expect, it } from 'vitest';
|
|
|
+import AppShell from '../../src/layouts/AppShell.vue';
|
|
|
+
|
|
|
+describe('AppShell', () => {
|
|
|
+ it('renders header, assistant context, conversation area, and right context panel', () => {
|
|
|
+ const wrapper = mount(AppShell, {
|
|
|
+ global: {
|
|
|
+ stubs: {
|
|
|
+ ConversationPanel: { template: '<section data-test="conversation-panel" />' },
|
|
|
+ ContextPanel: { template: '<aside data-test="context-panel" />' }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(wrapper.text()).toContain('医梦门诊助手');
|
|
|
+ expect(wrapper.text()).toContain('说出需求,我来判断下一步');
|
|
|
+ expect(wrapper.find('[data-test="conversation-panel"]').exists()).toBe(true);
|
|
|
+ expect(wrapper.find('[data-test="context-panel"]').exists()).toBe(true);
|
|
|
+ expect(wrapper.text()).not.toContain('智能挂号');
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run test and verify failure**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm vitest run tests/unit/appShell.spec.ts
|
|
|
+```
|
|
|
+
|
|
|
+Expected: fails until layout files exist.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement left assistant panel without workflow buttons**
|
|
|
+
|
|
|
+`src/layouts/AssistantContextPanel.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Implement app shell**
|
|
|
+
|
|
|
+`src/layouts/AppShell.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Run layout test**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm vitest run tests/unit/appShell.spec.ts
|
|
|
+```
|
|
|
+
|
|
|
+Expected: fails only until `ConversationPanel` and `ContextPanel` are implemented in later tasks.
|
|
|
+
|
|
|
+**Commit:** `feat: build terminal app shell`
|
|
|
+
|
|
|
+## Task 4: Implement Store And Demo Fixtures
|
|
|
+
|
|
|
+**Files:**
|
|
|
+
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/state/terminalStore.ts`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/api/types.ts`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/api/demoFixtures.ts`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/demo/demoMode.ts`
|
|
|
+- Test: `/Users/destiny/dev/emoon/emoon-terminal-client/tests/unit/terminalStore.spec.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing store tests**
|
|
|
+
|
|
|
+`tests/unit/terminalStore.spec.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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');
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run store test and verify failure**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm vitest run tests/unit/terminalStore.spec.ts
|
|
|
+```
|
|
|
+
|
|
|
+Expected: fails until store exists.
|
|
|
+
|
|
|
+- [ ] **Step 3: Define frontend API types**
|
|
|
+
|
|
|
+`src/api/types.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Implement store**
|
|
|
+
|
|
|
+`src/state/terminalStore.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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 = '';
|
|
|
+ }
|
|
|
+ }
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Add demo fixtures for registration cards**
|
|
|
+
|
|
|
+`src/api/demoFixtures.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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: '选择时间' }]
|
|
|
+ }
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Run store tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm vitest run tests/unit/terminalStore.spec.ts
|
|
|
+```
|
|
|
+
|
|
|
+Expected: pass.
|
|
|
+
|
|
|
+**Commit:** `feat: add terminal state and demo fixtures`
|
|
|
+
|
|
|
+## Task 5: Implement Conversation Panel And SSE Parser
|
|
|
+
|
|
|
+**Files:**
|
|
|
+
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/chat/ConversationPanel.vue`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/chat/MessageBubble.vue`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/chat/ChatInput.vue`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/api/sse.ts`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/tests/unit/sse.spec.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing SSE parser test**
|
|
|
+
|
|
|
+`tests/unit/sse.spec.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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('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 数据解析失败' } }]);
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run test and verify failure**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm vitest run tests/unit/sse.spec.ts
|
|
|
+```
|
|
|
+
|
|
|
+Expected: fails until `src/api/sse.ts` exists.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement SSE parser**
|
|
|
+
|
|
|
+`src/api/sse.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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 };
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Implement message bubble and input**
|
|
|
+
|
|
|
+`src/chat/MessageBubble.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`src/chat/ChatInput.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Implement conversation panel with demo start behavior**
|
|
|
+
|
|
|
+`src/chat/ConversationPanel.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Run unit tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm vitest run tests/unit/sse.spec.ts
|
|
|
+```
|
|
|
+
|
|
|
+Expected: pass.
|
|
|
+
|
|
|
+**Commit:** `feat: add conversation panel and sse parser`
|
|
|
+
|
|
|
+## Task 6: Implement Context Panel And Registration Cards
|
|
|
+
|
|
|
+**Files:**
|
|
|
+
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/CardRenderer.vue`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/DepartmentSelectionCard.vue`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/DoctorSelectionCard.vue`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/TimeSlotSelectionCard.vue`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/ConfirmAppointmentCard.vue`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/PaymentQrCard.vue`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/AppointmentSuccessCard.vue`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/ErrorCard.vue`
|
|
|
+- Modify: `/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/ContextPanel.vue`
|
|
|
+- Modify: `/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/RegistrationProgress.vue`
|
|
|
+- Test: `/Users/destiny/dev/emoon/emoon-terminal-client/tests/unit/cardRenderer.spec.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing card renderer test**
|
|
|
+
|
|
|
+`tests/unit/cardRenderer.spec.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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('神经内科');
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run test and verify failure**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm vitest run tests/unit/cardRenderer.spec.ts
|
|
|
+```
|
|
|
+
|
|
|
+Expected: fails until cards exist.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement ContextPanel**
|
|
|
+
|
|
|
+`src/layouts/ContextPanel.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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 (actionName === 'select_doctor') store.setActiveCard(demoCards.card_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 (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 (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: []
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.context-panel {
|
|
|
+ display: grid;
|
|
|
+ min-height: 0;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Implement RegistrationProgress**
|
|
|
+
|
|
|
+`src/layouts/RegistrationProgress.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Implement CardRenderer and one reusable card pattern**
|
|
|
+
|
|
|
+`src/cards/CardRenderer.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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>
|
|
|
+```
|
|
|
+
|
|
|
+`src/cards/DepartmentSelectionCard.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Implement remaining card components**
|
|
|
+
|
|
|
+`src/cards/DoctorSelectionCard.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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; }
|
|
|
+```
|
|
|
+
|
|
|
+`src/cards/TimeSlotSelectionCard.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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; }
|
|
|
+```
|
|
|
+
|
|
|
+`src/cards/ConfirmAppointmentCard.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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; }
|
|
|
+```
|
|
|
+
|
|
|
+`src/cards/PaymentQrCard.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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; }
|
|
|
+```
|
|
|
+
|
|
|
+`src/cards/AppointmentSuccessCard.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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; }
|
|
|
+```
|
|
|
+
|
|
|
+`src/cards/ErrorCard.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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; }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: Run card renderer test**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm vitest run tests/unit/cardRenderer.spec.ts
|
|
|
+```
|
|
|
+
|
|
|
+Expected: pass.
|
|
|
+
|
|
|
+**Commit:** `feat: implement registration context panel cards`
|
|
|
+
|
|
|
+## Task 7: Add API Client With Demo And Real Modes
|
|
|
+
|
|
|
+**Files:**
|
|
|
+
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/api/client.ts`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/src/api/hmac.ts`
|
|
|
+- Modify: `/Users/destiny/dev/emoon/emoon-terminal-client/src/demo/demoMode.ts`
|
|
|
+- Test: `/Users/destiny/dev/emoon/emoon-terminal-client/tests/unit/apiClient.spec.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing HMAC helper test**
|
|
|
+
|
|
|
+`tests/unit/apiClient.spec.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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');
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run test and verify failure**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm vitest run tests/unit/apiClient.spec.ts
|
|
|
+```
|
|
|
+
|
|
|
+Expected: fails until `hmac.ts` exists.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement HMAC hash helper**
|
|
|
+
|
|
|
+`src/api/hmac.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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}`
|
|
|
+ };
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+This demo signature is only for local Web demo mode. Real terminal shell signing remains out of scope for this frontend plan.
|
|
|
+
|
|
|
+- [ ] **Step 4: Implement API client shape**
|
|
|
+
|
|
|
+`src/api/client.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Run API test**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm vitest run tests/unit/apiClient.spec.ts
|
|
|
+```
|
|
|
+
|
|
|
+Expected: pass.
|
|
|
+
|
|
|
+**Commit:** `feat: add api client demo and real modes`
|
|
|
+
|
|
|
+## Task 8: Add Playwright Visual And Interaction QA
|
|
|
+
|
|
|
+**Files:**
|
|
|
+
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/playwright.config.ts`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/tests/e2e/visual-layout.spec.ts`
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/tests/e2e/registration-flow.spec.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Add Playwright config**
|
|
|
+
|
|
|
+`playwright.config.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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'], viewport: { width: 1440, height: 900 } }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: 'desktop-1920',
|
|
|
+ use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 } }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Add visual layout test**
|
|
|
+
|
|
|
+`tests/e2e/visual-layout.spec.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+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);
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Add registration flow test**
|
|
|
+
|
|
|
+`tests/e2e/registration-flow.spec.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+import { expect, test } from '@playwright/test';
|
|
|
+
|
|
|
+test('demo registration flow reaches mock appointment success', async ({ page }) => {
|
|
|
+ await page.goto('/');
|
|
|
+
|
|
|
+ await page.getByPlaceholder('继续输入症状、科室、医生或时间...').fill('我头疼三天,还有点恶心,想挂号');
|
|
|
+ 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: /选择李明/ }).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('联调演示')).toBeVisible();
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Run Playwright tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm test:e2e
|
|
|
+```
|
|
|
+
|
|
|
+Expected: both viewport projects pass.
|
|
|
+
|
|
|
+**Commit:** `test: add terminal web demo playwright qa`
|
|
|
+
|
|
|
+## Task 9: Final Build, Documentation, And Handoff
|
|
|
+
|
|
|
+**Files:**
|
|
|
+
|
|
|
+- Create: `/Users/destiny/dev/emoon/emoon-terminal-client/README.md`
|
|
|
+- Modify: `/Users/destiny/dev/emoon/emoon-terminal-client/package.json`
|
|
|
+- Review: all source files.
|
|
|
+
|
|
|
+- [ ] **Step 1: Add README**
|
|
|
+
|
|
|
+`README.md`:
|
|
|
+
|
|
|
+````md
|
|
|
+# 医梦统一入口客户端 Web Demo
|
|
|
+
|
|
|
+单应用 Web Demo,用于演示患者通过对话完成门诊挂号闭环。
|
|
|
+
|
|
|
+## 启动
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm install
|
|
|
+pnpm dev
|
|
|
+```
|
|
|
+
|
|
|
+访问:`http://127.0.0.1:5173`
|
|
|
+
|
|
|
+## 演示范围
|
|
|
+
|
|
|
+- 对话优先,不展示左侧功能菜单。
|
|
|
+- 右侧 idle 状态展示静态医生助手形象。
|
|
|
+- 完整挂号闭环:科室、医生、时间、确认、Mock 支付、挂号成功。
|
|
|
+- Mock 结果必须展示“联调演示”。
|
|
|
+
|
|
|
+## 验证
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm build
|
|
|
+pnpm test:unit
|
|
|
+pnpm test:e2e
|
|
|
+```
|
|
|
+
|
|
|
+## 重要边界
|
|
|
+
|
|
|
+- 前端不传 `agentId`。
|
|
|
+- 前端不直连 Dify、HIS、MCP Tool、舌诊底层接口。
|
|
|
+- 所有卡片动作都必须带 `idempotencyKey`。
|
|
|
+- 真实签名密钥不得放进 `VITE_*` 环境变量。
|
|
|
+```
|
|
|
+````
|
|
|
+
|
|
|
+- [ ] **Step 2: Run full validation**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm build
|
|
|
+pnpm test:unit
|
|
|
+pnpm test:e2e
|
|
|
+```
|
|
|
+
|
|
|
+Expected: all commands pass.
|
|
|
+
|
|
|
+- [ ] **Step 3: Start dev server for user review**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+pnpm dev
|
|
|
+```
|
|
|
+
|
|
|
+Expected: dev server starts at `http://127.0.0.1:5173`.
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit final frontend demo**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+git add .
|
|
|
+git commit -m "feat: build terminal web registration demo"
|
|
|
+```
|
|
|
+
|
|
|
+Expected: commit succeeds in `/Users/destiny/dev/emoon/emoon-terminal-client`.
|
|
|
+
|
|
|
+## Self-Review
|
|
|
+
|
|
|
+Spec coverage:
|
|
|
+
|
|
|
+- A+B mixed layout is covered by `AppShell`, `AssistantContextPanel`, `ConversationPanel`, and `ContextPanel`.
|
|
|
+- Brand colors are covered by `tokens.css`.
|
|
|
+- Dialogue-first behavior is enforced by removing left workflow buttons and keeping intent entry in the center conversation input.
|
|
|
+- Right idle panel shows only static doctor image via `DoctorAssistantFigure`.
|
|
|
+- Voice and send buttons stay in `ChatInput`.
|
|
|
+- Registration card flow is implemented through card components and demo state transitions.
|
|
|
+- Mock labels are required in `PaymentQrCard` and `AppointmentSuccessCard`.
|
|
|
+- Desktop QA is covered by Playwright at `1440x900` and `1920x1080`.
|
|
|
+
|
|
|
+Placeholder scan:
|
|
|
+
|
|
|
+- No unresolved placeholder instructions remain.
|
|
|
+- Demo mode is explicit and bounded.
|
|
|
+
|
|
|
+Type consistency:
|
|
|
+
|
|
|
+- `CardInstance`, `CardAction`, `TaskState`, `contextPanelMode`, `cardKey`, `actionName`, and `idempotencyKey` names match across tasks.
|
|
|
+
|
|
|
+## Execution Choice
|
|
|
+
|
|
|
+Plan complete and saved to:
|
|
|
+
|
|
|
+```text
|
|
|
+docs/superpowers/plans/2026-06-02-terminal-web-demo.md
|
|
|
+```
|
|
|
+
|
|
|
+Two execution options:
|
|
|
+
|
|
|
+1. **Subagent-Driven (recommended)** - dispatch a fresh subagent per task, review between tasks, fast iteration.
|
|
|
+2. **Inline Execution** - execute tasks in this session using executing-plans, batch execution with checkpoints.
|
|
|
+
|
|
|
+Recommended for this frontend: Subagent-Driven for Tasks 1-8, because layout, state/API, cards, and Playwright QA are distinct review surfaces.
|