@tencent-rtc/trtc-agent-skills 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/README.zh.md +173 -0
  4. package/bin/cli.js +434 -0
  5. package/knowledge-base/index.yaml +454 -0
  6. package/knowledge-base/platform-slice-template.md +233 -0
  7. package/knowledge-base/scenario-spec.md +350 -0
  8. package/knowledge-base/scenarios/conference/base/general-conference.md +365 -0
  9. package/knowledge-base/scenarios/conference/base/webinar-conference.md +130 -0
  10. package/knowledge-base/scenarios/conference/medical/1v1-video-consultation.md +145 -0
  11. package/knowledge-base/scenarios/conference/medical/medical-multidoctor-consultation.md +113 -0
  12. package/knowledge-base/scenarios/live/entertainment-live-room.md +118 -0
  13. package/knowledge-base/slice-spec.md +546 -0
  14. package/knowledge-base/slices/conference/web/ai-tools.md +225 -0
  15. package/knowledge-base/slices/conference/web/beauty-effects.md +188 -0
  16. package/knowledge-base/slices/conference/web/device-control.md +338 -0
  17. package/knowledge-base/slices/conference/web/login-auth.md +261 -0
  18. package/knowledge-base/slices/conference/web/network-quality.md +190 -0
  19. package/knowledge-base/slices/conference/web/official-roomkit-api.md +298 -0
  20. package/knowledge-base/slices/conference/web/official-roomkit-login-ui.md +246 -0
  21. package/knowledge-base/slices/conference/web/participant-list.md +238 -0
  22. package/knowledge-base/slices/conference/web/participant-management.md +718 -0
  23. package/knowledge-base/slices/conference/web/prejoin-check.md +293 -0
  24. package/knowledge-base/slices/conference/web/room-call.md +213 -0
  25. package/knowledge-base/slices/conference/web/room-chat.md +426 -0
  26. package/knowledge-base/slices/conference/web/room-lifecycle.md +534 -0
  27. package/knowledge-base/slices/conference/web/room-schedule.md +281 -0
  28. package/knowledge-base/slices/conference/web/screen-share.md +211 -0
  29. package/knowledge-base/slices/conference/web/video-layout.md +675 -0
  30. package/knowledge-base/slices/conference/web/virtual-background.md +197 -0
  31. package/knowledge-base/slices/conference/web/webinar-interaction.md +206 -0
  32. package/knowledge-base/slices/live/anchor-lifecycle.md +122 -0
  33. package/knowledge-base/slices/live/anchor-preview.md +90 -0
  34. package/knowledge-base/slices/live/anchor-room-config.md +104 -0
  35. package/knowledge-base/slices/live/audience-list.md +86 -0
  36. package/knowledge-base/slices/live/audience-manage.md +92 -0
  37. package/knowledge-base/slices/live/audience-watch.md +85 -0
  38. package/knowledge-base/slices/live/audio.md +116 -0
  39. package/knowledge-base/slices/live/barrage.md +88 -0
  40. package/knowledge-base/slices/live/beauty.md +99 -0
  41. package/knowledge-base/slices/live/coguest-apply.md +105 -0
  42. package/knowledge-base/slices/live/device-control.md +91 -0
  43. package/knowledge-base/slices/live/error-codes.md +167 -0
  44. package/knowledge-base/slices/live/gift.md +84 -0
  45. package/knowledge-base/slices/live/ios/.gitkeep +0 -0
  46. package/knowledge-base/slices/live/ios/anchor-lifecycle.md +313 -0
  47. package/knowledge-base/slices/live/ios/anchor-preview.md +228 -0
  48. package/knowledge-base/slices/live/ios/anchor-room-config.md +257 -0
  49. package/knowledge-base/slices/live/ios/audience-list.md +353 -0
  50. package/knowledge-base/slices/live/ios/audience-manage.md +381 -0
  51. package/knowledge-base/slices/live/ios/audience-watch.md +286 -0
  52. package/knowledge-base/slices/live/ios/audio.md +373 -0
  53. package/knowledge-base/slices/live/ios/barrage.md +285 -0
  54. package/knowledge-base/slices/live/ios/beauty.md +323 -0
  55. package/knowledge-base/slices/live/ios/coguest-apply.md +506 -0
  56. package/knowledge-base/slices/live/ios/device-control.md +286 -0
  57. package/knowledge-base/slices/live/ios/error-codes.md +270 -0
  58. package/knowledge-base/slices/live/ios/gift.md +315 -0
  59. package/knowledge-base/slices/live/ios/live-list.md +269 -0
  60. package/knowledge-base/slices/live/ios/login-auth.md +247 -0
  61. package/knowledge-base/slices/live/live-list.md +82 -0
  62. package/knowledge-base/slices/live/login-auth.md +78 -0
  63. package/package.json +34 -0
  64. package/skills/trtc/SKILL.md +326 -0
  65. package/skills/trtc/room-builder/SKILL.md +138 -0
  66. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/README.md +108 -0
  67. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/docs/backend-contract.zh-CN.md +162 -0
  68. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/docs/integration.zh-CN.md +154 -0
  69. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/docs/theme.zh-CN.md +78 -0
  70. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/index.html +12 -0
  71. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/package.json +28 -0
  72. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/postcss.config.js +5 -0
  73. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/App.vue +25 -0
  74. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/components/ConsultationManagePanel.vue +838 -0
  75. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/components/LanguageSwitch.vue +102 -0
  76. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/components/LoadingSpinner.vue +6 -0
  77. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/components/MedicalAlert.vue +34 -0
  78. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/components/MedicalBusinessPanel.vue +148 -0
  79. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/components/MedicalButton.vue +49 -0
  80. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/components/MedicalConfirmDialog.vue +68 -0
  81. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/components/MedicalDataPanel.vue +196 -0
  82. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/components/MedicalRecordPanel.vue +270 -0
  83. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/components/PrescriptionPanel.vue +363 -0
  84. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/config/basic-info-config.ts +29 -0
  85. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/config/lib-generate-test-usersig-es.min.d.ts +4 -0
  86. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/config/lib-generate-test-usersig-es.min.js +2 -0
  87. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/config/runtime-config.ts +12 -0
  88. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/env.d.ts +32 -0
  89. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/features/consultation/components/ConsultationChatPanel.vue +123 -0
  90. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/features/consultation/components/ConsultationMembersPanel.vue +230 -0
  91. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/features/consultation/components/ConsultationTranscriptionPanel.vue +135 -0
  92. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/features/consultation/components/ConsultationVideoStage.vue +113 -0
  93. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/features/consultation/components/InviteDoctorDialog.vue +132 -0
  94. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/features/consultation/components/KickMemberConfirmDialog.vue +50 -0
  95. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/features/consultation/types.ts +77 -0
  96. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/features/consultation/useConsultationChat.ts +97 -0
  97. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/features/consultation/useConsultationDevices.ts +48 -0
  98. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/features/consultation/useConsultationParticipants.ts +121 -0
  99. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/features/consultation/useConsultationPermissions.ts +25 -0
  100. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/features/consultation/utils.ts +70 -0
  101. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/i18n/en-US/index.ts +553 -0
  102. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/i18n/index.ts +25 -0
  103. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/i18n/medicalTranslate.ts +85 -0
  104. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/i18n/state.ts +49 -0
  105. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/i18n/zh-CN/index.ts +463 -0
  106. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/main.ts +12 -0
  107. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/mock/appointments.ts +96 -0
  108. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/mock/users.ts +79 -0
  109. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/router/index.ts +63 -0
  110. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/services/adapters/index.ts +25 -0
  111. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/services/adapters/integration/appointmentService.ts +77 -0
  112. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/services/adapters/integration/authService.ts +38 -0
  113. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/services/adapters/integration/launchContext.ts +31 -0
  114. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/services/adapters/integration/userService.ts +35 -0
  115. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/services/adapters/mock/appointmentService.ts +43 -0
  116. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/services/adapters/mock/authService.ts +33 -0
  117. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/services/adapters/mock/userService.ts +43 -0
  118. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/services/adapters/types.ts +135 -0
  119. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/shared/icons.ts +53 -0
  120. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/styles/index.css +106 -0
  121. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/styles/tailwind.css +3 -0
  122. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/styles/theme.css +209 -0
  123. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/utils/auth.ts +50 -0
  124. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/utils/format.ts +24 -0
  125. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/utils/navigation.ts +12 -0
  126. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/utils/session.ts +28 -0
  127. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/views/DoctorConsultationView.vue +777 -0
  128. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/views/DoctorDashboardView.vue +678 -0
  129. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/views/LoginView.vue +441 -0
  130. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/views/PatientConsultationFinishedView.vue +185 -0
  131. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/views/PatientConsultationView.vue +1003 -0
  132. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/views/PatientSelectDoctorView.vue +317 -0
  133. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/src/views/PatientWaitingView.vue +454 -0
  134. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/tsconfig.json +21 -0
  135. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/tsconfig.node.json +8 -0
  136. package/skills/trtc/room-builder/templates/scenarios/medical-consultation/vite.config.ts +17 -0
  137. package/skills/trtc/room-builder/templates/scenarios/medical-consultation//346/216/245/345/205/245/350/257/264/346/230/216.md +6 -0
  138. package/skills/trtc/room-builder/tools/render_ai_instructions.py +226 -0
  139. package/skills/trtc-apply/SKILL.md +97 -0
  140. package/skills/trtc-apply/guardrails/apply_lib/__init__.py +0 -0
  141. package/skills/trtc-apply/guardrails/apply_lib/__pycache__/__init__.cpython-313.pyc +0 -0
  142. package/skills/trtc-apply/guardrails/apply_lib/__pycache__/rule_parser.cpython-313.pyc +0 -0
  143. package/skills/trtc-apply/guardrails/apply_lib/rule_parser.py +268 -0
  144. package/skills/trtc-docs/SKILL.md +207 -0
  145. package/skills/trtc-onboarding/SKILL.md +839 -0
  146. package/skills/trtc-onboarding/reference/path-a1-demo.md +103 -0
  147. package/skills/trtc-onboarding/reference/path-a2-integrate.md +693 -0
  148. package/skills/trtc-onboarding/reference/path-b-troubleshoot.md +115 -0
  149. package/skills/trtc-onboarding/reference/path-c-expand.md +43 -0
  150. package/skills/trtc-onboarding/reference/reporting-protocol.md +174 -0
  151. package/skills/trtc-onboarding/reference/supported-matrix.md +100 -0
  152. package/skills/trtc-onboarding/reference/usersig-handling.md +140 -0
  153. package/skills/trtc-search/SKILL.md +221 -0
  154. package/skills/trtc-topic/SKILL.md +638 -0
  155. package/skills/trtc-topic/guardrails/__pycache__/gate_slice_read.cpython-313.pyc +0 -0
  156. package/skills/trtc-topic/guardrails/__pycache__/gate_slice_write.cpython-313.pyc +0 -0
  157. package/skills/trtc-topic/guardrails/__pycache__/stop_require_apply_evidence.cpython-313.pyc +0 -0
  158. package/skills/trtc-topic/guardrails/gate_slice_read.py +133 -0
  159. package/skills/trtc-topic/guardrails/gate_slice_write.py +169 -0
  160. package/skills/trtc-topic/guardrails/stop_require_apply_evidence.py +97 -0
  161. package/skills/trtc-topic/references/execution-units.yaml +58 -0
  162. package/skills/trtc-topic/runtime/README.md +50 -0
  163. package/skills/trtc-topic/runtime/RUNTIME.md +128 -0
  164. package/skills/trtc-topic/runtime/lib/__init__.py +0 -0
  165. package/skills/trtc-topic/runtime/lib/platforms.py +194 -0
  166. package/skills/trtc-topic/runtime/package-lock.json +1211 -0
  167. package/skills/trtc-topic/runtime/package.json +13 -0
  168. package/skills/trtc-topic/runtime/telemetry-bridge.mjs +339 -0
  169. package/skills/trtc-topic/runtime/telemetry_collector.py +293 -0
  170. package/skills/trtc-topic/scripts/STATE-MACHINE-GUIDE.md +186 -0
  171. package/skills/trtc-topic/scripts/__pycache__/apply.cpython-313.pyc +0 -0
  172. package/skills/trtc-topic/scripts/apply.py +581 -0
  173. package/skills/trtc-topic/scripts/finalize_session.py +113 -0
  174. package/skills/trtc-topic/scripts/init_slice_queue.py +96 -0
  175. package/skills/trtc-topic/scripts/lib/__pycache__/state_machine.cpython-313.pyc +0 -0
  176. package/skills/trtc-topic/scripts/lib/state_machine.py +328 -0
  177. package/skills/trtc-topic/scripts/next_slice.py +137 -0
  178. package/skills/trtc-topic/tests/README.md +70 -0
  179. package/skills/trtc-topic/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc +0 -0
  180. package/skills/trtc-topic/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  181. package/skills/trtc-topic/tests/__pycache__/test_apply_cli.cpython-313-pytest-9.0.2.pyc +0 -0
  182. package/skills/trtc-topic/tests/__pycache__/test_apply_cli.cpython-313-pytest-9.0.3.pyc +0 -0
  183. package/skills/trtc-topic/tests/__pycache__/test_end_to_end.cpython-313-pytest-9.0.2.pyc +0 -0
  184. package/skills/trtc-topic/tests/__pycache__/test_end_to_end.cpython-313-pytest-9.0.3.pyc +0 -0
  185. package/skills/trtc-topic/tests/__pycache__/test_finalize_session.cpython-313-pytest-9.0.2.pyc +0 -0
  186. package/skills/trtc-topic/tests/__pycache__/test_finalize_session.cpython-313-pytest-9.0.3.pyc +0 -0
  187. package/skills/trtc-topic/tests/__pycache__/test_gates.cpython-313-pytest-9.0.2.pyc +0 -0
  188. package/skills/trtc-topic/tests/__pycache__/test_gates.cpython-313-pytest-9.0.3.pyc +0 -0
  189. package/skills/trtc-topic/tests/__pycache__/test_session_resolver.cpython-313-pytest-9.0.2.pyc +0 -0
  190. package/skills/trtc-topic/tests/__pycache__/test_session_resolver.cpython-313-pytest-9.0.3.pyc +0 -0
  191. package/skills/trtc-topic/tests/__pycache__/test_state_machine.cpython-313-pytest-9.0.2.pyc +0 -0
  192. package/skills/trtc-topic/tests/__pycache__/test_state_machine.cpython-313-pytest-9.0.3.pyc +0 -0
  193. package/skills/trtc-topic/tests/__pycache__/test_stop_require_apply.cpython-313-pytest-9.0.2.pyc +0 -0
  194. package/skills/trtc-topic/tests/__pycache__/test_stop_require_apply.cpython-313-pytest-9.0.3.pyc +0 -0
  195. package/skills/trtc-topic/tests/__pycache__/test_topic_skill_invariants.cpython-313-pytest-9.0.2.pyc +0 -0
  196. package/skills/trtc-topic/tests/__pycache__/test_topic_skill_invariants.cpython-313-pytest-9.0.3.pyc +0 -0
  197. package/skills/trtc-topic/tests/conftest.py +72 -0
  198. package/skills/trtc-topic/tests/test_apply_cli.py +480 -0
  199. package/skills/trtc-topic/tests/test_end_to_end.py +305 -0
  200. package/skills/trtc-topic/tests/test_finalize_session.py +51 -0
  201. package/skills/trtc-topic/tests/test_gates.py +316 -0
  202. package/skills/trtc-topic/tests/test_session_resolver.py +260 -0
  203. package/skills/trtc-topic/tests/test_state_machine.py +414 -0
  204. package/skills/trtc-topic/tests/test_stop_require_apply.py +99 -0
  205. package/skills/trtc-topic/tests/test_topic_skill_invariants.py +130 -0
