Browse Source

全部代码

ligao 1 month ago
parent
commit
b5801eca61

+ 9 - 1
src/api/sse.ts

@@ -1,6 +1,14 @@
 import type { SseEventType } from './types';
 import { buildAuthHeaders } from './hmac';
 
+function randomUUID(): string {
+  try { return crypto.randomUUID(); } catch { /* HTTP 环境不可用 */ }
+  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+    const r = (Math.random() * 16) | 0;
+    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
+  });
+}
+
 export interface ParsedSseEvent {
   type: SseEventType;
   data: Record<string, unknown>;
@@ -51,7 +59,7 @@ export function connectChatStream(options: ChatStreamOptions): AbortController {
     message: {
       type: 'text',
       text: options.text,
-      clientMessageId: options.clientMessageId ?? crypto.randomUUID(),
+      clientMessageId: options.clientMessageId ?? randomUUID(),
     },
     conversationId: options.conversationId || undefined,
   });

+ 1 - 1
src/cards/ConfirmAppointmentCard.vue

@@ -24,7 +24,7 @@
       </div>
     </div>
     <div class="confirm-btns">
-      <button class="btn-accent" @click="$emit('action', { actionName: 'confirm_appointment', payload: {} })">确认挂号</button>
+      <button class="btn-accent" :disabled="store.cardActionSubmitting" @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>

+ 11 - 1
src/cards/DepartmentSelectionCard.vue

@@ -3,7 +3,7 @@
     <div
       v-for="(dept, idx) in departments"
       :key="dept.deptId"
-      :class="['dept-full', { rec: idx === 0 }]"
+      :class="['dept-full', { rec: idx === 0, submitting: store.cardActionSubmitting }]"
       @click="select(dept)"
     >
       <span v-if="idx === 0" class="dept-badge">推荐</span>
@@ -19,10 +19,12 @@
 
 <script setup lang="ts">
 import { computed } from 'vue';
+import { useTerminalStore } from '../state/terminalStore';
 import type { CardInstance } from '../api/types';
 
 const props = defineProps<{ card: CardInstance }>();
 const emit = defineEmits<{ action: [data: { actionName: string; payload: Record<string, unknown> }] }>();
