doc_id: DEV-202606-002 feature_id: FEAT-202606-001-unified-entry-client type: dev-progress title: Terminal Web Demo Implementation Plan status: implemented owner: 医梦研发团队 created_at: 2026-06-02 updated_at: 2026-06-02 reviewers: [] related_docs:
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.
Read these before implementation:
docs/initiatives/FEAT-202606-001-unified-entry-client/design/终端Web演示设计.mddocs/initiatives/FEAT-202606-001-unified-entry-client/MVP接口契约.mddocs/initiatives/FEAT-202606-001-unified-entry-client/design/前端工程启动指南.mddocs/initiatives/FEAT-202606-001-unified-entry-client/前端接入指引.mddocs/initiatives/FEAT-202606-001-unified-entry-client/dev-progress/统一入口挂号演示实施计划.mdCreate or work in:
/Users/destiny/dev/emoon/emoon-terminal-client
This frontend repository is intentionally separate from:
/Users/destiny/dev/emoon/emoon-backend
Do not add frontend source files to the backend Maven reactor.
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
Files:
/Users/destiny/dev/emoon/emoon-terminal-client/package.json/Users/destiny/dev/emoon/emoon-terminal-client/vite.config.ts/Users/destiny/dev/emoon/emoon-terminal-client/tsconfig.json/Users/destiny/dev/emoon/emoon-terminal-client/index.html/Users/destiny/dev/emoon/emoon-terminal-client/src/main.tsCreate: /Users/destiny/dev/emoon/emoon-terminal-client/src/App.vue
[ ] Step 1: Create package manifest
Use this dependency baseline:
{
"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"
}
}
Run:
cd /Users/destiny/dev/emoon/emoon-terminal-client
pnpm install
Expected: pnpm-lock.yaml is created and install succeeds.
src/main.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:
<template>
<AppShell />
</template>
<script setup lang="ts">
import AppShell from './layouts/AppShell.vue';
</script>
Run:
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
Files:
/Users/destiny/dev/emoon/emoon-terminal-client/src/theme/tokens.css/Users/destiny/dev/emoon/emoon-terminal-client/src/assets/doctor-assistant.png/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/DoctorAssistantFigure.vueTest: /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:
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.
src/theme/tokens.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;
}
tests/unit/doctorAssistantFigure.spec.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);
});
});
Run:
pnpm vitest run tests/unit/doctorAssistantFigure.spec.ts
Expected: fails because DoctorAssistantFigure.vue does not exist.
src/layouts/DoctorAssistantFigure.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;
}
Run:
pnpm vitest run tests/unit/doctorAssistantFigure.spec.ts
Expected: pass.
Commit: feat: add brand tokens and doctor assistant asset
Files:
/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/AppShell.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/AssistantContextPanel.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/ContextPanel.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/RegistrationProgress.vueTest: /Users/destiny/dev/emoon/emoon-terminal-client/tests/unit/appShell.spec.ts
[ ] Step 1: Write failing layout test
tests/unit/appShell.spec.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('智能挂号');
});
});
Run:
pnpm vitest run tests/unit/appShell.spec.ts
Expected: fails until layout files exist.
src/layouts/AssistantContextPanel.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;
}
src/layouts/AppShell.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;
}
Run:
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
Files:
/Users/destiny/dev/emoon/emoon-terminal-client/src/state/terminalStore.ts/Users/destiny/dev/emoon/emoon-terminal-client/src/api/types.ts/Users/destiny/dev/emoon/emoon-terminal-client/src/api/demoFixtures.ts/Users/destiny/dev/emoon/emoon-terminal-client/src/demo/demoMode.tsTest: /Users/destiny/dev/emoon/emoon-terminal-client/tests/unit/terminalStore.spec.ts
[ ] Step 1: Write failing store tests
tests/unit/terminalStore.spec.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');
});
});
Run:
pnpm vitest run tests/unit/terminalStore.spec.ts
Expected: fails until store exists.
src/api/types.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;
}
src/state/terminalStore.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 = '';
}
}
});
src/api/demoFixtures.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: '选择时间' }]
}
};
Run:
pnpm vitest run tests/unit/terminalStore.spec.ts
Expected: pass.
Commit: feat: add terminal state and demo fixtures
Files:
/Users/destiny/dev/emoon/emoon-terminal-client/src/chat/ConversationPanel.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/chat/MessageBubble.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/chat/ChatInput.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/api/sse.tsCreate: /Users/destiny/dev/emoon/emoon-terminal-client/tests/unit/sse.spec.ts
[ ] Step 1: Write failing SSE parser test
tests/unit/sse.spec.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 数据解析失败' } }]);
});
});
Run:
pnpm vitest run tests/unit/sse.spec.ts
Expected: fails until src/api/sse.ts exists.
src/api/sse.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 };
}
src/chat/MessageBubble.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:
<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;
}
src/chat/ConversationPanel.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;
}
Run:
pnpm vitest run tests/unit/sse.spec.ts
Expected: pass.
Commit: feat: add conversation panel and sse parser
Files:
/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/CardRenderer.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/DepartmentSelectionCard.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/DoctorSelectionCard.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/TimeSlotSelectionCard.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/ConfirmAppointmentCard.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/PaymentQrCard.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/AppointmentSuccessCard.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/cards/ErrorCard.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/ContextPanel.vue/Users/destiny/dev/emoon/emoon-terminal-client/src/layouts/RegistrationProgress.vueTest: /Users/destiny/dev/emoon/emoon-terminal-client/tests/unit/cardRenderer.spec.ts
[ ] Step 1: Write failing card renderer test
tests/unit/cardRenderer.spec.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('神经内科');
});
});
Run:
pnpm vitest run tests/unit/cardRenderer.spec.ts
Expected: fails until cards exist.
src/layouts/ContextPanel.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;
}
src/layouts/RegistrationProgress.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);
}
src/cards/CardRenderer.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:
<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;
}
src/cards/DoctorSelectionCard.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:
<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:
<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:
<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:
<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:
<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; }
Run:
pnpm vitest run tests/unit/cardRenderer.spec.ts
Expected: pass.
Commit: feat: implement registration context panel cards
Files:
/Users/destiny/dev/emoon/emoon-terminal-client/src/api/client.ts/Users/destiny/dev/emoon/emoon-terminal-client/src/api/hmac.ts/Users/destiny/dev/emoon/emoon-terminal-client/src/demo/demoMode.tsTest: /Users/destiny/dev/emoon/emoon-terminal-client/tests/unit/apiClient.spec.ts
[ ] Step 1: Write failing HMAC helper test
tests/unit/apiClient.spec.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');
});
});
Run:
pnpm vitest run tests/unit/apiClient.spec.ts
Expected: fails until hmac.ts exists.
src/api/hmac.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.
src/api/client.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;
}
Run:
pnpm vitest run tests/unit/apiClient.spec.ts
Expected: pass.
Commit: feat: add api client demo and real modes
Files:
/Users/destiny/dev/emoon/emoon-terminal-client/playwright.config.ts/Users/destiny/dev/emoon/emoon-terminal-client/tests/e2e/visual-layout.spec.tsCreate: /Users/destiny/dev/emoon/emoon-terminal-client/tests/e2e/registration-flow.spec.ts
[ ] Step 1: Add Playwright config
playwright.config.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 } }
}
]
});
tests/e2e/visual-layout.spec.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);
});
tests/e2e/registration-flow.spec.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();
});
Run:
pnpm test:e2e
Expected: both viewport projects pass.
Commit: test: add terminal web demo playwright qa
Files:
/Users/destiny/dev/emoon/emoon-terminal-client/README.md/Users/destiny/dev/emoon/emoon-terminal-client/package.jsonReview: all source files.
[ ] Step 1: Add README
README.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_*` 环境变量。
```
Run:
pnpm build
pnpm test:unit
pnpm test:e2e
Expected: all commands pass.
Run:
pnpm dev
Expected: dev server starts at http://127.0.0.1:5173.
Run:
git add .
git commit -m "feat: build terminal web registration demo"
Expected: commit succeeds in /Users/destiny/dev/emoon/emoon-terminal-client.
Spec coverage:
AppShell, AssistantContextPanel, ConversationPanel, and ContextPanel.tokens.css.DoctorAssistantFigure.ChatInput.PaymentQrCard and AppointmentSuccessCard.1440x900 and 1920x1080.Placeholder scan:
Type consistency:
CardInstance, CardAction, TaskState, contextPanelMode, cardKey, actionName, and idempotencyKey names match across tasks.Plan complete and saved to:
docs/initiatives/FEAT-202606-001-unified-entry-client/dev-progress/终端Web演示实施计划.md
Two execution options:
Recommended for this frontend: Subagent-Driven for Tasks 1-8, because layout, state/API, cards, and Playwright QA are distinct review surfaces.