OrderChat.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. <template>
  2. <div class="order-chat flex flex-col" :class="compact ? 'h-[320px]' : 'h-[420px]'">
  3. <!-- Header -->
  4. <div class="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-card/60 backdrop-blur-sm rounded-t-2xl">
  5. <div class="flex items-center gap-2">
  6. <MessageCircle class="w-4 h-4 text-primary" />
  7. <span class="text-sm font-bold">{{ t("chat.title") }}</span>
  8. <span v-if="messages.length" class="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-bold">{{ messages.length }}</span>
  9. <span class="w-2 h-2 rounded-full" :class="wsConnected ? 'bg-emerald-500' : 'bg-rose-500'" :title="wsConnected ? 'Connected' : 'Disconnected'" />
  10. <span v-if="otherPartyOnline" class="text-[10px] text-emerald-500 font-bold ml-2 flex items-center gap-1 animate-in fade-in zoom-in duration-300">
  11. <Eye class="w-3 h-3" /> Online
  12. </span>
  13. </div>
  14. <button v-if="closable" @click="$emit('close')" class="p-1 hover:bg-secondary rounded-lg transition-colors">
  15. <X class="w-4 h-4 text-muted-foreground" />
  16. </button>
  17. </div>
  18. <!-- Messages area -->
  19. <div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-3 space-y-3 scrollbar-thin">
  20. <div v-if="isLoading" class="flex items-center justify-center h-full">
  21. <Loader2 class="w-6 h-6 animate-spin text-primary" />
  22. </div>
  23. <div v-else-if="messages.length === 0" class="flex flex-col items-center justify-center h-full text-center opacity-60">
  24. <MessageCircle class="w-10 h-10 text-muted-foreground mb-3 opacity-40" />
  25. <p class="text-sm text-muted-foreground">{{ t("chat.empty") }}</p>
  26. </div>
  27. <template v-else>
  28. <div
  29. v-for="msg in messages"
  30. :key="msg.id"
  31. class="flex"
  32. :class="msg.is_from_admin ? 'justify-start' : 'justify-end'"
  33. >
  34. <div
  35. class="max-w-[80%] px-3.5 py-2 rounded-2xl text-sm leading-relaxed shadow-sm"
  36. :class="msg.is_from_admin
  37. ? 'bg-secondary/80 text-foreground rounded-bl-md'
  38. : 'bg-primary text-primary-foreground rounded-br-md'"
  39. >
  40. <div v-if="msg.is_from_admin" class="flex items-center gap-1.5 mb-1">
  41. <ShieldCheck class="w-3 h-3 opacity-70" />
  42. <span class="text-[10px] font-bold uppercase tracking-wider opacity-70">{{ t("chat.admin") }}</span>
  43. </div>
  44. <p class="whitespace-pre-wrap break-words">{{ msg.message }}</p>
  45. <p class="text-[10px] mt-1 opacity-50 text-right">{{ formatTime(msg.created_at) }}</p>
  46. </div>
  47. </div>
  48. </template>
  49. <!-- Typing Indicator -->
  50. <div v-if="isOtherPartyTyping" class="flex justify-start mb-2 animate-in fade-in zoom-in duration-300">
  51. <div class="bg-secondary/60 text-muted-foreground px-4 py-3 rounded-2xl rounded-bl-md flex items-center gap-1.5 shadow-sm">
  52. <span class="w-1.5 h-1.5 bg-foreground/40 rounded-full animate-bounce" style="animation-delay: 0ms"></span>
  53. <span class="w-1.5 h-1.5 bg-foreground/40 rounded-full animate-bounce" style="animation-delay: 150ms"></span>
  54. <span class="w-1.5 h-1.5 bg-foreground/40 rounded-full animate-bounce" style="animation-delay: 300ms"></span>
  55. </div>
  56. </div>
  57. </div>
  58. <!-- Input area -->
  59. <div class="px-3 py-3 border-t border-border/50 bg-card/40 rounded-b-2xl">
  60. <form @submit.prevent="handleSend" class="flex gap-2 items-end">
  61. <textarea
  62. v-model="newMessage"
  63. :placeholder="t('chat.placeholder')"
  64. rows="1"
  65. class="flex-1 resize-none bg-background/80 border border-border/50 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40 transition-all placeholder:text-muted-foreground/50 max-h-[80px] overflow-y-auto"
  66. @keydown.enter.exact.prevent="handleSend"
  67. @input="onTextareaInput($event); autoResize($event)"
  68. ref="textareaRef"
  69. />
  70. <button
  71. type="submit"
  72. :disabled="!newMessage.trim() || isSending"
  73. class="p-2.5 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 transition-all disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0"
  74. >
  75. <Send v-if="!isSending" class="w-4 h-4" />
  76. <Loader2 v-else class="w-4 h-4 animate-spin" />
  77. </button>
  78. </form>
  79. </div>
  80. </div>
  81. </template>
  82. <script setup lang="ts">
  83. import { ref, onMounted, onUnmounted, nextTick, watch } from "vue";
  84. import { useI18n } from "vue-i18n";
  85. import { MessageCircle, Send, Loader2, ShieldCheck, X, Eye } from "lucide-vue-next";
  86. import { getOrderMessages, sendOrderMessage } from "@/lib/api";
  87. import { useAuthStore } from "@/stores/auth";
  88. const WS_BASE_URL = "ws://localhost:8000";
  89. const props = defineProps<{
  90. orderId: number;
  91. compact?: boolean;
  92. closable?: boolean;
  93. }>();
  94. defineEmits<{ (e: 'close'): void }>();
  95. const { t } = useI18n();
  96. const messages = ref<any[]>([]);
  97. const newMessage = ref("");
  98. const isLoading = ref(true);
  99. const isSending = ref(false);
  100. const wsConnected = ref(false);
  101. const messagesContainer = ref<HTMLElement | null>(null);
  102. const textareaRef = ref<HTMLTextAreaElement | null>(null);
  103. const otherPartyOnline = ref(false);
  104. const isOtherPartyTyping = ref(false);
  105. let typingTimeout: ReturnType<typeof setTimeout> | null = null;
  106. const authStore = useAuthStore();
  107. function playDing() {
  108. try {
  109. const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
  110. if (!AudioContext) return;
  111. const ctx = new AudioContext();
  112. const osc = ctx.createOscillator();
  113. const gainNode = ctx.createGain();
  114. osc.type = "sine";
  115. osc.frequency.setValueAtTime(880, ctx.currentTime);
  116. osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.1);
  117. gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
  118. gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
  119. osc.connect(gainNode);
  120. gainNode.connect(ctx.destination);
  121. osc.start();
  122. osc.stop(ctx.currentTime + 0.3);
  123. } catch(e) {}
  124. }
  125. let ws: WebSocket | null = null;
  126. let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  127. function connectWebSocket() {
  128. const token = localStorage.getItem("token");
  129. if (!token) return;
  130. const url = `${WS_BASE_URL}/ws/chat/${props.orderId}?token=${encodeURIComponent(token)}`;
  131. ws = new WebSocket(url);
  132. ws.onopen = () => {
  133. wsConnected.value = true;
  134. };
  135. ws.onmessage = (event) => {
  136. try {
  137. const msg = JSON.parse(event.data);
  138. if (msg.type === "presence") {
  139. const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
  140. const otherRole = myRole === 'admin' ? 'user' : 'admin';
  141. otherPartyOnline.value = msg.online_roles.includes(otherRole);
  142. return;
  143. }
  144. if (msg.type === "typing" || msg.type === "stop_typing") {
  145. const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
  146. const isFromOther = (msg.is_admin && myRole === 'user') || (!msg.is_admin && myRole === 'admin');
  147. if (isFromOther) {
  148. isOtherPartyTyping.value = (msg.type === "typing");
  149. scrollToBottom();
  150. }
  151. return;
  152. }
  153. // Avoid duplicates (we already optimistically added our own message)
  154. if (!messages.value.find(m => m.id === msg.id)) {
  155. messages.value.push(msg);
  156. scrollToBottom();
  157. // Check if we should play a sound
  158. const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
  159. const isMsgFromMe = (msg.is_from_admin && myRole === 'admin') || (!msg.is_from_admin && myRole === 'user');
  160. if (!isMsgFromMe) {
  161. playDing();
  162. // We indicate we have read it right away
  163. ws?.send("read");
  164. setTimeout(() => authStore.refreshUnreadCount(), 300);
  165. }
  166. }
  167. } catch (e) {
  168. console.error("WS parse error:", e);
  169. }
  170. };
  171. ws.onclose = () => {
  172. wsConnected.value = false;
  173. // Auto-reconnect after 3 seconds
  174. reconnectTimer = setTimeout(() => connectWebSocket(), 3000);
  175. };
  176. ws.onerror = () => {
  177. ws?.close();
  178. };
  179. }
  180. function disconnectWebSocket() {
  181. if (reconnectTimer) {
  182. clearTimeout(reconnectTimer);
  183. reconnectTimer = null;
  184. }
  185. if (ws) {
  186. ws.onclose = null; // prevent auto-reconnect
  187. ws.close();
  188. ws = null;
  189. }
  190. wsConnected.value = false;
  191. }
  192. async function loadMessages() {
  193. try {
  194. messages.value = await getOrderMessages(props.orderId);
  195. authStore.refreshUnreadCount();
  196. } catch (e) {
  197. console.error("Failed to load messages:", e);
  198. } finally {
  199. isLoading.value = false;
  200. }
  201. }
  202. async function handleSend() {
  203. const text = newMessage.value.trim();
  204. if (!text || isSending.value) return;
  205. isSending.value = true;
  206. try {
  207. await sendOrderMessage(props.orderId, text);
  208. newMessage.value = "";
  209. if (textareaRef.value) {
  210. textareaRef.value.style.height = "auto";
  211. }
  212. // The server will broadcast back via WS — but also reload as fallback
  213. if (!wsConnected.value) {
  214. await loadMessages();
  215. }
  216. scrollToBottom();
  217. } catch (e) {
  218. console.error("Failed to send message:", e);
  219. } finally {
  220. isSending.value = false;
  221. }
  222. }
  223. function scrollToBottom() {
  224. nextTick(() => {
  225. if (messagesContainer.value) {
  226. messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
  227. }
  228. });
  229. }
  230. function autoResize(e: Event) {
  231. const el = e.target as HTMLTextAreaElement;
  232. el.style.height = "auto";
  233. el.style.height = Math.min(el.scrollHeight, 80) + "px";
  234. }
  235. function onTextareaInput(e: Event) {
  236. if (wsConnected.value && ws) {
  237. ws.send("typing");
  238. if (typingTimeout) clearTimeout(typingTimeout);
  239. typingTimeout = setTimeout(() => {
  240. if (ws && ws.readyState === WebSocket.OPEN) {
  241. ws.send("stop_typing");
  242. }
  243. }, 2000);
  244. }
  245. }
  246. function formatTime(dt: string) {
  247. if (!dt) return "";
  248. const d = new Date(dt);
  249. return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
  250. }
  251. onMounted(async () => {
  252. await loadMessages();
  253. scrollToBottom();
  254. connectWebSocket();
  255. });
  256. onUnmounted(() => {
  257. disconnectWebSocket();
  258. });
  259. watch(() => props.orderId, async () => {
  260. disconnectWebSocket();
  261. isLoading.value = true;
  262. await loadMessages();
  263. scrollToBottom();
  264. connectWebSocket();
  265. });
  266. </script>
  267. <style scoped>
  268. .scrollbar-thin::-webkit-scrollbar { width: 4px; }
  269. .scrollbar-thin::-webkit-scrollbar-thumb { background: hsl(var(--border)); border-radius: 9999px; }
  270. .scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
  271. </style>