+const store = useTerminalStore();
 
 interface DepartmentOption {
   deptId: string;
@@ -42,6 +44,7 @@ const departments = computed(() => {
 });
 
 function select(dept: DepartmentOption) {
+  if (store.cardActionSubmitting) return;
   emit('action', {
     actionName: 'select_department',
     payload: { departmentId: dept.deptId, departmentName: dept.name },
@@ -49,6 +52,13 @@ function select(dept: DepartmentOption) {
 }
 </script>
 
+<style scoped>
+.dept-full.submitting {
+  pointer-events: none;
+  opacity: 0.5;
+}
+</style>
+
 
 <style scoped>
 .dept-list {

+ 3 - 0
src/cards/DoctorSelectionCard.vue

@@ -19,10 +19,12 @@
 
 <script setup lang="ts">
 import { computed } from 'vue';
+import { useTerminalStore } from '../state/terminalStore';
 import type { CardInstance } from '../api/types';
 
 const props = defineProps<{ card: CardInstance }>();
 const emit = defineEmits<{ action: [data: { actionName: string; payload: Record<string, unknown> }] }>();
+const store = useTerminalStore();
 
 interface DoctorOption {
   doctorId: string;
@@ -35,6 +37,7 @@ interface DoctorOption {
 const doctors = computed(() => (props.card.cardData.doctors as DoctorOption[]) ?? []);
 
 function select(doc: DoctorOption) {
+  if (store.cardActionSubmitting) return;
   emit('action', {
     actionName: 'select_doctor',
     payload: { doctorId: doc.doctorId, doctorName: doc.name, doctorTitle: doc.title },

+ 1 - 1
src/cards/PaymentQrCard.vue

@@ -11,7 +11,7 @@
       <div class="tl">支付说明</div>
       <div class="tt">当前为联调演示支付,不会产生真实扣费。支付成功后系统将自动确认预约。</div>
     </div>
-    <button class="btn-accent" @click="$emit('action', { actionName: 'mock_payment_paid', payload: { orderId } })">模拟支付完成</button>
+    <button class="btn-accent" :disabled="store.cardActionSubmitting" @click="$emit('action', { actionName: 'mock_payment_paid', payload: { orderId } })">模拟支付完成</button>
   </section>
 </template>
 

+ 1 - 0
src/cards/TimeSlotSelectionCard.vue

@@ -45,6 +45,7 @@ const room = computed(() => store.selection.room || props.card.cardData.room as
 const selectedSlot = ref<string | null>(null);
 
 function selectSlot(slot: SlotOption) {
+  if (store.cardActionSubmitting) return;
   selectedSlot.value = slot.slotId;
   emit('action', {
     actionName: 'select_time_slot',

+ 9 - 1
src/chat/ChatInput.vue

@@ -32,6 +32,14 @@
 <script setup lang="ts">
 import { useTerminalStore } from '../state/terminalStore';
 
+function randomUUID(): string {
+  try { return crypto.randomUUID(); } catch { /* HTTP */ }
+  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+    const r = (Math.random() * 16) | 0;
+    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
+  });
+}
+
 defineProps<{ disabled?: boolean }>();
 const emit = defineEmits<{ submit: [text: string] }>();
 const store = useTerminalStore();
@@ -53,7 +61,7 @@ function onKeydown(e: KeyboardEvent) {
 
 function showVoiceHint() {
   store.addMessage({
-    id: crypto.randomUUID(),
+    id: randomUUID(),
     role: 'system',
     content: '语音能力将在后续版本接入,当前请使用文字输入。',
   });

+ 2 - 8
src/layouts/ContextPanel.vue

@@ -59,9 +59,9 @@ import RegistrationProgress from './RegistrationProgress.vue';
 const store = useTerminalStore();
 
 async function handleAction(data: { actionName: string; payload: Record<string, unknown> }) {
-  store.saveSelection(data.payload);
   try {
     await store.submitCard(data.payload);
+    store.saveSelection(data.payload);
   } catch {
     // error already set in store
   }
@@ -69,13 +69,7 @@ async function handleAction(data: { actionName: string; payload: Record<string,
 
 async function handleRetry() {
   store.errorMessage = '';
-  if (store.activeCard && store.selection) {
-    try {
-      await store.submitCard(store.selection);
-    } catch {
-      // error already set in store
-    }
-  }
+  // 仅清除错误,让用户在原卡片上重新操作(不自动重发旧数据)
 }
 </script>
 

+ 26 - 3
src/state/terminalStore.ts

@@ -3,6 +3,14 @@ import type { CardInstance, ChatMessage, TaskState } from '../api/types';
 import { connectChatStream, type ParsedSseEvent } from '../api/sse';
 import { fetchCard, submitCardAction } from '../api/client';
 
+function randomUUID(): string {
+  try { return randomUUID(); } catch { /* HTTP */ }
+  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+    const r = (Math.random() * 16) | 0;
+    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
+  });
+}
+
 export type ContextPanelMode = 'idle' | 'loading' | 'task' | 'error' | 'completed';
 export type ConnectionStatus = 'online' | 'disconnected';
 
@@ -41,6 +49,7 @@ export const useTerminalStore = defineStore('terminal', {
     pendingActionKey: '',
     demoMode: import.meta.env.VITE_DEMO_MODE === 'true',
     sending: false,
+    cardActionSubmitting: false,
     selection: {} as Record<string, string>,
   }),
   getters: {
@@ -72,7 +81,7 @@ export const useTerminalStore = defineStore('terminal', {
         return;
       }
       this.messages.push({
-        id: crypto.randomUUID(),
+        id: randomUUID(),
         role: 'assistant',
         content: text,
         streaming: true,
@@ -187,7 +196,7 @@ export const useTerminalStore = defineStore('terminal', {
 
     async sendMessage(text: string): Promise<void> {
       if (this.sending) return; // 防重入:正在发送中不允许重复调用
-      this.addMessage({ id: crypto.randomUUID(), role: 'user', content: text });
+      this.addMessage({ id: randomUUID(), role: 'user', content: text });
       this.errorMessage = '';
       this.sending = true;
       this.streaming = true;
@@ -215,13 +224,22 @@ export const useTerminalStore = defineStore('terminal', {
     },
 
     async submitCard(payload: Record<string, unknown>): Promise<void> {
-      if (!this.activeCard) return;
+      if (!this.activeCard || this.cardActionSubmitting) return;
 
       const actionName = this.activeCard.actions[0]?.actionName;
       if (!actionName) return;
 
       this.pendingActionKey = `${this.activeCard.cardInstanceId}-${actionName}`;
       this.errorMessage = '';
+      this.cardActionSubmitting = true;
+
+      // 15 秒超时兜底,防止网络异常导致按钮永久禁用
+      const timeoutId = setTimeout(() => {
+        if (this.cardActionSubmitting) {
+          this.setError('请求超时,请重试');
+          this.cardActionSubmitting = false;
+        }
+      }, 15_000);
 
       try {
         const result = await submitCardAction(
@@ -230,13 +248,18 @@ export const useTerminalStore = defineStore('terminal', {
           payload,
         );
 
+        clearTimeout(timeoutId);
+
         if (result.nextCard?.cardInstanceId) {
           const fullCard = await fetchCard(result.nextCard.cardInstanceId);
           this.setActiveCard(fullCard);
         }
       } catch (err) {
+        clearTimeout(timeoutId);
         this.setError((err as Error).message);
         throw err;
+      } finally {
+        this.cardActionSubmitting = false;
       }
     },
   },