@@ -0,0 +1,1003 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
3
+ import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
4
+ import { useUIKit } from '@tencentcloud/uikit-base-component-vue3';
5
+ import {
6
+ Mic,
7
+ MicOff,
8
+ Camera,
9
+ CameraOff,
10
+ PhoneOff,
11
+ Volume2,
12
+ VolumeX,
13
+ MessageCircle,
14
+ FileText,
15
+ ChevronDown,
16
+ Clock,
17
+ Send,
18
+ X,
19
+ } from '@/shared/icons';
20
+ import {
21
+ useAITranscriberState,
22
+ DeviceType,
23
+ RoomEvent,
24
+ RoomParticipantEvent,
25
+ useDeviceState,
26
+ useRoomParticipantState,
27
+ useRoomState,
28
+ } from 'tuikit-atomicx-vue3';
29
+ import ConsultationVideoStage from '@/features/consultation/components/ConsultationVideoStage.vue';
30
+ import { useConsultationParticipants } from '@/features/consultation/useConsultationParticipants';
31
+ import { useConsultationChat } from '@/features/consultation/useConsultationChat';
32
+ import { useConsultationDevices } from '@/features/consultation/useConsultationDevices';
33
+ import {
34
+ formatConsultationClock,
35
+ getConsultationMessageText,
36
+ normalizeConsultationTimestamp,
37
+ } from '@/features/consultation/utils';
38
+ import type { ConsultationCallEvent } from '@/features/consultation/types';
39
+ import { services } from '@/services/adapters';
40
+ import { getSessionUser } from '@/utils/session';
41
+ import LoadingSpinner from '@/components/LoadingSpinner.vue';
42
+ import MedicalAlert from '@/components/MedicalAlert.vue';
43
+
44
+ const route = useRoute();
45
+ const router = useRouter();
46
+ const { t } = useUIKit();
47
+ const roomState = useRoomState();
48
+ const participantState = useRoomParticipantState();
49
+ const deviceState = useDeviceState();
50
+ const { realtimeMessageList } = useAITranscriberState();
51
+
52
+ const appointment = computed(() =>
53
+ services.appointment.getAppointmentById(String(route.params.appointmentId))
54
+ );
55
+ const doctor = computed(() =>
56
+ appointment.value
57
+ ? services.user.getDoctorById(appointment.value.doctorId)
58
+ : null
59
+ );
60
+ const patient = computed(() =>
61
+ appointment.value
62
+ ? services.user.getPatientById(appointment.value.patientId)
63
+ : null
64
+ );
65
+
66
+ const localParticipantInfo = computed(
67
+ () => participantState.localParticipant.value
68
+ );
69
+
70
+ const primaryDoctorId = computed(() => appointment.value?.doctorId);
71
+ const patientId = computed(() => appointment.value?.patientId);
72
+ const {
73
+ mainMember: mainVideoMember,
74
+ thumbnailMembers: thumbnailVideoMembers,
75
+ focusParticipant,
76
+ } = useConsultationParticipants({
77
+ participantList: participantState.participantList,
78
+ participantListWithVideo: participantState.participantListWithVideo,
79
+ localParticipant: localParticipantInfo,
80
+ primaryDoctorId,
81
+ patientId,
82
+ preferredFocusUserId: primaryDoctorId,
83
+ getDoctorById: services.user.getDoctorById.bind(services.user),
84
+ getPatientById: services.user.getPatientById.bind(services.user),
85
+ selfDisplayName: t('Medical.Common.Me'),
86
+ });
87
+ const timer = ref(0);
88
+ const consultationSessionStartedAt = ref(Date.now());
89
+ const speakerEnabled = ref(true);
90
+ const endingByDoctor = ref(false);
91
+ const leavingConsultation = ref(false);
92
+ const deviceAction = ref<'camera' | 'microphone' | ''>('');
93
+ const chatPanelVisible = ref(false);
94
+ const transcriptionPanelVisible = ref(false);
95
+ const chatContainerRef = ref<HTMLElement | null>(null);
96
+ const transcriptionContainerRef = ref<HTMLElement | null>(null);
97
+ const seenTranscriptionCount = ref(0);
98
+ const deviceInviteVisible = ref(false);
99
+ const deviceInviteLoading = ref(false);
100
+ const deviceInviteDecision = ref<'accept' | 'decline' | ''>('');
101
+ const deviceInviteHint = ref('');
102
+ const {
103
+ devicePermissionHint,
104
+ getDeviceErrorHint,
105
+ updateDevicePermissionHint,
106
+ } = useConsultationDevices();
107
+ const pendingDeviceInvitation = ref<{
108
+ senderUserId: string;
109
+ senderName: string;
110
+ deviceType: DeviceType;
111
+ } | null>(null);
112
+ let timerInterval: number | null = null;
113
+ let clearingSession = false;
114
+
115
+ const doctorUserId = computed(() => doctor.value?.userId);
116
+ const {
117
+ activeConversation,
118
+ chatInput,
119
+ chatError,
120
+ isSendingMessage,
121
+ chatMessages,
122
+ ensureChatConversation,
123
+ handleSendMessage,
124
+ clearChatConversation,
125
+ } = useConsultationChat({
126
+ peerUserId: doctorUserId,
127
+ sessionStartedAt: consultationSessionStartedAt,
128
+ emptyErrorText: t('Medical.Chat.SessionInitFailed'),
129
+ });
130
+ const transcriptionMessages = computed(() =>
131
+ [...(realtimeMessageList.value ?? [])]
132
+ .filter(
133
+ item =>
134
+ normalizeConsultationTimestamp(item.timestamp) >=
135
+ consultationSessionStartedAt.value
136
+ )
137
+ .sort((a, b) => a.timestamp - b.timestamp)
138
+ .slice(-120)
139
+ );
140
+ const transcriptionUnreadCount = computed(() =>
141
+ Math.max(0, transcriptionMessages.value.length - seenTranscriptionCount.value)
142
+ );
143
+ const transcriptionUnreadText = computed(() => {
144
+ if (transcriptionUnreadCount.value <= 0) {
145
+ return '';
146
+ }
147
+ return transcriptionUnreadCount.value > 99
148
+ ? '99+'
149
+ : String(transcriptionUnreadCount.value);
150
+ });
151
+
152
+ const formattedTimer = computed(() => {
153
+ const mins = Math.floor(timer.value / 60);
154
+ const secs = timer.value % 60;
155
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
156
+ });
157
+
158
+ async function clearConsultationSession(options: { leaveRoom?: boolean } = {}) {
159
+ if (clearingSession) {
160
+ return;
161
+ }
162
+ clearingSession = true;
163
+ try {
164
+ chatPanelVisible.value = false;
165
+ transcriptionPanelVisible.value = false;
166
+ seenTranscriptionCount.value = 0;
167
+ deviceInviteVisible.value = false;
168
+ deviceInviteLoading.value = false;
169
+ deviceInviteDecision.value = '';
170
+ devicePermissionHint.value = '';
171
+ pendingDeviceInvitation.value = null;
172
+ if (options.leaveRoom) {
173
+ await roomState.leaveRoom().catch(() => undefined);
174
+ }
175
+ await clearChatConversation();
176
+ } finally {
177
+ clearingSession = false;
178
+ }
179
+ }
180
+
181
+ async function jumpToFinishedPage() {
182
+ if (!appointment.value || endingByDoctor.value) {
183
+ return;
184
+ }
185
+ endingByDoctor.value = true;
186
+ await clearConsultationSession({ leaveRoom: true });
187
+ router.replace({
188
+ path: `/patient/consultation-finished/${appointment.value.id}`,
189
+ query: { duration: String(timer.value) },
190
+ });
191
+ }
192
+
193
+ function handleRoomEnded(eventInfo: ConsultationCallEvent) {
194
+ if (eventInfo?.roomInfo?.roomId !== appointment.value?.roomId) {
195
+ return;
196
+ }
197
+ void jumpToFinishedPage();
198
+ }
199
+
200
+ async function leaveConsultation() {
201
+ await leaveConsultationTo(`/patient/waiting/${route.params.appointmentId}`);
202
+ }
203
+
204
+ async function leaveConsultationTo(targetPath: string) {
205
+ if (leavingConsultation.value || endingByDoctor.value) {
206
+ return;
207
+ }
208
+ leavingConsultation.value = true;
209
+ try {
210
+ await clearConsultationSession({ leaveRoom: true });
211
+ await router.replace(targetPath);
212
+ } finally {
213
+ leavingConsultation.value = false;
214
+ }
215
+ }
216
+
217
+ async function ensureJoined() {
218
+ if (!appointment.value) {
219
+ return;
220
+ }
221
+ if (
222
+ !roomState.currentRoom.value ||
223
+ roomState.currentRoom.value.roomId !== appointment.value.roomId
224
+ ) {
225
+ await roomState.joinRoom({ roomId: appointment.value.roomId });
226
+ }
227
+ }
228
+
229
+ function formatMessageTime(timestamp?: number) {
230
+ return formatConsultationClock(timestamp, false);
231
+ }
232
+
233
+ function scrollChatToBottom() {
234
+ if (!chatContainerRef.value) {
235
+ return;
236
+ }
237
+ chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight;
238
+ }
239
+
240
+ function scrollTranscriptionToBottom() {
241
+ if (!transcriptionContainerRef.value) {
242
+ return;
243
+ }
244
+ transcriptionContainerRef.value.scrollTop =
245
+ transcriptionContainerRef.value.scrollHeight;
246
+ }
247
+
248
+ function isPatientSpeaker(userId?: string) {
249
+ if (!userId) {
250
+ return false;
251
+ }
252
+ return (
253
+ userId === patient.value?.userId ||
254
+ userId === localParticipantInfo.value?.userId
255
+ );
256
+ }
257
+
258
+ function getTranscriptionSpeakerName(userId?: string) {
259
+ if (!userId) {
260
+ return t('Medical.Common.Member');
261
+ }
262
+ if (userId === doctor.value?.userId) {
263
+ return doctor.value?.userName || t('Medical.Common.Doctor');
264
+ }
265
+ if (isPatientSpeaker(userId)) {
266
+ return t('Medical.Common.Me');
267
+ }
268
+ return t('Medical.Common.Member');
269
+ }
270
+
271
+ function formatTranscriptionTime(timestamp?: number) {
272
+ return formatConsultationClock(timestamp, false);
273
+ }
274
+
275
+ function closeTranscriptionPanel() {
276
+ transcriptionPanelVisible.value = false;
277
+ }
278
+
279
+ async function toggleTranscriptionPanel() {
280
+ const nextVisible = !transcriptionPanelVisible.value;
281
+ transcriptionPanelVisible.value = nextVisible;
282
+ if (nextVisible) {
283
+ chatPanelVisible.value = false;
284
+ seenTranscriptionCount.value = transcriptionMessages.value.length;
285
+ await nextTick();
286
+ scrollTranscriptionToBottom();
287
+ }
288
+ }
289
+
290
+ function toggleChatPanel() {
291
+ const nextVisible = !chatPanelVisible.value;
292
+ chatPanelVisible.value = nextVisible;
293
+ if (nextVisible) {
294
+ transcriptionPanelVisible.value = false;
295
+ }
296
+ }
297
+
298
+ function getDeviceLabel(deviceType: DeviceType) {
299
+ return deviceType === DeviceType.Camera
300
+ ? t('Medical.Device.Camera')
301
+ : t('Medical.Device.Microphone');
302
+ }
303
+
304
+ const deviceInviteTitle = computed(() => {
305
+ if (!pendingDeviceInvitation.value) {
306
+ return '';
307
+ }
308
+ return t('Medical.Consultation.DeviceInviteTitle', {
309
+ sender: pendingDeviceInvitation.value.senderName,
310
+ device: getDeviceLabel(pendingDeviceInvitation.value.deviceType),
311
+ });
312
+ });
313
+
314
+ const onDeviceInvitationReceived = (options: {
315
+ invitation: {
316
+ senderUserId: string;
317
+ senderUserName: string;
318
+ deviceType: DeviceType;
319
+ };
320
+ }) => {
321
+ const { invitation } = options;
322
+ if (invitation.senderUserId !== doctor.value?.userId) {
323
+ return;
324
+ }
325
+ pendingDeviceInvitation.value = {
326
+ senderUserId: invitation.senderUserId,
327
+ senderName:
328
+ invitation.senderUserName || doctor.value?.userName || t('Medical.Common.Doctor'),
329
+ deviceType: invitation.deviceType,
330
+ };
331
+ deviceInviteHint.value = '';
332
+ deviceInviteVisible.value = true;
333
+ };
334
+
335
+ function isCurrentPendingInvitation(options: {
336
+ invitation: { senderUserId: string; deviceType: DeviceType };
337
+ }) {
338
+ if (!pendingDeviceInvitation.value) {
339
+ return false;
340
+ }
341
+ return (
342
+ options.invitation.senderUserId ===
343
+ pendingDeviceInvitation.value.senderUserId &&
344
+ options.invitation.deviceType === pendingDeviceInvitation.value.deviceType
345
+ );
346
+ }
347
+
348
+ const onDeviceInvitationCancelled = (options: {
349
+ invitation: { senderUserId: string; deviceType: DeviceType };
350
+ }) => {
351
+ if (!isCurrentPendingInvitation(options)) {
352
+ return;
353
+ }
354
+ deviceInviteHint.value = t('Medical.Consultation.InviteCancelled');
355
+ deviceInviteVisible.value = false;
356
+ pendingDeviceInvitation.value = null;
357
+ };
358
+
359
+ const onDeviceInvitationTimeout = (options: {
360
+ invitation: { senderUserId: string; deviceType: DeviceType };
361
+ }) => {
362
+ if (!isCurrentPendingInvitation(options)) {
363
+ return;
364
+ }
365
+ deviceInviteHint.value = t('Medical.Consultation.InviteTimeout');
366
+ deviceInviteVisible.value = false;
367
+ pendingDeviceInvitation.value = null;
368
+ };
369
+
370
+ async function handleDeviceInvitationDecision(accept: boolean) {
371
+ if (!pendingDeviceInvitation.value || deviceInviteLoading.value) {
372
+ return;
373
+ }
374
+ const invitation = pendingDeviceInvitation.value;
375
+ deviceInviteLoading.value = true;
376
+ deviceInviteDecision.value = accept ? 'accept' : 'decline';
377
+ try {
378
+ if (accept) {
379
+ await participantState.acceptOpenDeviceInvitation({
380
+ userId: invitation.senderUserId,
381
+ device: invitation.deviceType,
382
+ });
383
+ if (invitation.deviceType === DeviceType.Camera) {
384
+ await deviceState.openLocalCamera();
385
+ }
386
+ if (invitation.deviceType === DeviceType.Microphone) {
387
+ await deviceState.openLocalMicrophone();
388
+ }
389
+ devicePermissionHint.value = '';
390
+ deviceInviteHint.value = t('Medical.Consultation.DeviceInviteAccepted', {
391
+ device: getDeviceLabel(invitation.deviceType),
392
+ });
393
+ } else {
394
+ await participantState.declineOpenDeviceInvitation({
395
+ userId: invitation.senderUserId,
396
+ device: invitation.deviceType,
397
+ });
398
+ deviceInviteHint.value = t('Medical.Consultation.DeviceInviteDeclined', {
399
+ device: getDeviceLabel(invitation.deviceType),
400
+ });
401
+ }
402
+ deviceInviteVisible.value = false;
403
+ pendingDeviceInvitation.value = null;
404
+ } catch (error) {
405
+ deviceInviteHint.value =
406
+ error instanceof Error
407
+ ? error.message
408
+ : t('Medical.Consultation.DeviceInviteProcessFailed');
409
+ } finally {
410
+ deviceInviteLoading.value = false;
411
+ deviceInviteDecision.value = '';
412
+ }
413
+ }
414
+
415
+ async function toggleMic() {
416
+ if (deviceAction.value || leavingConsultation.value || endingByDoctor.value) {
417
+ return;
418
+ }
419
+ deviceAction.value = 'microphone';
420
+ try {
421
+ if (deviceState.microphoneStatus.value === 1) {
422
+ await deviceState.closeLocalMicrophone();
423
+ } else {
424
+ await deviceState.openLocalMicrophone();
425
+ devicePermissionHint.value = '';
426
+ }
427
+ } catch (error) {
428
+ devicePermissionHint.value = getDeviceErrorHint(
429
+ t('Medical.Device.Microphone'),
430
+ error
431
+ );
432
+ } finally {
433
+ deviceAction.value = '';
434
+ }
435
+ }
436
+
437
+ async function toggleCamera() {
438
+ if (deviceAction.value || leavingConsultation.value || endingByDoctor.value) {
439
+ return;
440
+ }
441
+ deviceAction.value = 'camera';
442
+ try {
443
+ if (deviceState.cameraStatus.value === 1) {
444
+ await deviceState.closeLocalCamera();
445
+ } else {
446
+ await deviceState.openLocalCamera();
447
+ devicePermissionHint.value = '';
448
+ }
449
+ } catch (error) {
450
+ devicePermissionHint.value = getDeviceErrorHint(
451
+ t('Medical.Device.Camera'),
452
+ error
453
+ );
454
+ } finally {
455
+ deviceAction.value = '';
456
+ }
457
+ }
458
+
459
+ onMounted(async () => {
460
+ const currentUser = getSessionUser();
461
+ if (!currentUser || currentUser.role !== 'patient' || !appointment.value) {
462
+ router.replace('/login');
463
+ return;
464
+ }
465
+ consultationSessionStartedAt.value = Date.now();
466
+ await ensureJoined();
467
+ await ensureChatConversation();
468
+ await participantState
469
+ .getParticipantList({ cursor: '' })
470
+ .catch(() => undefined);
471
+ const deviceResults = await Promise.allSettled([
472
+ deviceState.openLocalMicrophone(),
473
+ deviceState.openLocalCamera(),
474
+ ]);
475
+ updateDevicePermissionHint(deviceResults);
476
+ roomState.subscribeEvent(RoomEvent.onRoomEnded, handleRoomEnded);
477
+ participantState.subscribeEvent(
478
+ RoomParticipantEvent.onDeviceInvitationReceived,
479
+ onDeviceInvitationReceived
480
+ );
481
+ participantState.subscribeEvent(
482
+ RoomParticipantEvent.onDeviceInvitationCancelled,
483
+ onDeviceInvitationCancelled
484
+ );
485
+ participantState.subscribeEvent(
486
+ RoomParticipantEvent.onDeviceInvitationTimeout,
487
+ onDeviceInvitationTimeout
488
+ );
489
+ timerInterval = window.setInterval(() => {
490
+ timer.value += 1;
491
+ }, 1000);
492
+ });
493
+
494
+ watch(
495
+ () => chatMessages.value.length,
496
+ async () => {
497
+ await nextTick();
498
+ scrollChatToBottom();
499
+ }
500
+ );
501
+
502
+ watch(
503
+ () => chatPanelVisible.value,
504
+ async visible => {
505
+ if (visible) {
506
+ await nextTick();
507
+ scrollChatToBottom();
508
+ }
509
+ }
510
+ );
511
+
512
+ watch(
513
+ () => transcriptionMessages.value.length,
514
+ async length => {
515
+ if (!length) {
516
+ return;
517
+ }
518
+ if (transcriptionPanelVisible.value) {
519
+ seenTranscriptionCount.value = length;
520
+ await nextTick();
521
+ scrollTranscriptionToBottom();
522
+ }
523
+ }
524
+ );
525
+
526
+ watch(
527
+ () => transcriptionPanelVisible.value,
528
+ async visible => {
529
+ if (visible) {
530
+ seenTranscriptionCount.value = transcriptionMessages.value.length;
531
+ await nextTick();
532
+ scrollTranscriptionToBottom();
533
+ }
534
+ }
535
+ );
536
+
537
+ onUnmounted(() => {
538
+ roomState.unsubscribeEvent(RoomEvent.onRoomEnded, handleRoomEnded);
539
+ participantState.unsubscribeEvent(
540
+ RoomParticipantEvent.onDeviceInvitationReceived,
541
+ onDeviceInvitationReceived
542
+ );
543
+ participantState.unsubscribeEvent(
544
+ RoomParticipantEvent.onDeviceInvitationCancelled,
545
+ onDeviceInvitationCancelled
546
+ );
547
+ participantState.unsubscribeEvent(
548
+ RoomParticipantEvent.onDeviceInvitationTimeout,
549
+ onDeviceInvitationTimeout
550
+ );
551
+ if (timerInterval) {
552
+ clearInterval(timerInterval);
553
+ }
554
+ void clearConsultationSession();
555
+ });
556
+
557
+ onBeforeRouteLeave(to => {
558
+ if (endingByDoctor.value || leavingConsultation.value || !appointment.value) {
559
+ return true;
560
+ }
561
+
562
+ void leaveConsultationTo(to.fullPath);
563
+ return false;
564
+ });
565
+ </script>
566
+
567
+ <template>
568
+ <div
569
+ v-if="appointment && doctor && patient"
570
+ class="h-screen w-screen overflow-hidden bg-gradient-to-br from-[#EEF6FB] via-[#E6F5F7] to-[#EEF2FF]"
571
+ >
572
+ <div
573
+ class="w-full h-full overflow-hidden bg-gradient-to-br from-[#163768] via-[#142E58] to-[#122747] relative"
574
+ >
575
+ <ConsultationVideoStage
576
+ :main-member="mainVideoMember"
577
+ :thumbnail-members="thumbnailVideoMembers"
578
+ thumbnail-list-class="top-[86px] right-4 max-h-[calc(100%-230px)] w-32"
579
+ thumbnail-card-class="h-36"
580
+ @focus="focusParticipant"
581
+ />
582
+
583
+ <div
584
+ class="absolute top-0 left-0 right-0 px-5 pt-4 pb-3 bg-gradient-to-b from-black/40 to-transparent z-20"
585
+ >
586
+ <div class="flex items-center justify-between">
587
+ <div class="flex items-center gap-3 min-w-0">
588
+ <div
589
+ class="w-12 h-12 rounded-full bg-gradient-to-br from-[#0D9488] to-[#0F766E] flex items-center justify-center text-white font-semibold shrink-0"
590
+ >
591
+ {{ doctor.userName.charAt(0) }}
592
+ </div>
593
+ <div class="min-w-0">
594
+ <p
595
+ class="text-white font-semibold text-lg leading-tight truncate"
596
+ >
597
+ {{ doctor.userName }}
598
+ </p>
599
+ <p class="text-white/75 text-xs leading-tight mt-0.5 truncate">
600
+ {{ doctor.department }} · {{ doctor.title }}
601
+ </p>
602
+ </div>
603
+ </div>
604
+
605
+ <div
606
+ class="flex items-center gap-2 shrink-0"
607
+ >
608
+ <div
609
+ class="bg-black/40 backdrop-blur-md rounded-full px-4 py-2 flex items-center gap-2"
610
+ >
611
+ <span
612
+ class="inline-block h-2.5 w-2.5 rounded-full bg-[#00D08A] animate-pulse"
613
+ ></span>
614
+ <span class="text-white text-base font-mono leading-none">{{
615
+ formattedTimer
616
+ }}</span>
617
+ </div>
618
+ </div>
619
+ </div>
620
+
621
+ <div
622
+ class="mt-3 inline-flex items-center gap-2 bg-black/30 rounded-full px-3 py-1.5 backdrop-blur-md"
623
+ >
624
+ <span class="flex items-end gap-0.5">
625
+ <i class="inline-block w-1.5 h-3 rounded-full bg-[#00D08A]"></i>
626
+ <i class="inline-block w-1.5 h-4 rounded-full bg-[#00D08A]"></i>
627
+ <i class="inline-block w-1.5 h-5 rounded-full bg-[#00D08A]"></i>
628
+ </span>
629
+ <span class="text-white text-xs leading-none">
630
+ {{ t('Medical.Consultation.NetworkGood') }}
631
+ </span>
632
+ </div>
633
+
634
+ <div
635
+ v-if="devicePermissionHint"
636
+ class="mt-3"
637
+ >
638
+ <MedicalAlert variant="warning">
639
+ {{ devicePermissionHint }}
640
+ </MedicalAlert>
641
+ </div>
642
+ </div>
643
+
644
+ <div
645
+ class="absolute bottom-32 left-1/2 -translate-x-1/2 flex items-center justify-center gap-4 z-20"
646
+ >
647
+ <button
648
+ @click="toggleMic"
649
+ :disabled="!!deviceAction || leavingConsultation || endingByDoctor"
650
+ :class="[
651
+ 'w-16 h-16 rounded-full transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
652
+ deviceState.microphoneStatus.value === 1
653
+ ? 'bg-[#334155]/90 hover:bg-[#475569]'
654
+ : 'bg-red-500 hover:bg-red-600',
655
+ ]"
656
+ >
657
+ <LoadingSpinner
658
+ v-if="deviceAction === 'microphone'"
659
+ class="mx-auto text-white"
660
+ />
661
+ <component
662
+ v-else
663
+ :is="deviceState.microphoneStatus.value === 1 ? Mic : MicOff"
664
+ :size="26"
665
+ class="mx-auto text-white"
666
+ />
667
+ </button>
668
+ <button
669
+ @click="leaveConsultation"
670
+ :disabled="leavingConsultation || endingByDoctor"
671
+ class="w-20 h-20 rounded-full bg-red-500 hover:bg-red-600 transition-colors shadow-[0_10px_24px_rgba(239,68,68,0.35)] disabled:opacity-60 disabled:cursor-not-allowed"
672
+ >
673
+ <LoadingSpinner
674
+ v-if="leavingConsultation || endingByDoctor"
675
+ class="mx-auto text-white"
676
+ />
677
+ <PhoneOff v-else :size="34" class="mx-auto text-white" />
678
+ </button>
679
+ <button
680
+ @click="toggleCamera"
681
+ :disabled="!!deviceAction || leavingConsultation || endingByDoctor"
682
+ :class="[
683
+ 'w-16 h-16 rounded-full transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
684
+ deviceState.cameraStatus.value === 1
685
+ ? 'bg-[#334155]/90 hover:bg-[#475569]'
686
+ : 'bg-red-500 hover:bg-red-600',
687
+ ]"
688
+ >
689
+ <LoadingSpinner
690
+ v-if="deviceAction === 'camera'"
691
+ class="mx-auto text-white"
692
+ />
693
+ <component
694
+ v-else
695
+ :is="deviceState.cameraStatus.value === 1 ? Camera : CameraOff"
696
+ :size="26"
697
+ class="mx-auto text-white"
698
+ />
699
+ </button>
700
+ </div>
701
+
702
+ <div
703
+ class="absolute bottom-16 left-1/2 -translate-x-1/2 flex items-center justify-center gap-3 z-20"
704
+ >
705
+ <button
706
+ @click="speakerEnabled = !speakerEnabled"
707
+ class="px-4 py-2 rounded-full bg-[#111827]/70 text-white text-sm flex items-center gap-2"
708
+ >
709
+ <component :is="speakerEnabled ? Volume2 : VolumeX" :size="16" />
710
+ {{ t('Medical.Consultation.Speaker') }}
711
+ </button>
712
+ <button
713
+ @click="toggleChatPanel"
714
+ class="px-4 py-2 rounded-full bg-[#111827]/70 text-white text-sm flex items-center gap-2"
715
+ >
716
+ <MessageCircle :size="16" />
717
+ {{ t('Medical.Consultation.Messages') }}
718
+ </button>
719
+ </div>
720
+
721
+ <button
722
+ @click="toggleTranscriptionPanel"
723
+ class="absolute bottom-32 right-4 w-14 h-14 rounded-full bg-gradient-to-br from-[#0D9488] to-[#0F766E] flex items-center justify-center shadow-2xl shadow-teal-500/30 z-20"
724
+ >
725
+ <FileText class="w-6 h-6 text-white" />
726
+ <div
727
+ v-if="transcriptionUnreadCount > 0"
728
+ class="absolute -top-1 -right-1 min-w-5 h-5 px-1 bg-red-500 rounded-full flex items-center justify-center border-2 border-black"
729
+ >
730
+ <span class="text-white text-[11px] leading-none font-semibold">{{
731
+ transcriptionUnreadText
732
+ }}</span>
733
+ </div>
734
+ </button>
735
+
736
+ <div
737
+ class="absolute bottom-4 left-1/2 -translate-x-1/2 w-[92%] rounded-full bg-[#0F172A]/55 text-center py-2 px-4 text-white/85 text-xs z-20"
738
+ >
739
+ {{ t('Medical.Consultation.PatientBottomHint') }}
740
+ </div>
741
+
742
+ <div
743
+ v-if="transcriptionPanelVisible"
744
+ class="absolute inset-0 bg-black/45 z-30"
745
+ @click.self="closeTranscriptionPanel"
746
+ >
747
+ <div
748
+ class="absolute bottom-0 left-0 right-0 z-40 bg-white rounded-t-3xl shadow-2xl pb-safe max-h-[70vh] flex flex-col"
749
+ >
750
+ <div
751
+ class="flex items-center justify-center py-3 border-b border-gray-100"
752
+ >
753
+ <div class="w-12 h-1.5 bg-gray-300 rounded-full"></div>
754
+ </div>
755
+
756
+ <div
757
+ class="flex items-center justify-between px-4 py-3 border-b border-gray-100"
758
+ >
759
+ <div class="flex items-center gap-2">
760
+ <FileText class="w-5 h-5 text-[#0D9488]" />
761
+ <h3 class="font-semibold text-gray-900">
762
+ {{ t('Medical.Consultation.Transcription') }}
763
+ </h3>
764
+ </div>
765
+ <button
766
+ @click="closeTranscriptionPanel"
767
+ class="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center hover:bg-gray-200 transition-colors"
768
+ >
769
+ <ChevronDown class="w-5 h-5 text-gray-600" />
770
+ </button>
771
+ </div>
772
+
773
+ <div
774
+ ref="transcriptionContainerRef"
775
+ class="flex-1 overflow-y-auto px-4 py-3 space-y-3 min-h-0"
776
+ >
777
+ <div
778
+ v-for="item in transcriptionMessages"
779
+ :key="item.segmentId"
780
+ :class="[
781
+ 'flex gap-3',
782
+ isPatientSpeaker(item.speakerUserId) ? 'flex-row-reverse' : '',
783
+ ]"
784
+ >
785
+ <div class="shrink-0">
786
+ <div
787
+ :class="[
788
+ 'w-8 h-8 rounded-full text-white text-xs font-semibold flex items-center justify-center',
789
+ isPatientSpeaker(item.speakerUserId)
790
+ ? 'bg-gradient-to-br from-blue-500 to-blue-600'
791
+ : 'bg-gradient-to-br from-[#0D9488] to-[#0F766E]',
792
+ ]"
793
+ >
794
+ {{
795
+ isPatientSpeaker(item.speakerUserId)
796
+ ? patient.userName.charAt(0)
797
+ : doctor.userName.charAt(0)
798
+ }}
799
+ </div>
800
+ </div>
801
+
802
+ <div
803
+ :class="[
804
+ 'flex-1 flex flex-col',
805
+ isPatientSpeaker(item.speakerUserId)
806
+ ? 'items-end'
807
+ : 'items-start',
808
+ ]"
809
+ >
810
+ <div class="flex items-center gap-2 mb-1">
811
+ <span class="text-xs text-gray-500 font-medium">
812
+ {{ getTranscriptionSpeakerName(item.speakerUserId) }}
813
+ </span>
814
+ <span class="text-xs text-gray-400">
815
+ {{ formatTranscriptionTime(item.timestamp) }}
816
+ </span>
817
+ </div>
818
+ <div
819
+ :class="[
820
+ 'px-3 py-2 rounded-2xl max-w-[85%]',
821
+ isPatientSpeaker(item.speakerUserId)
822
+ ? 'bg-gradient-to-br from-[#0D9488] to-[#0F766E] text-white'
823
+ : 'bg-gray-100 text-gray-900',
824
+ ]"
825
+ >
826
+ <p class="text-sm leading-relaxed">{{ item.sourceText }}</p>
827
+ </div>
828
+ </div>
829
+ </div>
830
+
831
+ <div
832
+ v-if="transcriptionMessages.length === 0"
833
+ class="text-center text-sm text-gray-400 py-10"
834
+ >
835
+ {{ t('Medical.Consultation.NoTranscription') }}
836
+ </div>
837
+ </div>
838
+
839
+ <div class="px-4 py-3 bg-blue-50 border-t border-blue-100">
840
+ <p
841
+ class="text-xs text-blue-800 text-center flex items-center justify-center gap-1"
842
+ >
843
+ <Clock class="w-3 h-3" />
844
+ {{ t('Medical.Consultation.AITranscribingTip') }}
845
+ </p>
846
+ </div>
847
+ </div>
848
+ </div>
849
+
850
+ <div
851
+ v-if="chatPanelVisible"
852
+ class="absolute inset-0 bg-black/45 z-30"
853
+ @click.self="chatPanelVisible = false"
854
+ >
855
+ <div
856
+ class="absolute left-0 right-0 bottom-0 h-[58%] bg-white rounded-t-[28px] overflow-hidden flex flex-col"
857
+ >
858
+ <div
859
+ class="h-14 px-4 border-b border-gray-100 flex items-center justify-between"
860
+ >
861
+ <h3 class="text-base font-semibold text-gray-900">
862
+ {{ t('Medical.Consultation.TextChat') }}
863
+ </h3>
864
+ <button
865
+ @click="chatPanelVisible = false"
866
+ class="w-8 h-8 rounded-full hover:bg-gray-100 text-gray-500 flex items-center justify-center"
867
+ >
868
+ <X :size="18" />
869
+ </button>
870
+ </div>
871
+
872
+ <div
873
+ ref="chatContainerRef"
874
+ class="flex-1 min-h-0 overflow-y-auto p-4 space-y-3"
875
+ >
876
+ <div
877
+ v-for="item in chatMessages"
878
+ :key="item.ID"
879
+ :class="[
880
+ 'flex gap-2',
881
+ item.flow === 'out' ? 'justify-end' : 'justify-start',
882
+ ]"
883
+ >
884
+ <template v-if="item.flow !== 'out'">
885
+ <div
886
+ class="w-7 h-7 rounded-full bg-[#0D9488] text-white text-xs font-semibold flex items-center justify-center shrink-0"
887
+ >
888
+ {{ doctor.userName.charAt(0) }}
889
+ </div>
890
+ <div class="max-w-[72%]">
891
+ <div
892
+ class="bg-gray-100 text-gray-900 rounded-2xl px-3 py-2 text-sm"
893
+ >
894
+ {{ getConsultationMessageText(item) }}
895
+ </div>
896
+ <p class="text-[11px] text-gray-400 mt-1">
897
+ {{ formatMessageTime(item.time) }}
898
+ </p>
899
+ </div>
900
+ </template>
901
+ <template v-else>
902
+ <div class="max-w-[72%]">
903
+ <div
904
+ class="bg-[#0D9488] text-white rounded-2xl px-3 py-2 text-sm"
905
+ >
906
+ {{ getConsultationMessageText(item) }}
907
+ </div>
908
+ <p class="text-[11px] text-gray-400 mt-1 text-right">
909
+ {{ formatMessageTime(item.time) }}
910
+ </p>
911
+ </div>
912
+ <div
913
+ class="w-7 h-7 rounded-full bg-[#3B82F6] text-white text-xs font-semibold flex items-center justify-center shrink-0"
914
+ >
915
+ {{ patient.userName.charAt(0) }}
916
+ </div>
917
+ </template>
918
+ </div>
919
+ <div
920
+ v-if="chatMessages.length === 0"
921
+ class="text-center text-sm text-gray-400 py-10"
922
+ >
923
+ {{
924
+ activeConversation?.conversationID
925
+ ? t('Medical.Chat.Empty')
926
+ : t('Medical.Chat.Initializing')
927
+ }}
928
+ </div>
929
+ </div>
930
+
931
+ <div class="border-t border-gray-100 p-3">
932
+ <div class="flex items-center gap-2">
933
+ <input
934
+ v-model="chatInput"
935
+ @keyup.enter="handleSendMessage"
936
+ class="flex-1 h-10 rounded-xl border border-gray-200 px-3 text-sm outline-none focus:border-[#0D9488]"
937
+ :placeholder="t('Medical.Chat.InputPlaceholder')"
938
+ />
939
+ <button
940
+ @click="handleSendMessage"
941
+ :disabled="isSendingMessage"
942
+ class="w-10 h-10 rounded-xl bg-[#0D9488] text-white flex items-center justify-center disabled:opacity-60 disabled:cursor-not-allowed"
943
+ >
944
+ <LoadingSpinner v-if="isSendingMessage" />
945
+ <Send v-else :size="16" />
946
+ </button>
947
+ </div>
948
+ <p class="mt-2 text-xs text-red-500 min-h-4">{{ chatError }}</p>
949
+ </div>
950
+ </div>
951
+ </div>
952
+
953
+ <div
954
+ v-if="deviceInviteVisible"
955
+ class="absolute inset-0 bg-black/45 z-40 flex items-center justify-center p-6"
956
+ >
957
+ <div
958
+ class="w-full max-w-[360px] rounded-3xl bg-white shadow-[0_20px_50px_rgba(15,23,42,0.25)] p-5"
959
+ >
960
+ <h3 class="text-[17px] font-semibold text-[#0F172A] leading-7">
961
+ {{ t('Medical.Consultation.DeviceRequest') }}
962
+ </h3>
963
+ <p class="mt-2 text-sm text-[#475569] leading-6">
964
+ {{ deviceInviteTitle }}
965
+ </p>
966
+ <div class="mt-5 grid grid-cols-2 gap-3">
967
+ <button
968
+ @click="handleDeviceInvitationDecision(false)"
969
+ :disabled="deviceInviteLoading"
970
+ class="h-10 rounded-xl border border-[#E2E8F0] text-[#475569] font-medium disabled:opacity-60 disabled:cursor-not-allowed inline-flex items-center justify-center gap-2"
971
+ >
972
+ <LoadingSpinner v-if="deviceInviteDecision === 'decline'" />
973
+ {{
974
+ deviceInviteDecision === 'decline'
975
+ ? t('Medical.Common.Processing')
976
+ : t('Medical.Common.Decline')
977
+ }}
978
+ </button>
979
+ <button
980
+ @click="handleDeviceInvitationDecision(true)"
981
+ :disabled="deviceInviteLoading"
982
+ class="h-10 rounded-xl bg-[#0D9488] text-white font-medium disabled:opacity-60 disabled:cursor-not-allowed inline-flex items-center justify-center gap-2"
983
+ >
984
+ <LoadingSpinner v-if="deviceInviteDecision === 'accept'" />
985
+ {{
986
+ deviceInviteDecision === 'accept'
987
+ ? t('Medical.Common.Processing')
988
+ : t('Medical.Common.Accept')
989
+ }}
990
+ </button>
991
+ </div>
992
+ </div>
993
+ </div>
994
+
995
+ <div
996
+ v-if="deviceInviteHint"
997
+ class="absolute top-4 left-1/2 -translate-x-1/2 z-50 rounded-full bg-black/55 px-4 py-2 text-white text-xs"
998
+ >
999
+ {{ deviceInviteHint }}
1000
+ </div>
1001
+ </div>
1002
+ </div>
1003
+ </template>