Przeglądaj źródła

feat: 对话体验增强 — 思考状态/时间线穿插/打字机过渡/卡片加载反馈

- 新增thinking模式:右侧医生形象+思考中波浪气泡,区分AI思考与卡片流程
- 消息与卡片按插入序号穿插排列,不再分堆
- 卡片选择弹出用户气泡,过渡话术打字机逐字输出
- 支付流程:跳过选择气泡,绿色成功消息+挂号卡片信息
- 卡片副标题完善:医生带科室、时段带价格、确认带摘要、成功带编号
- 右侧卡片区提交时遮罩+旋转加载
- 气泡右下角HH:mm时间戳
- DoctorAssistantFigure支持label prop

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
zhaohan 1 miesiąc temu
rodzic
commit
408bb29014

+ 3 - 0
src/api/types.ts

@@ -11,6 +11,9 @@ export interface ChatMessage {
   role: 'user' | 'assistant' | 'system';
   content: string;
   streaming?: boolean;
+  timestamp?: string;
+  _seq?: number;
+  _type?: string;
 }
 
 export interface CardAction {

+ 81 - 52
src/chat/ConversationPanel.vue

@@ -37,31 +37,28 @@
         <p class="chat-empty-hint">请说出您的就诊需求,例如:我头疼三天,想挂号</p>
       </div>
 
-      <!-- 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 }}
+      <!-- Unified timeline: messages + cards interleaved by time -->
+      <template v-for="item in timeline" :key="item.key">
+        <div v-if="item.type === 'message'" :class="['msg', item.data._type === 'success' ? 'msg-success' : item.data.role === 'user' ? 'msg-user' : 'msg-ai']">
+          {{ item.data.content }}
+          <span v-if="item.data.timestamp" class="msg-time">{{ item.data.timestamp }}</span>
+        </div>
+        <div
+          v-else
+          :class="['chat-card', { active: isPreviewActive(item.data.id) }]"
+          @click="store.viewCard(isPreviewActive(item.data.id) ? null : item.data.id)"
+        >
+          <div class="cc-title">{{ item.data.title }}</div>
+          <div class="cc-choice">{{ item.data.subtitle }}</div>
+          <span class="msg-time">{{ item.data.timestamp }}</span>
         </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>
+      <!-- Thinking bubble: shown while waiting for AI to start responding -->
+      <div v-if="isThinking" class="msg msg-ai thinking-bubble">
+        <span class="wave-char" v-for="(c, i) in '思考中...'" :key="i" :style="{ animationDelay: i * 0.2 + 's' }">{{ c }}</span>
       </div>
+
     </div>
 
     <ChatInput :disabled="store.sending" @submit="handleSubmit" />
@@ -69,13 +66,40 @@
 </template>
 
 <script setup lang="ts">
-import { watch, ref, nextTick } from 'vue';
+import { watch, ref, nextTick, computed } from 'vue';
 import ChatInput from './ChatInput.vue';
 import { useTerminalStore } from '../state/terminalStore';
 
 const store = useTerminalStore();
 const messagesEl = ref<HTMLElement | null>(null);
 
