Jelajahi Sumber

feat: 全量重构 Web Demo UI 对齐设计原型

- tokens.css: 完整品牌 token 体系(色板/字体/间距/圆角)
- 布局三栏 260px/1fr/380px,去除 gaps,新增对话头栏+在线状态
- 左面板: SVG 十字头像 + 例句 + 能力标签 + 联调提示框
- 对话区: 空状态 + 消息动画 + mini 卡片预览可点击回溯
- 输入框: 圆形 SVG 按钮 + 语音 tooltip + focus 态
- 右侧面板: PNG 医生形象 + 纵向时间线替代进度条
- 7 张卡片全部对齐设计原型(科室/医生/时间/确认/支付/成功/错误)
- 加载骨架屏 + 断连覆盖层
- store 新增 cardHistory/viewingCardId/displayedCard/connectionStatus

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
zhaohan 1 bulan lalu
induk
melakukan
2b798265dd

+ 28 - 17
src/api/client.ts

@@ -1,9 +1,13 @@
 import { demoCards } from './demoFixtures';
-import { buildDemoAuthHeaders } from './hmac';
+import { buildAuthHeaders } 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';
+const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:6040/api/v1';
+const DEMO_MODE = import.meta.env.VITE_DEMO_MODE === 'true';
+
+function isSuccess(body: { code: unknown }): boolean {
+  return body.code === 0 || body.code === 200;
+}
 
 export async function fetchCard(cardInstanceId: string): Promise<CardInstance> {
   if (DEMO_MODE) {
@@ -12,38 +16,45 @@ export async function fetchCard(cardInstanceId: string): Promise<CardInstance> {
     return card;
   }
 
-  const path = `/cards/${cardInstanceId}`;
-  const headers = await buildDemoAuthHeaders('GET', `/api/v1${path}`, '');
-  const response = await fetch(`${API_BASE}${path}`, { headers });
+  const path = `/api/v1/cards/${cardInstanceId}`;
+  const headers = await buildAuthHeaders('GET', path, '');
+  const response = await fetch(`${API_BASE}/cards/${cardInstanceId}`, { headers });
   const body = await response.json();
-  if (!response.ok || body.code !== 200) throw new Error(body.msg ?? '查询卡片失败');
+  if (!response.ok || !isSuccess(body)) throw new Error(body.msg ?? '查询卡片失败');
   return body.data;
 }
 
+export interface CardActionResult {
+  status: string;
+  actionId: string;
+  nextCard?: CardInstance;
+}
+
 export async function submitCardAction(
   cardInstanceId: string,
   actionName: string,
   payload: Record<string, unknown>,
-  confirm = true
-) {
+  idempotencyKey?: string,
+): Promise<CardActionResult> {
+  const key = idempotencyKey ?? `${cardInstanceId}-${actionName}-${Date.now()}`;
   const body = JSON.stringify({
-    idempotencyKey: `${cardInstanceId}-${actionName}-${JSON.stringify(payload)}`,
-    confirm,
-    payload
+    idempotencyKey: key,
+    confirm: true,
+    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}`, {
+  const path = `/api/v1/cards/${cardInstanceId}/actions/${actionName}`;
+  const headers = await buildAuthHeaders('POST', path, body);
+  const response = await fetch(`${API_BASE}/cards/${cardInstanceId}/actions/${actionName}`, {
     method: 'POST',
     headers: { ...headers, 'Content-Type': 'application/json' },
-    body
+    body,
   });
   const data = await response.json();
-  if (!response.ok || data.code !== 200) throw new Error(data.msg ?? '提交卡片动作失败');
+  if (!response.ok || !isSuccess(data)) throw new Error(data.msg ?? '提交卡片动作失败');
   return data.data;
 }

+ 8 - 9
src/api/hmac.ts

@@ -1,3 +1,5 @@
+const DEMO_BEARER_TOKEN = 'demo-token-123';
+
 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))
@@ -5,15 +7,12 @@ export async function sha256Hex(data: string): Promise<string> {
     .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);
-
+export async function buildAuthHeaders(
+  _method: string,
+  _path: string,
+  _body: string
+): Promise<Record<string, string>> {
   return {
-    'X-Emoon-Access-Key': 'pk-web-demo',
-    'X-Emoon-Timestamp': timestamp,
-    'X-Emoon-Nonce': nonce,
-    'X-Emoon-Signature': `demo-signature:${method}:${path}:${bodyHash}`
+    Authorization: `Bearer ${DEMO_BEARER_TOKEN}`,
   };
 }

+ 83 - 0
src/api/sse.ts

@@ -1,10 +1,22 @@
 import type { SseEventType } from './types';
+import { buildAuthHeaders } from './hmac';
 
 export interface ParsedSseEvent {
   type: SseEventType;
   data: Record<string, unknown>;
 }
 
+export interface ChatStreamOptions {
+  deviceId: string;
+  deviceType: string;
+  text: string;
+  conversationId?: string;
+  clientMessageId?: string;
+  onEvent: (event: ParsedSseEvent) => void;
+  onError: (error: Error) => void;
+  onComplete: () => void;
+}
+
 export function parseSseChunk(previous: string, chunk: string): { events: ParsedSseEvent[]; remainder: string } {
   const buffer = previous + chunk;
   const rawEvents = buffer.split('\n\n');
@@ -26,3 +38,74 @@ export function parseSseChunk(previous: string, chunk: string): { events: Parsed
 
   return { events, remainder };
 }
+
+const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:6040/api/v1';
+
+export function connectChatStream(options: ChatStreamOptions): AbortController {
+  const controller = new AbortController();
+  const body = JSON.stringify({
+    deviceContext: {
+      deviceId: options.deviceId,
+      deviceType: options.deviceType,
+    },
+    message: {
+      type: 'text',
+      text: options.text,
+      clientMessageId: options.clientMessageId ?? crypto.randomUUID(),
+    },
+    conversationId: options.conversationId || undefined,
+  });
+
+  const path = '/api/v1/agent/chat/stream';
+  const url = `${API_BASE}/agent/chat/stream`;
+
+  buildAuthHeaders('POST', path, body).then((headers) => {
+    if (controller.signal.aborted) return;
+
+    fetch(url, {
+      method: 'POST',
+      headers: { ...headers, 'Content-Type': 'application/json', Accept: 'text/event-stream' },
+      body,
+      signal: controller.signal,
+    })
+      .then(async (response) => {
+        if (!response.ok) {
+          const errText = await response.text().catch(() => '');
+          options.onError(new Error(errText || `SSE 连接失败: HTTP ${response.status}`));
+          return;
+        }
+        const reader = response.body?.getReader();
+        if (!reader) {
+          options.onError(new Error('浏览器不支持 ReadableStream'));
+          return;
+        }
+        const decoder = new TextDecoder();
+        let remainder = '';
+
+        while (true) {
+          const { done, value } = await reader.read();
+          if (done) break;
+
+          const chunk = decoder.decode(value, { stream: true });
+          const result = parseSseChunk(remainder, chunk);
+          remainder = result.remainder;
+
+          for (const event of result.events) {
+            if (event.type === 'completed') {
+              options.onComplete();
+              return;
+            }
+            options.onEvent(event);
+          }
+        }
+        options.onComplete();
+      })
+      .catch((err) => {
+        if (err.name !== 'AbortError') {
+          options.onError(err);
+        }
+      });
+  });
+
+  return controller;
+}

TEMPAT SAMPAH
src/assets/doctor-assistant.png


+ 206 - 16
src/cards/AppointmentSuccessCard.vue

@@ -1,28 +1,218 @@
 <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 class="success-card">
+    <div class="success-icon">
+      <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--accent-light)" stroke-width="2.2">
+        <polyline points="20,6 9,17 4,12" />
+      </svg>
+    </div>
+    <div class="success-title">挂号成功</div>
+    <div class="success-subtitle">联调演示</div>
+
+    <div class="appt-grid">
+      <div class="appt-cell" v-if="patientName">
+        <div class="lb">患者</div>
+        <div class="vl">{{ patientName }}</div>
+      </div>
+      <div class="appt-cell" v-if="departmentName">
+        <div class="lb">科室</div>
+        <div class="vl">{{ departmentName }}</div>
+      </div>
+      <div class="appt-cell" v-if="doctorDisplay">
+        <div class="lb">医生</div>
+        <div class="vl">{{ doctorDisplay }}</div>
+      </div>
+      <div class="appt-cell" v-if="visitDate">
+        <div class="lb">就诊日期</div>
+        <div class="vl hl">{{ visitDate }}</div>
+      </div>
+      <div class="appt-cell" v-if="visitTime">
+        <div class="lb">时段</div>
+        <div class="vl">{{ visitTime }}</div>
+      </div>
+      <div class="appt-cell" v-if="room">
+        <div class="lb">诊室</div>
+        <div class="vl">{{ room }}</div>
+      </div>
+      <div class="appt-cell full" v-if="queueNo">
+        <div class="lb">就诊序号</div>
+        <div class="vl queue-no">{{ queueNo }}</div>
+      </div>
+    </div>
+
+    <div v-if="appointmentNo" class="appt-no-line">预约号 <span class="apt-no">{{ appointmentNo }}</span></div>
+
+    <div class="tips-box">
+      <div class="tl">温馨提示</div>
+      <div class="tt">请提前 <strong>15 分钟</strong>到达候诊区签到。就诊时请出示二维码凭证。</div>
+    </div>
+
+    <button class="btn-accent" @click="$emit('finish')">完成</button>
   </section>
 </template>
 
 <script setup lang="ts">
+import { computed } from 'vue';
 import type { CardInstance } from '../api/types';
+import { useTerminalStore } from '../state/terminalStore';
 
-defineProps<{ card: CardInstance }>();
+const props = defineProps<{ card: CardInstance }>();
 defineEmits<{ finish: [] }>();
+
+const store = useTerminalStore();
+const data = computed(() => props.card.cardData);
+
+const patientName = computed(() => (data.value.patientName as string) || '');
+const departmentName = computed(() => (data.value.departmentName as string) || store.selection.departmentName || '');
+const doctorDisplay = computed(() => {
+  const name = (data.value.doctorName as string) || store.selection.doctorName || '';
+  const title = (data.value.doctorTitle as string) || store.selection.doctorTitle || '';
+  return name ? (name + (title ? ` ${title}` : '')) : '';
+});
+const visitDate = computed(() => (data.value.visitDate as string) || (data.value.visitTime as string) || '');
+const visitTime = computed(() => (data.value.timePeriod as string) || store.selection.timePeriod || '');
+const room = computed(() => (data.value.room as string) || '');
+const queueNo = computed(() => (data.value.queueNo as string) || (data.value.appointmentNo as string) || '');
+const appointmentNo = computed(() => (data.value.appointmentNo as string) || (data.value.appointmentNoStr as string) || '');
 </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; }
+.success-card {
+  border: 1px solid var(--border);
+  border-radius: var(--radius-lg);
+  padding: 24px 18px 20px;
+  text-align: center;
+  position: relative;
+  overflow: hidden;
+}
+
+.success-card::before {
+  content: '';
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  height: 4px;
+  background: linear-gradient(90deg, var(--accent), var(--accent-light));
+}
+
+.success-icon {
+  width: 52px;
+  height: 52px;
+  border-radius: 50%;
+  background: color-mix(in oklch, var(--accent-light) 25%, transparent);
+  display: grid;
+  place-items: center;
+  margin: 0 auto 12px;
+}
+
+.success-title {
+  font-family: var(--font-display);
+  font-size: var(--fs-h3);
+  font-weight: 600;
+  letter-spacing: -0.01em;
+  margin-bottom: 4px;
+}
+
+.success-subtitle {
+  font-size: 11px;
+  color: var(--muted);
+  font-family: var(--font-mono);
+  margin-bottom: 14px;
+}
+
+.appt-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 1px;
+  background: var(--border);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  overflow: hidden;
+  text-align: left;
+}
+
+.appt-cell {
+  background: var(--surface);
+  padding: 10px 14px;
+}
+
+.appt-cell .lb {
+  font-size: 10px;
+  color: var(--muted);
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  margin-bottom: 1px;
+}
+
+.appt-cell .vl {
+  font-size: var(--fs-sm);
+  font-weight: 500;
+}
+
+.appt-cell .vl.hl {
+  font-family: var(--font-display);
+  color: var(--accent);
+  font-size: 18px;
+}
+
+.appt-cell .vl.queue-no {
+  font-family: var(--font-mono);
+  font-weight: 600;
+  color: var(--accent);
+}
+
+.appt-cell.full {
+  grid-column: 1 / -1;
+}
+
+.appt-no-line {
+  margin-top: 14px;
+  font-size: var(--fs-sm);
+  color: var(--muted);
+}
+
+.apt-no {
+  font-family: var(--font-mono);
+  font-weight: 500;
+  color: var(--fg);
+}
+
+.tips-box {
+  margin-top: 12px;
+  padding: 12px 14px;
+  background: var(--accent-faint);
+  border-radius: var(--radius-sm);
+  text-align: left;
+}
+
+.tips-box .tl {
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--accent);
+  margin-bottom: 2px;
+}
+
+.tips-box .tt {
+  font-size: var(--fs-xs);
+  color: var(--muted);
+  line-height: 1.5;
+}
+
+.btn-accent {
+  display: block;
+  width: 100%;
+  padding: 12px;
+  background: var(--accent);
+  color: var(--surface);
+  border-radius: var(--radius-sm);
+  font-size: var(--fs-body);
+  font-weight: 500;
+  text-align: center;
+  margin-top: 14px;
+  transition: background 0.15s ease;
+}
+
+.btn-accent:hover {
+  background: color-mix(in oklch, var(--accent) 85%, black);
+}
 </style>

+ 1 - 1
src/cards/CardRenderer.vue

@@ -19,5 +19,5 @@ import PaymentQrCard from './PaymentQrCard.vue';
 import TimeSlotSelectionCard from './TimeSlotSelectionCard.vue';
 
 defineProps<{ card: CardInstance }>();
-const emit = defineEmits<{ action: [actionName: string]; finish: [] }>();
+const emit = defineEmits<{ action: [data: { actionName: string; payload: Record<string, unknown> }]; finish: [] }>();
 </script>

+ 143 - 21
src/cards/ConfirmAppointmentCard.vue

@@ -1,41 +1,163 @@
 <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 class="confirm-card">
+    <div class="confirm-title">确认预约信息</div>
+    <div class="confirm-summary">
+      <div class="confirm-row" v-if="store.selection.departmentName || summaryObj.departmentName">
+        <span class="lbl">科室</span>
+        <span class="val">{{ store.selection.departmentName || summaryObj.departmentName }}</span>
+      </div>
+      <div class="confirm-row" v-if="doctorDisplay">
+        <span class="lbl">医生</span>
+        <span class="val">{{ doctorDisplay }}</span>
+      </div>
+      <div class="confirm-row" v-if="store.selection.timePeriod || summaryObj.visitTime">
+        <span class="lbl">时段</span>
+        <span class="val">{{ store.selection.timePeriod || summaryObj.visitTime }}</span>
+      </div>
+      <div class="confirm-row" v-if="room">
+        <span class="lbl">诊室</span>
+        <span class="val">{{ room }}</span>
+      </div>
+      <div class="confirm-row">
+        <span class="lbl">费用</span>
+        <span class="val price">¥{{ amount }}</span>
+      </div>
+    </div>
+    <div class="confirm-btns">
+      <button class="btn-accent" @click="$emit('action', { actionName: 'confirm_appointment', payload: {} })">确认挂号</button>
+      <button class="btn-secondary" @click="$emit('action', { actionName: 'restart', payload: {} })">重新选择</button>
+    </div>
+    <p v-if="card.cardData.lockExpiresAt" class="lock-notice">号源锁定至 {{ card.cardData.lockExpiresAt }},超时自动释放</p>
   </section>
 </template>
 
 <script setup lang="ts">
 import { computed } from 'vue';
 import type { CardInstance } from '../api/types';
+import { useTerminalStore } from '../state/terminalStore';
 
 const props = defineProps<{ card: CardInstance }>();
-defineEmits<{ action: [actionName: string] }>();
+defineEmits<{ action: [data: { actionName: string; payload: Record<string, unknown> }] }>();
 
-interface Summary {
+const store = useTerminalStore();
+
+interface SummaryObj {
   departmentName?: string;
   doctorName?: string;
   visitTime?: string;
+  room?: string;
   amount?: number;
 }
 
-const summary = computed(() => (props.card.cardData.summary as Summary) ?? {});
+const rawSummary = computed(() => props.card.cardData.summary);
+const summaryObj = computed<SummaryObj>(() => {
+  if (typeof rawSummary.value === 'object' && rawSummary.value !== null) {
+    return rawSummary.value as SummaryObj;
+  }
+  return {};
+});
+
+const doctorDisplay = computed(() => {
+  const name = store.selection.doctorName || summaryObj.value.doctorName;
+  const title = store.selection.doctorTitle;
+  return name ? (name + (title ? ` ${title}` : '')) : '';
+});
+
+const room = computed(() => store.selection.room || summaryObj.value.room || '');
+const amount = computed(() => summaryObj.value.amount ?? props.card.cardData.amount ?? '25.00');
 </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; }
+.confirm-card {
+  display: flex;
+  flex-direction: column;
+}
+
+.confirm-title {
+  font-size: var(--fs-body);
+  font-weight: 600;
+  margin-bottom: 14px;
+}
+
+.confirm-summary {
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  overflow: hidden;
+}
+
+.confirm-row {
+  display: flex;
+  justify-content: space-between;
+  padding: 12px 14px;
+  font-size: var(--fs-sm);
+  align-items: center;
+}
+
+.confirm-row + .confirm-row {
+  border-top: 1px solid var(--border);
+}
+
+.confirm-row .lbl {
+  color: var(--muted);
+}
+
+.confirm-row .val {
+  font-weight: 500;
+  text-align: right;
+}
+
+.confirm-row .val.price {
+  font-family: var(--font-mono);
+  color: var(--accent);
+  font-weight: 600;
+  font-size: var(--fs-body);
+}
+
+.confirm-btns {
+  margin-top: 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.btn-accent {
+  display: block;
+  width: 100%;
+  padding: 12px;
+  background: var(--accent);
+  color: var(--surface);
+  border-radius: var(--radius-sm);
+  font-size: var(--fs-body);
+  font-weight: 500;
+  text-align: center;
+  transition: background 0.15s ease;
+}
+
+.btn-accent:hover {
+  background: color-mix(in oklch, var(--accent) 85%, black);
+}
+
+.btn-secondary {
+  display: block;
+  width: 100%;
+  padding: 10px;
+  background: transparent;
+  color: var(--muted);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  font-size: var(--fs-sm);
+  text-align: center;
+  transition: background 0.15s ease;
+}
+
+.btn-secondary:hover {
+  background: var(--bg);
+}
+
+.lock-notice {
+  margin-top: 12px;
+  font-size: var(--fs-xs);
+  color: var(--warn-text);
+  text-align: center;
+}
 </style>

+ 83 - 42
src/cards/DepartmentSelectionCard.vue

@@ -1,11 +1,19 @@
 <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 class="dept-list">
+    <div
+      v-for="(dept, idx) in departments"
+      :key="dept.deptId"
+      :class="['dept-full', { rec: idx === 0 }]"
+      @click="select(dept)"
+    >
+      <span v-if="idx === 0" class="dept-badge">推荐</span>
+      <div class="dept-name">{{ dept.name }}</div>
+      <div v-if="dept.reason" class="dept-reason">{{ dept.reason }}</div>
+      <div class="dept-meta">
+        <span v-if="dept.doctorCount !== undefined">可约医生 <strong>{{ dept.doctorCount }}</strong> 位</span>
+        <span v-if="dept.slotCount !== undefined">明日号源 <strong>{{ dept.slotCount }}</strong> 个</span>
+      </div>
+    </div>
   </section>
 </template>
 
@@ -14,59 +22,92 @@ import { computed } from 'vue';
 import type { CardInstance } from '../api/types';
 
 const props = defineProps<{ card: CardInstance }>();
-defineEmits<{ action: [actionName: string] }>();
+const emit = defineEmits<{ action: [data: { actionName: string; payload: Record<string, unknown> }] }>();
 
 interface DepartmentOption {
-  departmentId: string;
+  deptId: string;
+  departmentId?: string;
   name: string;
-  reason: string;
+  reason?: string;
+  doctorCount?: number;
+  slotCount?: number;
 }
 
-const departments = computed(() => (props.card.cardData.departments as DepartmentOption[]) ?? []);
+const departments = computed(() => {
+  const raw = (props.card.cardData.departments as DepartmentOption[]) ?? [];
+  return raw.map((d) => ({
+    ...d,
+    deptId: d.deptId ?? d.departmentId ?? '',
+  }));
+});
+
+function select(dept: DepartmentOption) {
+  emit('action', {
+    actionName: 'select_department',
+    payload: { departmentId: dept.deptId, departmentName: dept.name },
+  });
+}
 </script>
 
+
 <style scoped>
-.business-card {
-  min-height: 0;
-  overflow: auto;
-  padding: 16px;
+.dept-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.dept-full {
   border: 1px solid var(--border);
-  border-radius: var(--radius-panel);
-  background: var(--surface);
+  border-radius: var(--radius-md);
+  padding: 18px;
+  cursor: pointer;
+  transition: border-color 0.15s ease, background 0.15s ease;
 }
 
-h2 {
-  margin: 0 0 12px;
-  font-size: 18px;
+.dept-full:hover {
+  border-color: var(--accent);
 }
 
-.option {
-  padding: 12px;
-  border: 1px solid var(--border);
-  border-radius: var(--radius-card);
-  margin-bottom: 10px;
-  background: var(--soft-tint);
+.dept-full.rec {
+  border-color: var(--accent);
+  background: var(--accent-faint);
 }
 
-h3 {
-  margin: 0;
-  color: var(--brand-primary);
+.dept-badge {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: var(--radius-pill);
+  background: var(--accent);
+  color: var(--surface);
+  font-size: 10px;
+  font-weight: 500;
+  margin-bottom: 8px;
 }
 
-p {
-  color: var(--text-secondary);
-  font-size: 12px;
-  line-height: 1.6;
+.dept-name {
+  font-size: var(--fs-body);
+  font-weight: 600;
+  letter-spacing: -0.01em;
+  margin-bottom: 4px;
 }
 
-button {
-  width: 100%;
-  border: 0;
-  border-radius: 999px;
-  padding: 9px 12px;
-  background: var(--brand-primary);
-  color: #ffffff;
-  font-weight: 900;
-  cursor: pointer;
+.dept-reason {
+  font-size: var(--fs-xs);
+  color: var(--muted);
+  line-height: 1.5;
+  margin-bottom: 10px;
+}
+
+.dept-meta {
+  display: flex;
+  gap: 16px;
+  font-size: 11px;
+  color: var(--muted);
+}
+
+.dept-meta strong {
+  color: var(--fg);
+  font-weight: 500;
 }
 </style>

+ 95 - 18
src/cards/DoctorSelectionCard.vue

@@ -1,12 +1,19 @@
 <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 class="doc-list">
+    <div
+      v-for="(doc, idx) in doctors"
+      :key="doc.doctorId"
+      :class="['doc-card', { rec: idx === 0 }]"
+      @click="select(doc)"
+    >
+      <div class="doc-av">{{ doc.name.charAt(0) }}</div>
+      <div class="doc-info">
+        <div class="doc-name">{{ doc.name }}</div>
+        <div class="doc-dept">{{ doc.title }}</div>
+        <div v-if="doc.specialty" class="doc-skill">擅长:{{ doc.specialty }}</div>
+      </div>
+      <span v-if="idx === 0" class="doc-rec-badge">推荐</span>
+    </div>
   </section>
 </template>
 
@@ -15,25 +22,95 @@ import { computed } from 'vue';
 import type { CardInstance } from '../api/types';
 
 const props = defineProps<{ card: CardInstance }>();
-defineEmits<{ action: [actionName: string] }>();
+const emit = defineEmits<{ action: [data: { actionName: string; payload: Record<string, unknown> }] }>();
 
 interface DoctorOption {
   doctorId: string;
   name: string;
   title: string;
-  specialty: string;
-  room: string;
+  specialty?: string;
+  room?: string;
 }
 
 const doctors = computed(() => (props.card.cardData.doctors as DoctorOption[]) ?? []);
+
+function select(doc: DoctorOption) {
+  emit('action', {
+    actionName: 'select_doctor',
+    payload: { doctorId: doc.doctorId, doctorName: doc.name, doctorTitle: doc.title },
+  });
+}
 </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; }
+.doc-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.doc-card {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 14px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  cursor: pointer;
+  transition: border-color 0.15s ease, background 0.15s ease;
+}
+
+.doc-card:hover {
+  border-color: var(--accent);
+}
+
+.doc-card.rec {
+  border-color: var(--accent);
+  background: var(--accent-faint);
+}
+
+.doc-av {
+  width: 44px;
+  height: 44px;
+  border-radius: var(--radius-sm);
+  background: var(--accent-soft);
+  display: grid;
+  place-items: center;
+  font-size: 16px;
+  color: var(--accent);
+  font-weight: 600;
+  flex-shrink: 0;
+  font-family: var(--font-display);
+}
+
+.doc-info {
+  flex: 1;
+  min-width: 0;
+}
+
+.doc-name {
+  font-size: var(--fs-body);
+  font-weight: 500;
+}
+
+.doc-dept {
+  font-size: var(--fs-xs);
+  color: var(--muted);
+}
+
+.doc-skill {
+  font-size: 11px;
+  color: var(--muted);
+  margin-top: 1px;
+}
+
+.doc-rec-badge {
+  font-size: 10px;
+  padding: 2px 7px;
+  border-radius: var(--radius-pill);
+  background: var(--accent);
+  color: var(--surface);
+  font-weight: 500;
+  white-space: nowrap;
+}
 </style>

+ 91 - 9
src/cards/ErrorCard.vue

@@ -1,9 +1,19 @@
 <template>
   <section class="error-card">
-    <h2>处理异常</h2>
-    <p>{{ message }}</p>
-    <small v-if="traceId">traceId: {{ traceId }}</small>
-    <button @click="$emit('restart')">重新开始</button>
+    <div class="error-icon">
+      <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+        <circle cx="12" cy="12" r="10" />
+        <line x1="12" y1="8" x2="12" y2="12" />
+        <line x1="12" y1="16" x2="12.01" y2="16" />
+      </svg>
+    </div>
+    <div class="error-title">处理失败</div>
+    <div class="error-desc">{{ message }}</div>
+    <div v-if="traceId" class="error-trace">traceId: {{ traceId }}</div>
+    <div class="error-btns">
+      <button class="btn-accent" @click="$emit('restart')">重试</button>
+      <button class="btn-outline" @click="$emit('restart')">重新开始</button>
+    </div>
   </section>
 </template>
 
@@ -13,9 +23,81 @@ 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; }
+.error-card {
+  border: 1px solid color-mix(in oklch, var(--danger) 30%, transparent);
+  background: color-mix(in oklch, var(--danger) 4%, transparent);
+  border-radius: var(--radius-md);
+  padding: 24px 20px;
+  text-align: center;
+}
+
+.error-icon {
+  width: 48px;
+  height: 48px;
+  border-radius: 50%;
+  background: color-mix(in oklch, var(--danger) 15%, transparent);
+  display: grid;
+  place-items: center;
+  margin: 0 auto 12px;
+  color: var(--danger);
+}
+
+.error-title {
+  font-size: var(--fs-h3);
+  font-weight: 600;
+  letter-spacing: -0.01em;
+  margin-bottom: 4px;
+}
+
+.error-desc {
+  font-size: var(--fs-sm);
+  color: var(--muted);
+  margin-bottom: 6px;
+  line-height: 1.5;
+}
+
+.error-trace {
+  font-size: 10px;
+  color: var(--muted);
+  font-family: var(--font-mono);
+  margin-bottom: 16px;
+  padding: 6px 10px;
+  background: rgba(0, 0, 0, 0.03);
+  border-radius: 4px;
+  word-break: break-all;
+}
+
+.error-btns {
+  display: flex;
+  gap: 8px;
+  justify-content: center;
+}
+
+.btn-accent {
+  padding: 10px 20px;
+  background: var(--accent);
+  color: var(--surface);
+  border-radius: var(--radius-sm);
+  font-size: var(--fs-sm);
+  font-weight: 500;
+  transition: background 0.15s ease;
+}
+
+.btn-accent:hover {
+  background: color-mix(in oklch, var(--accent) 85%, black);
+}
+
+.btn-outline {
+  padding: 10px 20px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  font-size: var(--fs-sm);
+  color: var(--muted);
+  background: transparent;
+  transition: background 0.15s ease;
+}
+
+.btn-outline:hover {
+  background: var(--bg);
+}
 </style>

+ 128 - 15
src/cards/PaymentQrCard.vue

@@ -1,29 +1,142 @@
 <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 class="payment-section">
+    <div class="payment-card">
+      <div class="payment-badge">联调演示 &middot; Mock 支付</div>
+      <div class="payment-amount">¥{{ amount }}</div>
+      <div class="payment-label">{{ doctorLabel }}</div>
+      <div class="qr-placeholder" />
+      <div class="qr-note">微信 / 支付宝 扫码支付</div>
+    </div>
+    <div class="tips-box">
+      <div class="tl">支付说明</div>
+      <div class="tt">当前为联调演示支付,不会产生真实扣费。支付成功后系统将自动确认预约。</div>
+    </div>
+    <button class="btn-accent" @click="$emit('action', { actionName: 'mock_payment_paid', payload: { orderId } })">模拟支付完成</button>
   </section>
 </template>
 
 <script setup lang="ts">
 import { computed } from 'vue';
 import type { CardInstance } from '../api/types';
+import { useTerminalStore } from '../state/terminalStore';
 
 const props = defineProps<{ card: CardInstance }>();
-defineEmits<{ action: [actionName: string] }>();
+defineEmits<{ action: [data: { actionName: string; payload: Record<string, unknown> }] }>();
 
-const qrText = computed(() => String(props.card.cardData.qrContent ?? 'demo-payment://orderId=unknown'));
+const store = useTerminalStore();
+
+const amount = computed(() => props.card.cardData.amountYuan ?? props.card.cardData.amount ?? '25.00');
+const orderId = computed(() => String(props.card.cardData.orderId ?? 'unknown'));
+const doctorLabel = computed(() => {
+  const dept = store.selection.departmentName;
+  const doc = store.selection.doctorName;
+  if (dept && doc) return `${dept} · ${doc}`;
+  if (doc) return doc;
+  return '';
+});
 </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; }
+.payment-section {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.payment-card {
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  padding: 24px 20px;
+  text-align: center;
+}
+
+.payment-badge {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: var(--radius-pill);
+  background: var(--warn-bg);
+  color: var(--warn-text);
+  font-size: 10px;
+  font-weight: 600;
+  margin-bottom: 12px;
+}
+
+.payment-amount {
+  font-family: var(--font-mono);
+  font-size: 28px;
+  font-weight: 600;
+  color: var(--accent);
+  margin-bottom: 4px;
+}
+
+.payment-label {
+  font-size: var(--fs-sm);
+  color: var(--muted);
+  margin-bottom: 16px;
+}
+
+.qr-placeholder {
+  margin: 0 auto 16px;
+  width: 130px;
+  height: 130px;
+  background: var(--bg);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  display: grid;
+  place-items: center;
+  position: relative;
+}
+
+.qr-placeholder::before {
+  content: '';
+  width: 96px;
+  height: 96px;
+  background:
+    repeating-linear-gradient(0deg, var(--accent), var(--accent) 6px, transparent 6px, transparent 12px),
+    repeating-linear-gradient(90deg, var(--accent), var(--accent) 6px, transparent 6px, transparent 12px);
+  border-radius: 4px;
+  opacity: 0.3;
+}
+
+.qr-note {
+  font-size: 10px;
+  color: var(--warn-text);
+  text-align: center;
+}
+
+.tips-box {
+  padding: 12px 14px;
+  background: var(--accent-faint);
+  border-radius: var(--radius-sm);
+}
+
+.tips-box .tl {
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--accent);
+  margin-bottom: 2px;
+}
+
+.tips-box .tt {
+  font-size: var(--fs-xs);
+  color: var(--muted);
+  line-height: 1.5;
+}
+
+.btn-accent {
+  display: block;
+  width: 100%;
+  padding: 12px;
+  background: var(--accent);
+  color: var(--surface);
+  border-radius: var(--radius-sm);
+  font-size: var(--fs-body);
+  font-weight: 500;
+  text-align: center;
+  transition: background 0.15s ease;
+}
+
+.btn-accent:hover {
+  background: color-mix(in oklch, var(--accent) 85%, black);
+}
 </style>

+ 91 - 16
src/cards/TimeSlotSelectionCard.vue

@@ -1,35 +1,110 @@
 <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 class="time-card">
+    <div class="time-date">6月5日(周三)</div>
+    <div class="time-grid">
+      <div
+        v-for="slot in slots"
+        :key="slot.slotId"
+        :class="['time-slot', { pick: selectedSlot === slot.slotId }]"
+        @click="selectSlot(slot)"
+      >
+        {{ slot.timePeriod }}
+        <div class="time-slot-meta">余{{ slot.remain ?? slot.remaining ?? '--' }}号 &middot; ¥{{ amount }}</div>
+      </div>
+    </div>
+    <div v-if="doctorName || room" class="time-note">
+      <span v-if="doctorName">{{ doctorName }}</span>
+      <span v-if="doctorName && room"> &middot; </span>
+      <span v-if="room">{{ room }}</span>
+      <span> &middot; 挂号费 ¥{{ amount }}</span>
+    </div>
   </section>
 </template>
 
 <script setup lang="ts">
-import { computed } from 'vue';
+import { computed, ref } from 'vue';
 import type { CardInstance } from '../api/types';
+import { useTerminalStore } from '../state/terminalStore';
 
 const props = defineProps<{ card: CardInstance }>();
-defineEmits<{ action: [actionName: string] }>();
+const emit = defineEmits<{ action: [data: { actionName: string; payload: Record<string, unknown> }] }>();
+const store = useTerminalStore();
 
 interface SlotOption {
   slotId: string;
   timePeriod: string;
-  remaining: number;
+  remain?: number;
+  remaining?: number;
 }
 
 const slots = computed(() => (props.card.cardData.slots as SlotOption[]) ?? []);
+const amount = computed(() => props.card.cardData.amountYuan ?? props.card.cardData.amount ?? '25.00');
+const doctorName = computed(() => store.selection.doctorName || props.card.cardData.doctorName as string || '');
+const room = computed(() => store.selection.room || props.card.cardData.room as string || '');
+
+const selectedSlot = ref<string | null>(null);
+
+function selectSlot(slot: SlotOption) {
+  selectedSlot.value = slot.slotId;
+  emit('action', {
+    actionName: 'select_time_slot',
+    payload: { slotId: slot.slotId, timePeriod: slot.timePeriod },
+  });
+}
 </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; }
+.time-card {
+  display: flex;
+  flex-direction: column;
+}
+
+.time-date {
+  font-family: var(--font-display);
+  font-size: var(--fs-h3);
+  font-weight: 600;
+  letter-spacing: -0.01em;
+  margin-bottom: 14px;
+}
+
+.time-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 8px;
+  margin-bottom: 14px;
+}
+
+.time-slot {
+  padding: 10px 12px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  text-align: center;
+  font-size: var(--fs-sm);
+  cursor: pointer;
+  transition: border-color 0.15s ease, background 0.15s ease;
+}
+
+.time-slot:hover {
+  border-color: var(--accent);
+}
+
+.time-slot.pick {
+  border-color: var(--accent);
+  background: var(--accent-faint);
+  color: var(--accent);
+  font-weight: 500;
+}
+
+.time-slot-meta {
+  font-size: 11px;
+  color: var(--muted);
+}
+
+.time-note {
+  padding: 10px 14px;
+  background: var(--accent-faint);
+  border-radius: var(--radius-sm);
+  font-size: var(--fs-xs);
+  color: var(--muted);
+}
 </style>

+ 134 - 30
src/chat/ChatInput.vue

@@ -1,14 +1,38 @@
 <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>
+  <div class="chat-input-bar">
+    <div class="voice-btn-wrap">
+      <button class="voice-btn" title="语音输入" @click="showVoiceHint">
+        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <rect x="9" y="1" width="6" height="12" rx="3" />
+          <path d="M5 11a7 7 0 0 0 14 0" />
+          <line x1="12" y1="19" x2="12" y2="23" />
+          <line x1="8" y1="23" x2="16" y2="23" />
+        </svg>
+      </button>
+      <span class="voice-tooltip">演示模式,请直接输入文字</span>
+    </div>
+    <input
+      ref="inputEl"
+      v-model="store.inputText"
+      type="text"
+      class="chat-input"
+      placeholder="请直接说出您的就诊需求,例如:我头疼三天,想挂号"
+      :disabled="disabled"
+      @keydown="onKeydown"
+    />
+    <button class="send-btn" title="发送" :disabled="!store.inputText.trim() || disabled" @click="submit">
+      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+        <line x1="22" y1="2" x2="11" y2="13" />
+        <polygon points="22,2 15,22 11,13 2,9" />
+      </svg>
+    </button>
+  </div>
 </template>
 
 <script setup lang="ts">
 import { useTerminalStore } from '../state/terminalStore';
 
+defineProps<{ disabled?: boolean }>();
 const emit = defineEmits<{ submit: [text: string] }>();
 const store = useTerminalStore();
 
@@ -19,53 +43,133 @@ function submit() {
   emit('submit', text);
 }
 
+function onKeydown(e: KeyboardEvent) {
+  if (e.key === 'Enter' && !e.shiftKey) {
+    e.preventDefault();
+    submit();
+  }
+}
+
 function showVoiceHint() {
   store.addMessage({
     id: crypto.randomUUID(),
     role: 'system',
-    content: '语音能力将在后续版本接入,当前请使用文字输入。'
+    content: '语音能力将在后续版本接入,当前请使用文字输入。',
   });
 }
 </script>
 
 <style scoped>
-.chat-input {
-  display: flex;
-  gap: 10px;
-  padding: 14px;
-  border-top: 1px solid #edf1f7;
+.chat-input-bar {
+  padding: 16px 24px;
   background: var(--surface);
+  border-top: 1px solid var(--border);
+  display: flex;
+  gap: var(--gap-sm);
+  align-items: center;
+  flex-shrink: 0;
 }
 
-input {
+.chat-input {
   flex: 1;
-  min-width: 0;
-  padding: 12px 14px;
-  border: 1px solid #dce7f7;
-  border-radius: 999px;
+  padding: 12px 16px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-pill);
+  font-size: var(--fs-body);
+  background: var(--bg);
+  color: var(--fg);
   outline: none;
+  transition: border-color 0.15s ease, box-shadow 0.15s ease;
 }
 
-button {
-  border: 0;
-  border-radius: 999px;
-  padding: 0 18px;
-  font-weight: 900;
-  cursor: pointer;
+.chat-input:focus {
+  border-color: var(--accent);
+  box-shadow: 0 0 0 3px var(--accent-soft);
 }
 
-.voice {
-  background: var(--brand-accent);
-  color: #10144a;
+.chat-input::placeholder {
+  color: var(--muted);
 }
 
-.send {
-  background: var(--brand-primary);
-  color: #ffffff;
+.chat-input:disabled {
+  opacity: 0.6;
 }
 
-.send:disabled {
-  cursor: not-allowed;
+.voice-btn-wrap {
+  position: relative;
+}
+
+.voice-btn {
+  width: 42px;
+  height: 42px;
+  border-radius: 50%;
+  background: var(--accent);
+  color: var(--surface);
+  display: grid;
+  place-items: center;
+  font-size: 20px;
+  transition: background 0.15s ease, transform 0.1s ease;
+  flex-shrink: 0;
+}
+
+.voice-btn:hover {
+  background: color-mix(in oklch, var(--accent) 85%, black);
+  transform: scale(1.05);
+}
+
+.voice-btn:active {
+  transform: scale(0.97);
+}
+
+.voice-tooltip {
+  position: absolute;
+  bottom: calc(100% + 8px);
+  left: 50%;
+  transform: translateX(-50%);
+  background: var(--fg);
+  color: var(--surface);
+  font-size: var(--fs-xs);
+  padding: 6px 12px;
+  border-radius: var(--radius-sm);
+  white-space: nowrap;
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 0.15s ease;
+}
+
+.voice-tooltip::after {
+  content: '';
+  position: absolute;
+  top: 100%;
+  left: 50%;
+  transform: translateX(-50%);
+  border: 5px solid transparent;
+  border-top-color: var(--fg);
+}
+
+.voice-btn-wrap:hover .voice-tooltip {
+  opacity: 1;
+}
+
+.send-btn {
+  width: 42px;
+  height: 42px;
+  border-radius: 50%;
+  background: var(--accent);
+  color: var(--surface);
+  display: grid;
+  place-items: center;
+  font-size: 18px;
+  transition: background 0.15s ease;
+  flex-shrink: 0;
+}
+
+.send-btn:hover:not(:disabled) {
+  background: color-mix(in oklch, var(--accent) 85%, black);
+}
+
+.send-btn:disabled {
   opacity: 0.45;
+  cursor: not-allowed;
 }
 </style>

+ 338 - 60
src/chat/ConversationPanel.vue

@@ -1,101 +1,379 @@
 <template>
-  <main class="conversation-panel">
-    <header>
-      <div>
-        <h1>对话入口</h1>
-        <p>系统会识别意图并进入对应流程</p>
+  <main class="chat-area">
+    <div class="chat-header" :class="{ disconnected: store.connectionStatus === 'disconnected' }">
+      <span class="chat-header-title">空海医院医疗助手 · Web Demo · 门诊大厅 · 联调演示</span>
+      <span class="chat-header-status">
+        <span :class="store.connectionStatus === 'disconnected' ? 'status-dot-err' : 'status-dot'" />
+        {{ store.connectionStatus === 'disconnected' ? '连接异常' : '在线' }}
+      </span>
+    </div>
+
+    <div class="chat-messages" ref="messagesEl">
+      <!-- Disconnected overlay -->
+      <div v-if="store.connectionStatus === 'disconnected'" class="disconnected-overlay">
+        <div class="disc-icon">
+          <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+            <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
+            <line x1="12" y1="9" x2="12" y2="13" />
+            <line x1="12" y1="17" x2="12.01" y2="17" />
+          </svg>
+        </div>
+        <p class="disc-title">连接异常</p>
+        <p class="disc-desc">请检查网络连接或联系现场工作人员。</p>
+        <div class="disc-btns">
+          <button class="btn-accent">重新连接</button>
+          <button class="btn-outline">联系工作人员</button>
+        </div>
+      </div>
+
+      <!-- Empty state -->
+      <div v-else-if="store.messages.length === 0 && !store.sending" class="chat-empty">
+        <div class="chat-empty-icon">
+          <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
+            <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
+          </svg>
+        </div>
+        <p class="chat-empty-text">您好,我是门诊助手</p>
+        <p class="chat-empty-hint">请说出您的就诊需求,例如:我头疼三天,想挂号</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>
+      <!-- Loading skeleton -->
+      <div v-if="store.sending && store.messages.length === 0" class="loading-block">
+        <div class="skeleton-line w60" />
+        <div class="skeleton-line w80" />
+        <div class="skeleton-line w40" />
+        <p class="loading-text">正在分析症状并匹配科室…</p>
+      </div>
+
+      <!-- Messages -->
+      <template v-for="msg in store.messages" :key="msg.id">
+        <div v-if="msg.role !== 'system'" :class="['msg', msg.role === 'user' ? 'msg-user' : 'msg-ai']">
+          {{ msg.content }}
+        </div>
+      </template>
+
+      <!-- Mini card previews -->
+      <div
+        v-for="preview in store.cardPreviews"
+        :key="preview.id"
+        :class="['chat-card', { active: isPreviewActive(preview.id) }]"
+        @click="store.viewCard(isPreviewActive(preview.id) ? null : preview.id)"
+      >
+        <div class="cc-title">{{ preview.title }}</div>
+        <div class="cc-choice">{{ preview.subtitle }}</div>
+      </div>
+    </div>
 
-    <ChatInput @submit="handleSubmit" />
+    <ChatInput :disabled="store.sending" @submit="handleSubmit" />
   </main>
 </template>
 
 <script setup lang="ts">
-import { demoCards } from '../api/demoFixtures';
+import { watch, ref, nextTick } from 'vue';
 import ChatInput from './ChatInput.vue';
-import MessageBubble from './MessageBubble.vue';
 import { useTerminalStore } from '../state/terminalStore';
 
 const store = useTerminalStore();
+const messagesEl = ref<HTMLElement | null>(null);
 
-if (store.messages.length === 0) {
-  store.addMessage({
-    id: 'welcome',
-    role: 'assistant',
-    content: '请直接告诉我你想办理什么,也可以描述症状。'
-  });
+function isPreviewActive(previewId: string): boolean {
+  // Active when viewing a historical card, or when this is the latest card and not viewing history
+  if (store.viewingCardId) {
+    return store.viewingCardId === previewId;
+  }
+  return store.activeCard?.cardInstanceId === previewId;
 }
 
-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: '已识别为挂号需求。根据描述,建议优先选择神经内科。'
+async function handleSubmit(text: string) {
+  try {
+    await store.sendMessage(text);
+    scrollToBottom();
+  } catch {
+    // error already set in store
+  }
+}
+
+function scrollToBottom() {
+  nextTick(() => {
+    if (messagesEl.value) {
+      messagesEl.value.scrollTop = messagesEl.value.scrollHeight;
+    }
   });
-  store.setActiveCard(demoCards.card_department);
 }
+
+watch(() => store.messages.length, () => scrollToBottom());
 </script>
 
 <style scoped>
-.conversation-panel {
+.chat-area {
   display: flex;
-  min-height: 0;
   flex-direction: column;
-  overflow: hidden;
-  border: 1px solid var(--border);
-  border-radius: var(--radius-panel);
-  background: var(--surface);
+  height: 100vh;
+  background: var(--bg);
 }
 
-header {
+.chat-header {
+  padding: 14px 24px;
+  background: var(--surface);
+  border-bottom: 1px solid var(--border);
   display: flex;
+  align-items: center;
   justify-content: space-between;
-  padding: 16px;
-  border-bottom: 1px solid #edf1f7;
+  flex-shrink: 0;
 }
 
-h1 {
-  margin: 0;
-  font-size: 22px;
+.chat-header-title {
+  font-size: var(--fs-sm);
+  color: var(--muted);
+  font-weight: 500;
 }
 
-p {
-  margin: 4px 0 0;
-  color: var(--text-secondary);
-  font-size: 13px;
+.chat-header-status {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: var(--fs-xs);
+  color: var(--accent-light);
 }
 
-.task-pill {
-  align-self: flex-start;
-  padding: 7px 11px;
-  border-radius: 999px;
-  background: #f1f0ff;
-  color: var(--brand-primary);
-  font-size: 12px;
-  font-weight: 800;
+.status-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: var(--accent-light);
+}
+
+.status-dot-err {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: var(--danger);
+  animation: blink 1s ease-in-out infinite;
+}
+
+@keyframes blink {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.3; }
 }
 
-.messages {
+.chat-messages {
+  flex: 1;
+  overflow-y: auto;
+  padding: 24px;
   display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+/* ─── Empty state ─── */
+.chat-empty {
   flex: 1;
-  min-height: 0;
+  display: flex;
   flex-direction: column;
+  align-items: center;
+  justify-content: center;
   gap: 12px;
-  overflow-y: auto;
+  color: var(--muted);
+}
+
+.chat-empty-icon {
+  width: 64px;
+  height: 64px;
+  border-radius: 50%;
+  background: var(--accent-soft);
+  display: grid;
+  place-items: center;
+  font-size: 28px;
+  color: var(--accent);
+}
+
+.chat-empty-text {
+  font-family: var(--font-display);
+  font-size: var(--fs-h2);
+  font-weight: 500;
+  color: var(--fg);
+}
+
+.chat-empty-hint {
+  font-size: var(--fs-sm);
+  color: var(--muted);
+}
+
+/* ─── Messages ─── */
+.msg {
+  max-width: 78%;
+  padding: 12px 16px;
+  border-radius: var(--radius-lg);
+  font-size: var(--fs-body);
+  line-height: 1.6;
+  animation: msgIn 0.3s ease-out both;
+}
+
+@keyframes msgIn {
+  from {
+    opacity: 0;
+    transform: translateY(8px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+.msg-user {
+  align-self: flex-end;
+  background: var(--accent);
+  color: var(--surface);
+  border-bottom-right-radius: 4px;
+}
+
+.msg-ai {
+  align-self: flex-start;
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-bottom-left-radius: 4px;
+}
+
+/* ─── Mini chat cards ─── */
+.chat-card {
+  align-self: flex-start;
+  margin-top: -4px;
+  padding: 12px 16px;
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-left: 4px solid var(--accent);
+  border-radius: var(--radius-md);
+  cursor: pointer;
+  transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  max-width: 300px;
+  font-size: var(--fs-sm);
+}
+
+.chat-card:hover {
+  border-color: var(--accent);
+  box-shadow: 0 2px 10px rgba(43, 31, 153, 0.1);
+}
+
+.chat-card.active {
+  border-color: var(--accent);
+  background: var(--accent-faint);
+  box-shadow: 0 2px 12px rgba(43, 31, 153, 0.08);
+}
+
+.cc-title {
+  font-weight: 600;
+  font-size: var(--fs-sm);
+  color: var(--fg);
+  line-height: 1.3;
+}
+
+.cc-choice {
+  font-size: var(--fs-xs);
+  color: var(--muted);
+  line-height: 1.4;
+}
+
+/* ─── Loading skeleton ─── */
+.loading-block {
+  align-self: flex-start;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  width: 70%;
   padding: 16px;
-  background: #fbfdff;
+}
+
+.skeleton-line {
+  height: 14px;
+  border-radius: 4px;
+  background: linear-gradient(90deg, var(--border) 25%, color-mix(in oklch, var(--accent) 5%, transparent) 50%, var(--border) 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s ease-in-out infinite;
+}
+
+.skeleton-line.w60 { width: 60%; }
+.skeleton-line.w80 { width: 80%; }
+.skeleton-line.w40 { width: 40%; }
+
+@keyframes shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+
+.loading-text {
+  margin-top: 6px;
+  font-size: var(--fs-sm);
+  color: var(--muted);
+}
+
+/* ─── Disconnected overlay ─── */
+.disconnected-overlay {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  text-align: center;
+}
+
+.disc-icon {
+  width: 72px;
+  height: 72px;
+  border-radius: 50%;
+  background: color-mix(in oklch, var(--danger) 10%, transparent);
+  display: grid;
+  place-items: center;
+  color: var(--danger);
+  margin-bottom: 4px;
+}
+
+.disc-title {
+  font-family: var(--font-display);
+  font-size: var(--fs-h2);
+  font-weight: 600;
+}
+
+.disc-desc {
+  font-size: var(--fs-sm);
+  color: var(--muted);
+  max-width: 280px;
+}
+
+.disc-btns {
+  display: flex;
+  gap: 10px;
+  margin-top: 8px;
+}
+
+.btn-accent {
+  padding: 10px 24px;
+  background: var(--accent);
+  color: var(--surface);
+  border-radius: var(--radius-sm);
+  font-size: var(--fs-body);
+  font-weight: 500;
+  transition: background 0.15s ease;
+}
+
+.btn-accent:hover {
+  background: color-mix(in oklch, var(--accent) 85%, black);
+}
+
+.btn-outline {
+  padding: 10px 24px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  font-size: var(--fs-body);
+  color: var(--muted);
+  background: transparent;
+  transition: background 0.15s ease;
+}
+
+.btn-outline:hover {
+  background: var(--bg);
 }
 </style>

+ 1 - 1
src/demo/demoMode.ts

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

+ 6 - 56
src/layouts/AppShell.vue

@@ -1,18 +1,8 @@
 <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>
+    <AssistantContextPanel />
+    <ConversationPanel />
+    <ContextPanel />
   </div>
 </template>
 
@@ -24,49 +14,9 @@ import ContextPanel from './ContextPanel.vue';
 
 <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;
+  grid-template-columns: var(--panel-left) 1fr var(--panel-right);
+  height: 100vh;
+  overflow: hidden;
 }
 </style>

+ 139 - 80
src/layouts/AssistantContextPanel.vue

@@ -1,126 +1,185 @@
 <template>
-  <aside class="assistant-panel" aria-label="助手上下文">
-    <div class="identity">
-      <div class="avatar">AI</div>
-      <div>
-        <h2>你好,我在</h2>
-        <p>说出需求,我来判断下一步</p>
+  <aside class="panel-left" aria-label="助手上下文">
+    <div class="panel-left-header">
+      <div class="avatar">
+        <svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg">
+          <rect x="2" y="2" width="76" height="76" rx="20" fill="#2b1f99" />
+          <rect x="33" y="18" width="14" height="44" rx="6" fill="#ffffff" />
+          <rect x="18" y="33" width="44" height="14" rx="6" fill="#ffffff" />
+          <circle cx="47" cy="26" r="4" fill="#3ad4d8" />
+        </svg>
       </div>
+      <span class="assistant-name">空海医院医疗助手</span>
+      <span class="assistant-tagline">说出需求,我来判断下一步</span>
     </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 class="separator" />
+
+    <div class="example-section">
+      <span class="example-label">你可以这样说</span>
+      <div class="example-phrase">我头疼三天,还有点恶心,想挂号</div>
+      <div class="example-phrase">明天上午有神经内科的号吗</div>
+      <div class="example-phrase">我想找李明医生</div>
+    </div>
+
+    <div class="separator" />
+
+    <div class="cap-section">
+      <span class="cap-label">我能帮您</span>
+      <div class="cap-tag-grid">
+        <span class="cap-tag">挂号预约</span>
+        <span class="cap-tag">科室导诊</span>
+        <span class="cap-tag">医生查询</span>
+        <span class="cap-tag">查报告</span>
+        <span class="cap-tag">门诊缴费</span>
       </div>
-    </section>
+    </div>
 
-    <p class="mock-note">Mock 结果显示"联调演示",不伪装成真实业务。</p>
+    <div class="demo-hint-box">
+      <span class="demo-hint-label">联调演示环境</span>
+      <span class="demo-hint-text">当前为联调演示环境,支付与 HIS 数据均为 Mock。语音及后端接口暂未接入。</span>
+    </div>
   </aside>
 </template>
 
 <style scoped>
-.assistant-panel {
+.panel-left {
+  background: var(--surface);
+  border-right: 1px solid var(--border);
   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);
+  padding: 24px 20px;
+  gap: 24px;
+  overflow-y: auto;
 }
 
-.identity {
+.panel-left-header {
   display: flex;
-  gap: 12px;
+  flex-direction: column;
   align-items: center;
+  text-align: center;
+  gap: 10px;
 }
 
 .avatar {
+  width: 80px;
+  height: 80px;
+  border-radius: 50%;
   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;
+  flex-shrink: 0;
+  overflow: hidden;
 }
 
-h2 {
-  margin: 0;
-  font-size: 18px;
+.avatar svg {
+  width: 100%;
+  height: 100%;
+  display: block;
 }
 
-p {
-  margin: 4px 0 0;
-  color: var(--text-secondary);
-  font-size: 13px;
+.assistant-name {
+  font-family: var(--font-display);
+  font-size: var(--fs-h3);
+  font-weight: 600;
+  letter-spacing: -0.01em;
 }
 
-.section-label {
-  margin-bottom: 10px;
-  color: var(--text-secondary);
-  font-size: 12px;
+.assistant-tagline {
+  font-size: var(--fs-sm);
+  color: var(--muted);
 }
 
-.examples,
-.capabilities {
-  padding: 14px;
-  border: 1px solid #dce7f8;
-  border-radius: var(--radius-card);
-  background: var(--surface);
+.separator {
+  height: 0;
+  border-top: 1px solid var(--border);
+  margin: 0 -4px;
 }
 
-ul {
-  display: grid;
+.example-section {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.example-label {
+  font-size: var(--fs-xs);
+  color: var(--muted);
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  font-weight: 500;
+}
+
+.example-phrase {
+  padding: 9px 12px;
+  background: var(--bg);
+  border-radius: var(--radius-sm);
+  font-size: var(--fs-sm);
+  color: var(--muted);
+  line-height: 1.5;
+  border: 1px solid transparent;
+  transition: border-color 0.15s ease, color 0.15s ease;
+  cursor: pointer;
+}
+
+.example-phrase:hover {
+  border-color: var(--border);
+  color: var(--fg);
+}
+
+.cap-section {
+  display: flex;
+  flex-direction: column;
   gap: 8px;
-  margin: 0;
-  padding: 0;
-  list-style: none;
 }
 
-li {
-  padding: 9px;
-  border-radius: 10px;
-  background: #f4fbff;
-  font-size: 13px;
+.cap-label {
+  font-size: var(--fs-xs);
+  color: var(--muted);
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  font-weight: 500;
 }
 
-.chips {
+.cap-tag-grid {
   display: flex;
   flex-wrap: wrap;
-  gap: 8px;
+  gap: 6px;
 }
 
-.chips span {
-  padding: 6px 9px;
-  border: 1px solid #cceff1;
-  border-radius: 999px;
-  color: #156e83;
+.cap-tag {
+  display: inline-flex;
+  align-items: center;
+  padding: 4px 10px;
+  border-radius: var(--radius-pill);
   font-size: 12px;
+  color: var(--accent);
+  background: var(--accent-soft);
+  border: 1px solid color-mix(in oklch, var(--accent) 15%, transparent);
+  white-space: nowrap;
 }
 
-.mock-note {
+.demo-hint-box {
   margin-top: auto;
-  padding: 12px;
-  border: 1px solid #f0d89c;
-  border-radius: 12px;
-  background: var(--warning-bg);
-  color: var(--warning-text);
-  line-height: 1.6;
+  padding: 12px 14px;
+  background: var(--warn-bg);
+  border: 1px solid color-mix(in oklch, var(--warn) 20%, transparent);
+  border-radius: var(--radius-sm);
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.demo-hint-label {
+  font-size: 11px;
+  font-weight: 600;
+  letter-spacing: 0.04em;
+  text-transform: uppercase;
+  color: var(--warn-text);
+}
+
+.demo-hint-text {
+  font-size: var(--fs-xs);
+  color: var(--muted);
+  line-height: 1.4;
 }
 </style>

+ 121 - 67
src/layouts/ContextPanel.vue

@@ -1,92 +1,146 @@
 <template>
-  <aside class="context-panel" aria-label="上下文面板">
+  <aside class="panel-right" aria-label="上下文面板">
+    <!-- Idle: doctor image -->
     <DoctorAssistantFigure v-if="store.contextPanelMode === 'idle'" />
 
+    <!-- Loading: skeleton timeline -->
+    <template v-else-if="store.contextPanelMode === 'loading'">
+      <div class="pr-header">
+        <div class="pr-label">分析中</div>
+      </div>
+      <div class="timeline-skeleton">
+        <div class="sk-item" v-for="i in 6" :key="i">
+          <div class="sk-dot" />
+          <div class="sk-line" />
+        </div>
+      </div>
+    </template>
+
+    <!-- Task / Completed: timeline + card -->
     <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" />
+      <div class="pr-header">
+        <div class="pr-label">
+          {{ store.displayedCard?.cardKey === 'appointment-success' ? '挂号结果' : '挂号进度' }}
+        </div>
+      </div>
+      <RegistrationProgress />
+      <div class="panel-card-container">
+        <CardRenderer
+          v-if="store.displayedCard"
+          :card="store.displayedCard"
+          @action="handleAction"
+          @finish="store.resetToIdle"
+        />
+      </div>
     </template>
 
-    <ErrorCard
-      v-else
-      :message="store.errorMessage"
-      :trace-id="store.currentTraceId"
-      @restart="store.resetToIdle"
-    />
+    <!-- Error -->
+    <template v-else>
+      <div class="pr-header">
+        <div class="pr-label">异常</div>
+      </div>
+      <ErrorCard
+        :message="store.errorMessage"
+        :trace-id="store.currentTraceId"
+        @restart="store.resetToIdle"
+      />
+    </template>
   </aside>
 </template>
 
 <script setup lang="ts">
 import CardRenderer from '../cards/CardRenderer.vue';
 import ErrorCard from '../cards/ErrorCard.vue';
-import { demoCards } from '../api/demoFixtures';
 import { useTerminalStore } from '../state/terminalStore';
 import DoctorAssistantFigure from './DoctorAssistantFigure.vue';
 import RegistrationProgress from './RegistrationProgress.vue';
 
 const store = useTerminalStore();
 
-function handleAction(actionName: string) {
-  store.pendingActionKey = `${store.activeCard?.cardInstanceId}-${actionName}`;
-  if (actionName === 'select_department') {
-    store.setActiveCard(demoCards.card_doctor);
-    if (store.task) store.task.currentStep = 'WAIT_SELECT_DOCTOR';
-  }
-  if (actionName === 'select_doctor') {
-    store.setActiveCard(demoCards.card_slot);
-    if (store.task) store.task.currentStep = 'WAIT_SELECT_TIME_SLOT';
-  }
-  if (actionName === 'select_time_slot') {
-    store.setActiveCard({
-      cardInstanceId: 'card_confirm',
-      cardKey: 'confirm-appointment',
-      status: 'active',
-      cardData: {
-        summary: {
-          departmentName: '神经内科',
-          doctorName: '李明',
-          visitTime: '明天 09:30-09:45',
-          amount: 25
-        }
-      },
-      actions: [{ actionName: 'confirm_appointment', label: '确认挂号', requiredConfirm: true }]
-    });
-    if (store.task) store.task.currentStep = 'WAIT_CONFIRM_APPOINTMENT';
-  }
-  if (actionName === 'confirm_appointment') {
-    store.setActiveCard({
-      cardInstanceId: 'card_payment',
-      cardKey: 'payment-qrcode',
-      status: 'active',
-      cardData: { orderId: 'PAY202606020001', amount: 25, qrContent: 'demo-payment://orderId=PAY202606020001', mock: true },
-      actions: [{ actionName: 'mock_payment_paid', label: '模拟支付完成', requiredConfirm: true }]
-    });
-    if (store.task) store.task.currentStep = 'WAIT_CONFIRM_APPOINTMENT';
-  }
-  if (actionName === 'mock_payment_paid') {
-    store.setActiveCard({
-      cardInstanceId: 'card_success',
-      cardKey: 'appointment-success',
-      status: 'completed',
-      cardData: {
-        appointmentNo: 'A023',
-        departmentName: '神经内科',
-        doctorName: '李明',
-        visitTime: '明天 09:30-09:45',
-        room: '门诊三楼 302 诊室',
-        mock: true
-      },
-      actions: []
-    });
-    if (store.task) store.task.currentStep = 'APPOINTMENT_SUCCESS';
+async function handleAction(data: { actionName: string; payload: Record<string, unknown> }) {
+  store.saveSelection(data.payload);
+  try {
+    await store.submitCard(data.payload);
+  } catch {
+    // error already set in store
   }
 }
 </script>
 
 <style scoped>
-.context-panel {
-  display: grid;
-  min-height: 0;
-  gap: 12px;
+.panel-right {
+  background: var(--surface);
+  border-left: 1px solid var(--border);
+  padding: 24px 20px;
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+}
+
+.pr-header {
+  margin-bottom: 20px;
+}
+
+.pr-label {
+  font-size: var(--fs-xs);
+  color: var(--muted);
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  font-weight: 500;
+}
+
+.panel-card-container {
+  flex: 1;
+  overflow-y: auto;
+}
+
+/* ─── Loading skeleton timeline ─── */
+.timeline-skeleton {
+  position: relative;
+  padding-left: 28px;
+}
+
+.timeline-skeleton::before {
+  content: '';
+  position: absolute;
+  left: 10px;
+  top: 8px;
+  bottom: 8px;
+  width: 0;
+  border-left: 2px dotted var(--border);
+}
+
+.sk-item {
+  position: relative;
+  padding: 8px 0 8px 14px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.sk-dot {
+  position: absolute;
+  left: -22px;
+  top: 12px;
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  background: var(--border);
+  border: 2px solid var(--surface);
+  z-index: 1;
+}
+
+.sk-line {
+  height: 12px;
+  border-radius: 4px;
+  background: linear-gradient(90deg, var(--border) 25%, var(--bg) 50%, var(--border) 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s ease-in-out infinite;
+  width: 100%;
+}
+
+@keyframes shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
 }
 </style>

+ 38 - 34
src/layouts/DoctorAssistantFigure.vue

@@ -1,53 +1,57 @@
 <template>
-  <div class="doctor-figure" aria-label="医生助手静态形象">
-    <div class="imageFrame">
-      <img src="../assets/doctor-assistant.svg" alt="医生助手" />
+  <div class="doctor-figure" aria-label="医生助手">
+    <div class="pr-label">空闲</div>
+    <div class="panel-idle">
+      <img src="../assets/doctor-assistant.png" alt="医生助手" />
+      <div class="panel-idle-name">医生助手</div>
+      <div class="panel-idle-note">当前仅作为静态形象展示。语音与发送操作仍在中间输入区完成。</div>
     </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;
+  flex: 1;
+}
+
+.pr-label {
+  font-size: var(--fs-xs);
+  color: var(--muted);
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  font-weight: 500;
+  margin-bottom: 20px;
 }
 
-.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);
+.panel-idle {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  text-align: center;
+  gap: 16px;
+  flex: 1;
+  justify-content: center;
 }
 
-img {
-  max-width: 86%;
-  max-height: 86%;
-  object-fit: contain;
+.panel-idle img {
+  width: 220px;
+  height: auto;
+  border-radius: 16px;
 }
 
-h2 {
-  margin: 22px 0 8px;
-  font-size: 24px;
+.panel-idle-name {
+  font-family: var(--font-display);
+  font-size: var(--fs-h3);
+  font-weight: 600;
+  letter-spacing: -0.01em;
 }
 
-p {
-  max-width: 280px;
-  margin: 0;
-  color: var(--text-secondary);
-  font-size: 14px;
-  line-height: 1.7;
+.panel-idle-note {
+  font-size: var(--fs-sm);
+  color: var(--muted);
+  max-width: 240px;
+  line-height: 1.5;
 }
 </style>

+ 84 - 43
src/layouts/RegistrationProgress.vue

@@ -1,69 +1,110 @@
 <template>
-  <section class="progress-panel">
-    <div class="heading">
-      <strong>挂号进度</strong>
-      <span>{{ currentLabel }}</span>
+  <div class="timeline">
+    <div
+      v-for="step in steps"
+      :key="step.key"
+      :class="['tl-item', step.state]"
+    >
+      {{ step.label }}
     </div>
-    <div class="bar" aria-label="挂号进度">
-      <i v-for="step in 5" :key="step" :class="{ active: step <= activeIndex }" />
-    </div>
-  </section>
+  </div>
 </template>
 
 <script setup lang="ts">
 import { computed } from 'vue';
+import { useTerminalStore } from '../state/terminalStore';
+
+const store = useTerminalStore();
+
+interface TimelineStep {
+  key: string;
+  label: string;
+  state: 'done' | 'selected' | 'inactive';
+}
 
-const props = defineProps<{ currentStep: string }>();
+const cardOrder = ['department-selection', 'doctor-selection', 'time-slot-selection', 'confirm-appointment', 'payment-qrcode', 'appointment-success'];
+const labels = ['科室推荐', '医生选择', '就诊时间', '确认预约', '支付', '挂号成功'];
 
-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 steps = computed<TimelineStep[]>(() => {
+  const activeKey = store.displayedCard?.cardKey ?? '';
+  const activeIdx = cardOrder.indexOf(activeKey);
 
-const current = computed(() => stepMap[props.currentStep] ?? stepMap.TRIAGE_RECOMMEND_DEPARTMENT);
-const activeIndex = computed(() => current.value.index);
-const currentLabel = computed(() => current.value.label);
+  return cardOrder.map((key, i) => {
+    let state: TimelineStep['state'];
+    if (i < activeIdx) state = 'done';
+    else if (i === activeIdx) state = 'selected';
+    else state = 'inactive';
+    return { key, label: labels[i], state };
+  });
+});
 </script>
 
 <style scoped>
-.progress-panel {
-  padding: 14px;
-  border: 1px solid var(--border);
-  border-radius: var(--radius-panel);
-  background: var(--surface);
+.timeline {
+  position: relative;
+  padding-left: 28px;
+  margin-bottom: 20px;
 }
 
-.heading {
-  display: flex;
-  justify-content: space-between;
+.timeline::before {
+  content: '';
+  position: absolute;
+  left: 10px;
+  top: 8px;
+  bottom: 8px;
+  width: 0;
+  border-left: 2px dotted var(--border);
 }
 
-.heading span {
-  color: var(--text-secondary);
-  font-size: 12px;
+.tl-item {
+  position: relative;
+  padding: 8px 0 8px 14px;
+  font-size: 13px;
+  color: var(--muted);
+  cursor: default;
+  user-select: none;
+  line-height: 1.3;
+  transition: color 0.15s ease;
 }
 
-.bar {
-  display: grid;
-  grid-template-columns: repeat(5, 1fr);
-  gap: 5px;
-  margin-top: 12px;
+.tl-item::before {
+  content: '';
+  position: absolute;
+  left: -22px;
+  top: 12px;
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  background: var(--border);
+  transition: background 0.2s ease, box-shadow 0.2s ease;
+  border: 2px solid var(--surface);
+  z-index: 1;
 }
 
-.bar i {
-  height: 8px;
-  border-radius: 999px;
-  background: var(--border);
+.tl-item.done {
+  color: var(--fg);
 }
 
-.bar i.active:first-child {
-  background: var(--brand-primary);
+.tl-item.done::before {
+  background: var(--accent-light);
 }
 
-.bar i.active {
-  background: var(--brand-accent);
+.tl-item.selected {
+  color: var(--accent);
+  font-weight: 600;
+}
+
+.tl-item.selected::before {
+  background: var(--accent);
+  box-shadow: 0 0 0 4px var(--accent-soft);
+}
+
+.tl-item.inactive {
+  color: var(--muted);
+  opacity: 0.5;
+}
+
+.tl-item.inactive::before {
+  background: var(--border);
 }
 </style>

+ 182 - 8
src/state/terminalStore.ts

@@ -1,7 +1,26 @@
 import { defineStore } from 'pinia';
 import type { CardInstance, ChatMessage, TaskState } from '../api/types';
+import { connectChatStream, type ParsedSseEvent } from '../api/sse';
+import { fetchCard, submitCardAction } from '../api/client';
 
-export type ContextPanelMode = 'idle' | 'task' | 'error' | 'completed';
+export type ContextPanelMode = 'idle' | 'loading' | 'task' | 'error' | 'completed';
+export type ConnectionStatus = 'online' | 'disconnected';
+
+export interface CardPreview {
+  id: string;
+  cardKey: string;
+  title: string;
+  subtitle: string;
+}
+
+const CARD_TITLE_MAP: Record<string, string> = {
+  'department-selection': '科室推荐',
+  'doctor-selection': '医生选择',
+  'time-slot-selection': '就诊时间',
+  'confirm-appointment': '确认预约',
+  'payment-qrcode': '支付',
+  'appointment-success': '挂号成功',
+};
 
 export const useTerminalStore = defineStore('terminal', {
   state: () => ({
@@ -13,18 +32,34 @@ export const useTerminalStore = defineStore('terminal', {
     streaming: false,
     task: null as TaskState | null,
     activeCard: null as CardInstance | null,
+    cardHistory: [] as CardInstance[],
+    viewingCardId: null as string | null,
+    cardPreviews: [] as CardPreview[],
     errorMessage: '' as string,
+    connectionStatus: 'online' as ConnectionStatus,
     inputText: '',
     pendingActionKey: '',
-    demoMode: true
+    demoMode: import.meta.env.VITE_DEMO_MODE === 'true',
+    sending: false,
+    selection: {} as Record<string, string>,
   }),
   getters: {
     contextPanelMode(state): ContextPanelMode {
       if (state.errorMessage) return 'error';
-      if (state.activeCard?.cardKey === 'appointment-success') return 'completed';
-      if (state.activeCard) return 'task';
+      if (state.sending && !state.activeCard && !state.viewingCardId) return 'loading';
+      if (this.displayedCard?.cardKey === 'appointment-success') return 'completed';
+      if (this.displayedCard) return 'task';
       return 'idle';
-    }
+    },
+    displayedCard(state): CardInstance | null {
+      if (state.viewingCardId) {
+        return state.cardHistory.find((c) => c.cardInstanceId === state.viewingCardId) ?? state.activeCard;
+      }
+      return state.activeCard;
+    },
+    isDemo(): boolean {
+      return this.demoMode;
+    },
   },
   actions: {
     addMessage(message: ChatMessage) {
@@ -40,7 +75,7 @@ export const useTerminalStore = defineStore('terminal', {
         id: crypto.randomUUID(),
         role: 'assistant',
         content: text,
-        streaming: true
+        streaming: true,
       });
     },
     completeAssistantMessage() {
@@ -52,17 +87,156 @@ export const useTerminalStore = defineStore('terminal', {
     },
     setActiveCard(card: CardInstance) {
       this.activeCard = card;
+      this.viewingCardId = null;
+      this.cardHistory.push(card);
       this.errorMessage = '';
+      // Push mini card preview for chat feed
+      const cardKey = card.cardKey;
+      const title = CARD_TITLE_MAP[cardKey] || cardKey;
+      const data = card.cardData || {};
+      let subtitle = '';
+      if (cardKey === 'department-selection') {
+        const depts = data.departments as Array<{ name: string }> | undefined;
+        subtitle = depts?.[0]?.name ? `${depts[0].name}${depts.length > 1 ? '等' + depts.length + '个科室' : ''}` : '';
+      } else if (cardKey === 'doctor-selection') {
+        const docs = data.doctors as Array<{ doctorName: string }> | undefined;
+        subtitle = docs?.[0]?.doctorName || '';
+      } else if (cardKey === 'time-slot-selection') {
+        const slots = data.slots as Array<{ timePeriod: string }> | undefined;
+        subtitle = slots?.[0]?.timePeriod || '';
+      } else if (cardKey === 'confirm-appointment') {
+        subtitle = '请确认预约信息';
+      } else if (cardKey === 'payment-qrcode') {
+        const amount = data.amountYuan || '0.00';
+        subtitle = `Mock支付 ¥${amount}`;
+      } else if (cardKey === 'appointment-success') {
+        subtitle = '预约已确认';
+      }
+      this.cardPreviews.push({ id: card.cardInstanceId, cardKey, title, subtitle });
     },
     setError(message: string, traceId = '') {
       this.errorMessage = message;
       this.currentTraceId = traceId;
     },
+    viewCard(cardInstanceId: string | null) {
+      this.viewingCardId = cardInstanceId;
+    },
     resetToIdle() {
       this.task = null;
       this.activeCard = null;
+      this.viewingCardId = null;
+      this.cardHistory = [];
+      this.cardPreviews = [];
       this.errorMessage = '';
       this.pendingActionKey = '';
-    }
-  }
+      this.selection = {};
+    },
+
+    saveSelection(payload: Record<string, unknown>) {
+      for (const [key, value] of Object.entries(payload)) {
+        if (typeof value === 'string') {
+          this.selection[key] = value;
+        }
+      }
+    },
+
+    handleSseEvent(event: ParsedSseEvent) {
+      switch (event.type) {
+        case 'task_updated': {
+          const data = event.data as unknown as TaskState;
+          this.setTask(data);
+          break;
+        }
+        case 'message_delta': {
+          const text = event.data.text as string;
+          if (text) this.appendAssistantDelta(text);
+          this.streaming = true;
+          break;
+        }
+        case 'message_completed': {
+          this.completeAssistantMessage();
+          this.streaming = false;
+          if (event.data.conversationId) {
+            this.conversationId = event.data.conversationId as string;
+          }
+          break;
+        }
+        case 'card_created': {
+          const cardInstanceId = event.data.cardInstanceId as string;
+          if (cardInstanceId) {
+            fetchCard(cardInstanceId)
+              .then((card) => this.setActiveCard(card))
+              .catch((err) => this.setError(err.message));
+          }
+          break;
+        }
+        case 'error': {
+          this.setError(
+            (event.data.message as string) ?? '未知错误',
+            (event.data.traceId as string) ?? '',
+          );
+          this.streaming = false;
+          break;
+        }
+        case 'completed': {
+          this.streaming = false;
+          break;
+        }
+      }
+    },
+
+    async sendMessage(text: string): Promise<void> {
+      this.addMessage({ id: crypto.randomUUID(), role: 'user', content: text });
+      this.errorMessage = '';
+      this.sending = true;
+      this.streaming = true;
+
+      return new Promise((resolve, reject) => {
+        connectChatStream({
+          deviceId: this.deviceId,
+          deviceType: this.deviceType,
+          text,
+          conversationId: this.conversationId || undefined,
+          onEvent: (event) => this.handleSseEvent(event),
+          onError: (error) => {
+            this.setError(error.message);
+            this.streaming = false;
+            this.sending = false;
+            reject(error);
+          },
+          onComplete: () => {
+            this.streaming = false;
+            this.sending = false;
+            resolve();
+          },
+        });
+      });
+    },
+
+    async submitCard(payload: Record<string, unknown>): Promise<void> {
+      if (!this.activeCard) return;
+
+      const actionName = this.activeCard.actions[0]?.actionName;
+      if (!actionName) return;
+
+      this.pendingActionKey = `${this.activeCard.cardInstanceId}-${actionName}`;
+      this.errorMessage = '';
+
+      try {
+        const result = await submitCardAction(
+          this.activeCard.cardInstanceId,
+          actionName,
+          payload,
+        );
+
+        if (result.nextCard?.cardInstanceId) {
+          const fullCard = await fetchCard(result.nextCard.cardInstanceId);
+          this.setActiveCard(fullCard);
+        }
+      } catch (err) {
+        this.setError((err as Error).message);
+        throw err;
+      }
+    },
+  },
 });

+ 87 - 18
src/theme/tokens.css

@@ -1,37 +1,106 @@
 :root {
-  --brand-primary: #2b1f99;
-  --brand-accent: #3ad4d8;
-  --page-bg: #f7fbff;
+  /* ─── Brand ─── */
+  --accent: #2b1f99;
+  --accent-light: #3ad4d8;
+  --accent-soft: color-mix(in oklch, #2b1f99 10%, transparent);
+  --accent-faint: color-mix(in oklch, #2b1f99 5%, transparent);
+
+  /* ─── Surface ─── */
+  --bg: #f7fbff;
   --surface: #ffffff;
   --soft-tint: #f4fdff;
+
+  /* ─── Text ─── */
+  --fg: #16133a;
+  --muted: #69708a;
+
+  /* ─── Border ─── */
   --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;
+
+  /* ─── Status ─── */
+  --warn: #f0a020;
+  --warn-bg: #fff8e8;
+  --warn-text: #735b14;
+  --danger: #e05555;
+
+  /* ─── Typography ─── */
+  --font-display: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
+  --font-body: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
+  --font-mono: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
+
+  --fs-h1: 28px;
+  --fs-h2: 22px;
+  --fs-h3: 18px;
+  --fs-body: 15px;
+  --fs-sm: 13px;
+  --fs-xs: 12px;
+
+  /* ─── Spacing ─── */
+  --gap-xs: 6px;
+  --gap-sm: 10px;
+  --gap-md: 16px;
+  --gap-lg: 24px;
+  --gap-xl: 36px;
+
+  /* ─── Radius ─── */
+  --radius-sm: 8px;
+  --radius-md: 12px;
+  --radius-lg: 16px;
+  --radius-pill: 9999px;
+
+  /* ─── Layout ─── */
+  --panel-left: 260px;
+  --panel-right: 380px;
 }
 
-* {
+*,
+*::before,
+*::after {
   box-sizing: border-box;
 }
 
+html {
+  -webkit-text-size-adjust: 100%;
+}
+
 html,
 body,
 #app {
   width: 100%;
   min-width: 1280px;
-  min-height: 100%;
+  height: 100%;
   margin: 0;
-  background: var(--page-bg);
-  color: var(--text-primary);
+  background: var(--bg);
+  color: var(--fg);
+  font-family: var(--font-body);
+  font-size: var(--fs-body);
+  line-height: 1.55;
+  -webkit-font-smoothing: antialiased;
 }
 
-button,
-input,
-textarea {
+body {
+  overflow: hidden;
+  height: 100vh;
+}
+
+img,
+svg {
+  display: block;
+  max-width: 100%;
+}
+
+button {
+  font: inherit;
+  cursor: pointer;
+  border: none;
+  background: none;
+}
+
+input {
   font: inherit;
 }
+
+p {
+  margin: 0;
+  text-wrap: pretty;
+}