+interface TimelineItem {
+  type: 'message' | 'card';
+  key: string;
+  data: any;
+  _order: number;
+}
+
+const timeline = computed<TimelineItem[]>(() => {
+  const items: TimelineItem[] = [];
+  for (const msg of store.messages) {
+    if (msg.role !== 'system') {
+      items.push({ type: 'message', key: msg.id, data: msg, _order: 0 });
+    }
+  }
+  for (const preview of store.cardPreviews) {
+    items.push({ type: 'card', key: preview.id, data: preview, _order: 0 });
+  }
+  items.sort((a, b) => (a.data._seq || 0) - (b.data._seq || 0));
+  return items;
+});
+
+const isThinking = computed(() => {
+  if (!store.sending || !store.streaming) return false;
+  const last = store.messages[store.messages.length - 1];
+  return !last || last.role === 'user' || (last.role === 'assistant' && last.streaming && !last.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) {
@@ -95,13 +119,15 @@ async function handleSubmit(text: string) {
 
 function scrollToBottom() {
   nextTick(() => {
-    if (messagesEl.value) {
-      messagesEl.value.scrollTop = messagesEl.value.scrollHeight;
-    }
+    if (!messagesEl.value) return;
+    messagesEl.value.scrollTop = messagesEl.value.scrollHeight;
   });
 }
 
-watch(() => store.messages.length, () => scrollToBottom());
+watch(
+  () => [store.messages.length, store.cardPreviews.length],
+  () => scrollToBottom(),
+);
 </script>
 
 <style scoped>
@@ -234,6 +260,23 @@ watch(() => store.messages.length, () => scrollToBottom());
   border-bottom-left-radius: 4px;
 }
 
+.msg-success {
+  align-self: flex-start;
+  background: #f0faf5;
+  border: 1.5px solid #2ecc71;
+  border-bottom-left-radius: 4px;
+  color: #1a7a3a;
+}
+
+.msg-time {
+  display: block;
+  text-align: right;
+  font-size: 10px;
+  color: var(--muted);
+  margin-top: 4px;
+  opacity: 0.7;
+}
+
 /* ─── Mini chat cards ─── */
 .chat-card {
   align-self: flex-start;
@@ -276,37 +319,22 @@ watch(() => store.messages.length, () => scrollToBottom());
   line-height: 1.4;
 }
 
-/* ─── Loading skeleton ─── */
-.loading-block {
-  align-self: flex-start;
+/* ─── Thinking bubble ─── */
+.thinking-bubble {
+  color: var(--muted);
   display: flex;
-  flex-direction: column;
-  gap: 10px;
-  width: 70%;
-  padding: 16px;
+  gap: 1px;
 }
 
-.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;
+.wave-char {
+  display: inline-block;
+  animation: waveJump 2s 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);
+@keyframes waveJump {
+  0%, 100% { transform: translateY(0); }
+  25% { transform: translateY(-6px); }
+  50% { transform: translateY(0); }
 }
 
 /* ─── Disconnected overlay ─── */
@@ -376,4 +404,5 @@ watch(() => store.messages.length, () => scrollToBottom());
 .btn-outline:hover {
   background: var(--bg);
 }
+
 </style>

+ 114 - 3
src/layouts/ContextPanel.vue

@@ -3,7 +3,15 @@
     <!-- Idle: doctor image -->
     <DoctorAssistantFigure v-if="store.contextPanelMode === 'idle'" />
 
-    <!-- Loading: skeleton timeline -->
+    <!-- Thinking: doctor image with thought bubble overlay -->
+    <div v-else-if="store.contextPanelMode === 'thinking'" class="thinking-wrapper">
+      <div class="thinking-bubble-overlay">
+        <span class="wave-char" v-for="(c, i) in '思考中...'" :key="i" :style="{ animationDelay: i * 0.2 + 's' }">{{ c }}</span>
+      </div>
+      <DoctorAssistantFigure label="思考中" />
+    </div>
+
+    <!-- Loading: skeleton timeline (card-specific loading) -->
     <template v-else-if="store.contextPanelMode === 'loading'">
       <div class="pr-header">
         <div class="pr-label">分析中</div>
@@ -24,7 +32,11 @@
         </div>
       </div>
       <RegistrationProgress />
-      <div class="panel-card-container">
+      <div class="panel-card-container" :class="{ submitting: store.cardActionSubmitting }">
+        <div v-if="store.cardActionSubmitting" class="card-submitting-overlay">
+          <span class="card-spinner" />
+          <span>处理中...</span>
+        </div>
         <CardRenderer
           v-if="store.displayedCard"
           :card="store.displayedCard"
@@ -60,8 +72,9 @@ const store = useTerminalStore();
 
 async function handleAction(data: { actionName: string; payload: Record<string, unknown> }) {
   try {
-    await store.submitCard(data.payload);
+    store.addSelectionBubble(data.payload);
     store.saveSelection(data.payload);
+    await store.submitCard(data.payload);
   } catch {
     // error already set in store
   }
@@ -95,9 +108,107 @@ async function handleRetry() {
   font-weight: 500;
 }
 
+/* Thinking bubble overlay */
+.thinking-wrapper {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+}
+
+.thinking-bubble-overlay {
+  position: absolute;
+  top: 28%;
+  left: 50%;
+  transform: translateX(-50%);
+  background: linear-gradient(135deg, var(--surface) 0%, var(--accent-faint) 100%);
+  border: 1.5px solid var(--accent-soft);
+  border-radius: 16px;
+  padding: 14px 24px;
+  font-size: var(--fs-body);
+  color: var(--accent);
+  font-weight: 600;
+  z-index: 2;
+  box-shadow: 0 4px 20px rgba(43, 31, 153, 0.12), 0 1px 3px rgba(0, 0, 0, 0.06);
+  white-space: nowrap;
+  display: flex;
+  gap: 1px;
+}
+
+.thinking-bubble-overlay::after {
+  content: '';
+  position: absolute;
+  bottom: -10px;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 0;
+  height: 0;
+  border-left: 10px solid transparent;
+  border-right: 10px solid transparent;
+  border-top: 10px solid var(--accent-soft);
+}
+
+.thinking-bubble-overlay::before {
+  content: '';
+  position: absolute;
+  bottom: -7px;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 0;
+  height: 0;
+  border-left: 8px solid transparent;
+  border-right: 8px solid transparent;
+  border-top: 8px solid var(--surface);
+  z-index: 1;
+}
+
+.wave-char {
+  display: inline-block;
+  animation: waveJump 2s ease-in-out infinite;
+}
+
+@keyframes waveJump {
+  0%, 100% { transform: translateY(0); }
+  25% { transform: translateY(-6px); }
+  50% { transform: translateY(0); }
+}
+
 .panel-card-container {
   flex: 1;
   overflow-y: auto;
+  position: relative;
+}
+
+.panel-card-container.submitting {
+  pointer-events: none;
+}
+
+.card-submitting-overlay {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  background: rgba(255, 255, 255, 0.7);
+  z-index: 10;
+  border-radius: var(--radius-md);
+  font-size: var(--fs-sm);
+  color: var(--muted);
+}
+
+.card-spinner {
+  width: 24px;
+  height: 24px;
+  border: 2.5px solid var(--border);
+  border-top-color: var(--accent);
+  border-radius: 50%;
+  animation: spin 0.6s linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
 }
 
 /* ─── Loading skeleton timeline ─── */

+ 4 - 1
src/layouts/DoctorAssistantFigure.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="doctor-figure" aria-label="医生助手">
-    <div class="pr-label">空闲</div>
+    <div class="pr-label">{{ label }}</div>
     <div class="panel-idle">
       <img src="../assets/doctor-assistant.png" alt="医生助手" />
       <div class="panel-idle-name">医生助手</div>
@@ -9,6 +9,9 @@
   </div>
 </template>
 
+<script setup lang="ts">
+withDefaults(defineProps<{ label?: string }>(), { label: '空闲' });
+</script>
 <style scoped>
 .doctor-figure {
   display: flex;

+ 103 - 8
src/state/terminalStore.ts

@@ -11,7 +11,7 @@ function randomUUID(): string {
   });
 }
 
-export type ContextPanelMode = 'idle' | 'loading' | 'task' | 'error' | 'completed';
+export type ContextPanelMode = 'idle' | 'thinking' | 'loading' | 'task' | 'error' | 'completed';
 export type ConnectionStatus = 'online' | 'disconnected';
 
 export interface CardPreview {
@@ -19,6 +19,8 @@ export interface CardPreview {
   cardKey: string;
   title: string;
   subtitle: string;
+  timestamp: string;
+  _seq?: number;
 }
 
 const CARD_TITLE_MAP: Record<string, string> = {
@@ -37,6 +39,7 @@ export const useTerminalStore = defineStore('terminal', {
     conversationId: '' as string,
     currentTraceId: '' as string,
     messages: [] as ChatMessage[],
+    _timelineSeq: 0,
     streaming: false,
     task: null as TaskState | null,
     activeCard: null as CardInstance | null,
@@ -55,9 +58,10 @@ export const useTerminalStore = defineStore('terminal', {
   getters: {
     contextPanelMode(state): ContextPanelMode {
       if (state.errorMessage) return 'error';
-      if (state.sending && !state.activeCard && !state.viewingCardId) return 'loading';
+      if (state.sending && !state.activeCard && !state.viewingCardId) return 'thinking';
       if (this.displayedCard?.cardKey === 'appointment-success') return 'completed';
       if (this.displayedCard) return 'task';
+      if (state.sending || state.streaming) return 'thinking';
       return 'idle';
     },
     displayedCard(state): CardInstance | null {
@@ -72,6 +76,7 @@ export const useTerminalStore = defineStore('terminal', {
   },
   actions: {
     addMessage(message: ChatMessage) {
+      message._seq = ++this._timelineSeq;
       this.messages.push(message);
     },
     appendAssistantDelta(text: string) {
@@ -85,6 +90,8 @@ export const useTerminalStore = defineStore('terminal', {
         role: 'assistant',
         content: text,
         streaming: true,
+        timestamp: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
+        _seq: ++this._timelineSeq,
       });
     },
     completeAssistantMessage() {
@@ -108,20 +115,38 @@ export const useTerminalStore = defineStore('terminal', {
         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 || '';
+        const docs = data.doctors as Array<{ name: string; title: string; doctorName?: string }> | undefined;
+        const top = docs?.[0];
+        if (top) {
+          const dept = (this.selection.departmentName as string) || '';
+          subtitle = `${top.name || top.doctorName || ''} ${top.title || ''}`.trim();
+          if (dept) subtitle += ` · ${dept}`;
+        }
       } else if (cardKey === 'time-slot-selection') {
         const slots = data.slots as Array<{ timePeriod: string }> | undefined;
+        const amount = data.amountYuan ?? data.amount ?? '';
         subtitle = slots?.[0]?.timePeriod || '';
+        if (subtitle && amount) subtitle += ` · ¥${amount}`;
       } else if (cardKey === 'confirm-appointment') {
-        subtitle = '请确认预约信息';
+        const dept = (this.selection.departmentName as string) || (data.departmentName as string) || '';
+        const doc = (this.selection.doctorName as string) || (data.doctorName as string) || '';
+        const date = (data.visitDate as string) || '';
+        const time = (this.selection.timePeriod as string) || (data.visitTime as string) || '';
+        const amt = data.amountYuan ?? data.amount ?? '25.00';
+        subtitle = [dept, doc, date + (date && time ? ' ' : '') + time, `¥${amt}`].filter(Boolean).join(' · ');
       } else if (cardKey === 'payment-qrcode') {
         const amount = data.amountYuan || '0.00';
         subtitle = `Mock支付 ¥${amount}`;
       } else if (cardKey === 'appointment-success') {
-        subtitle = '预约已确认';
+        const aptNo = (data.appointmentNo as string) || (data.appointmentNoStr as string) || '';
+        const visitDate = (data.visitDate as string) || (data.visitTime as string) || '';
+        const visitTime = (data.timePeriod as string) || (this.selection.timePeriod as string) || '';
+        const docName = (data.doctorName as string) || (this.selection.doctorName as string) || '';
+        const docTitle = (data.doctorTitle as string) || (this.selection.doctorTitle as string) || '';
+        const parts = [aptNo, visitDate + visitTime, docName + (docTitle ? ' ' + docTitle : '')].filter(Boolean);
+        subtitle = parts.join(' · ') || '预约已确认';
       }
-      this.cardPreviews.push({ id: card.cardInstanceId, cardKey, title, subtitle });
+      this.cardPreviews.push({ id: card.cardInstanceId, cardKey, title, subtitle, timestamp: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), _seq: ++this._timelineSeq });
     },
     setError(message: string, traceId = '') {
       this.errorMessage = message;
@@ -196,7 +221,7 @@ export const useTerminalStore = defineStore('terminal', {
 
     async sendMessage(text: string): Promise<void> {
       if (this.sending) return; // 防重入:正在发送中不允许重复调用
-      this.addMessage({ id: randomUUID(), role: 'user', content: text });
+      this.addMessage({ id: randomUUID(), role: 'user', content: text, timestamp: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) });
       this.errorMessage = '';
       this.sending = true;
       this.streaming = true;
@@ -223,6 +248,75 @@ export const useTerminalStore = defineStore('terminal', {
       });
     },
 
+    addSelectionBubble(payload: Record<string, unknown>) {
+      const cardKey = this.activeCard?.cardKey ?? '';
+      let text = '';
+      if (cardKey === 'department-selection') text = (payload.departmentName as string) || '';
+      else if (cardKey === 'doctor-selection') text = `${payload.doctorTitle || ''} ${payload.doctorName || ''}`.trim();
+      else if (cardKey === 'time-slot-selection') text = (payload.timePeriod as string) || '';
+      else if (cardKey === 'confirm-appointment') text = '确认挂号';
+      else if (cardKey === 'payment-qrcode') return;
+      else text = JSON.stringify(payload);
+      if (text) {
+        this.addMessage({
+          id: randomUUID(),
+          role: 'user',
+          content: text,
+          timestamp: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
+        });
+      }
+    },
+
+    addTransitionMessage(nextCard: CardInstance) {
+      const currentKey = this.activeCard?.cardKey ?? '';
+      const nextKey = nextCard.cardKey;
+      let text = '';
+      if (currentKey === 'department-selection' && nextKey === 'doctor-selection') {
+        const deptName = (this.selection.departmentName as string) || '';
+        const docs = nextCard.cardData.doctors as Array<{ name: string; title: string }> | undefined;
+        if (docs?.[0]) {
+          text = `好的,以下是${deptName}可预约的医生。根据您的症状,建议优先选择${docs[0].title} ${docs[0].name}`;
+        } else {
+          text = `好的,以下是${deptName}可预约的医生`;
+        }
+      } else if (currentKey === 'doctor-selection' && nextKey === 'time-slot-selection') {
+        const dn = this.selection.doctorName || '';
+        const dt = this.selection.doctorTitle || '';
+        text = `好的,以下是${dt}${dn}的可预约时段`;
+      } else if (currentKey === 'time-slot-selection' && nextKey === 'confirm-appointment') {
+        text = '好的,请确认以下预约信息';
+      } else if (currentKey === 'confirm-appointment' && nextKey === 'payment-qrcode') {
+        text = '预约信息已确认,请扫码支付挂号费';
+      } else if (currentKey === 'payment-qrcode' && nextKey === 'appointment-success') {
+        text = '支付成功。您的挂号已确认,请按时到诊区签到候诊。系统将根据您的位置自动提醒签到。';
+      }
+      if (!text) return Promise.resolve();
+      return new Promise<void>((resolve) => {
+        const msgId = randomUUID();
+        const isSuccess = currentKey === 'payment-qrcode' && nextKey === 'appointment-success';
+        this.messages.push({
+          id: msgId,
+          role: 'assistant',
+          content: '',
+          streaming: true,
+          timestamp: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
+          _seq: ++this._timelineSeq,
+          _type: isSuccess ? 'success' : undefined,
+        });
+        let i = 0;
+        const timer = setInterval(() => {
+          const msg = this.messages.find((m) => m.id === msgId);
+          if (!msg) { clearInterval(timer); resolve(); return; }
+          msg.content += text[i++];
+          if (i >= text.length) {
+            clearInterval(timer);
+            msg.streaming = false;
+            resolve();
+          }
+        }, 40);
+      });
+    },
+
     async submitCard(payload: Record<string, unknown>): Promise<void> {
       if (!this.activeCard || this.cardActionSubmitting) return;
 
@@ -252,6 +346,7 @@ export const useTerminalStore = defineStore('terminal', {
 
         if (result.nextCard?.cardInstanceId) {
           const fullCard = await fetchCard(result.nextCard.cardInstanceId);
+          await this.addTransitionMessage(fullCard);
           this.setActiveCard(fullCard);
         }
       } catch (err) {