فهرست منبع

feat: Add image pasting support to chat and migrate messages to JSON

unknown 2 ماه پیش
والد
کامیت
3bc0cef1f7
6فایلهای تغییر یافته به همراه162 افزوده شده و 27 حذف شده
  1. 2 1
      backend/config.py
  2. 4 0
      backend/migrations/004_chat_json.sql
  3. 48 14
      backend/routers/chat.py
  4. 3 2
      backend/services/chat_manager.py
  5. 98 7
      src/components/OrderChat.vue
  6. 7 3
      src/lib/api.ts

+ 2 - 1
backend/config.py

@@ -28,7 +28,8 @@ SLICER_CONFIG = os.getenv("SLICER_CONFIG", os.path.join(BASE_DIR, "printer_profi
 # Order settings
 UPLOAD_DIR = os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads"))
 PREVIEW_DIR = os.path.join(UPLOAD_DIR, "previews")
-for d in [UPLOAD_DIR, PREVIEW_DIR]:
+CHAT_UPLOADS_DIR = os.path.join(UPLOAD_DIR, "chats")
+for d in [UPLOAD_DIR, PREVIEW_DIR, CHAT_UPLOADS_DIR]:
     if not os.path.exists(d):
         os.makedirs(d)
 

+ 4 - 0
backend/migrations/004_chat_json.sql

@@ -0,0 +1,4 @@
+-- Convert any existing plain-text messages to JSON objects {"text": "..."}
+UPDATE order_messages
+SET message = JSON_OBJECT('text', message)
+WHERE message NOT LIKE '{%';

+ 48 - 14
backend/routers/chat.py

@@ -1,4 +1,4 @@
-from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query, HTTPException
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query, HTTPException, File, UploadFile, Form
 from services.chat_manager import manager
 from services.global_manager import global_manager
 import db
@@ -7,6 +7,11 @@ import datetime
 import schemas
 import locales
 from dependencies import get_current_user
+import os
+import uuid
+import config
+import json
+from typing import Optional
 
 router = APIRouter(tags=["chat"])
 
@@ -43,7 +48,13 @@ async def get_order_messages(order_id: int, user: dict = Depends(get_current_use
     return messages
 
 @router.post("/orders/{order_id}/messages")
-async def post_order_message(order_id: int, data: schemas.MessageCreate, user: dict = Depends(get_current_user), lang: str = "en"):
+async def post_order_message(
+    order_id: int, 
+    message: Optional[str] = Form(None),
+    file: Optional[UploadFile] = File(None),
+    user: dict = Depends(get_current_user), 
+    lang: str = "en"
+):
     role = user.get("role")
     user_id = user.get("id")
     
@@ -55,8 +66,12 @@ async def post_order_message(order_id: int, data: schemas.MessageCreate, user: d
              raise HTTPException(status_code=429, detail=locales.translate_error("flood_control", lang))
         last_message_times[user_id] = now
 
-    message = data.message.strip()
-    if not message: raise HTTPException(status_code=400, detail="Empty message")
+    if not message and not file:
+        raise HTTPException(status_code=400, detail="Empty message")
+
+    if message:
+        message = message.strip()
+
     is_admin = (role == 'admin')
     
     if not is_admin:
@@ -67,20 +82,39 @@ async def post_order_message(order_id: int, data: schemas.MessageCreate, user: d
     order = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
     if not order: raise HTTPException(status_code=404, detail="Order not found")
     if not is_admin and order[0]['user_id'] != user_id: raise HTTPException(status_code=403, detail="Not authorized")
+
+    final_message = ""
+    db_file_path = None
+    
+    if file and file.filename:
+        file_ext = os.path.splitext(file.filename)[1]
+        unique_filename = f"{uuid.uuid4()}{file_ext}"
+        file_path = os.path.join(config.CHAT_UPLOADS_DIR, unique_filename)
+        db_file_path = f"uploads/chats/{unique_filename}"
+        
+        with open(file_path, "wb") as buffer:
+            while chunk := file.file.read(8192):
+                buffer.write(chunk)
+                
+    msg_obj = {}
+    if db_file_path:
+        msg_obj["image_url"] = db_file_path
+    if message:
+        msg_obj["text"] = message
+        
+    final_message = json.dumps(msg_obj)
+
     query = "INSERT INTO order_messages (order_id, user_id, is_from_admin, message) VALUES (%s, %s, %s, %s)"
-    msg_id = db.execute_commit(query, (order_id, user_id, is_admin, message))
+    msg_id = db.execute_commit(query, (order_id, user_id, is_admin, final_message))
     now = datetime.datetime.utcnow().isoformat()
-    await manager.broadcast_to_order(order_id, {"id": msg_id, "is_from_admin": is_admin, "message": message, "created_at": now})
+    await manager.broadcast_to_order(order_id, {"id": msg_id, "is_from_admin": is_admin, "message": final_message, "created_at": now})
+    
     if is_admin:
         await global_manager.notify_user(order[0]['user_id'])
     else:
         from services import event_hooks
         await global_manager.notify_admins()
-        await global_manager.notify_admins_new_message(order_id, message)
-        event_hooks.on_message_received(order_id, user_id, message)
-    return {"id": msg_id, "status": "sent"}
-
-# WebSocket moved to main.py
-# @router.websocket("/ws/chat/{order_id}")
-# async def ws_chat(websocket: WebSocket, order_id: int, token: str = Query(...)):
-#     ...
+        await global_manager.notify_admins_new_message(order_id, message or "Image uploaded")
+        event_hooks.on_message_received(order_id, user_id, message or "Image uploaded")
+        
+    return {"id": msg_id, "status": "sent", "message": final_message}

+ 3 - 2
backend/services/chat_manager.py

@@ -68,14 +68,15 @@ class ChatConnectionManager:
             return
 
         # Save to DB
+        json_text = json.dumps({"text": text})
         query = "INSERT INTO order_messages (order_id, user_id, is_from_admin, message) VALUES (%s, %s, %s, %s)"
-        msg_id = db.execute_commit(query, (order_id, user_id, is_admin, text))
+        msg_id = db.execute_commit(query, (order_id, user_id, is_admin, json_text))
         
         now = datetime.datetime.utcnow().isoformat()
         message_payload = {
             "id": msg_id,
             "is_from_admin": is_admin,
-            "message": text,
+            "message": json_text,
             "created_at": now,
             "type": "message"
         }

+ 98 - 7
src/components/OrderChat.vue

@@ -15,6 +15,16 @@
       </button>
     </div>
 
+    <!-- Image Modal -->
+    <div v-if="selectedImage" class="fixed inset-0 z-[100] flex items-center justify-center bg-background/90 backdrop-blur-md" @click="selectedImage = null">
+      <div class="relative max-w-[90vw] max-h-[90vh]">
+        <button @click.stop="selectedImage = null" class="absolute -top-12 right-0 p-2 text-foreground hover:text-primary transition-colors bg-card/50 rounded-full backdrop-blur-sm">
+          <X class="w-6 h-6" />
+        </button>
+        <img :src="getImageUrl(selectedImage)" class="max-w-full max-h-[85vh] rounded-xl shadow-2xl object-contain border border-border/50 animate-in zoom-in-95 duration-200" @click.stop />
+      </div>
+    </div>
+
     <!-- Messages area -->
     <div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-3 space-y-3 scrollbar-thin">
       <div v-if="isLoading" class="flex items-center justify-center h-full">
@@ -28,7 +38,7 @@
 
       <template v-else>
         <div
-          v-for="msg in messages"
+          v-for="msg in parsedMessages"
           :key="msg.id"
           class="flex"
           :class="msg.is_from_admin ? 'justify-start' : 'justify-end'"
@@ -43,7 +53,10 @@
               <ShieldCheck class="w-3 h-3 opacity-70" />
               <span class="text-[10px] font-bold uppercase tracking-wider opacity-70">{{ t("chat.admin") }}</span>
             </div>
-            <p class="whitespace-pre-wrap break-words">{{ msg.message }}</p>
+            <div v-if="msg.imageUrl" class="mt-1 mb-1">
+              <img :src="getImageUrl(msg.imageUrl)" @click="openImageModal(msg.imageUrl)" class="max-w-[150px] max-h-[150px] rounded-lg cursor-pointer hover:opacity-90 transition-opacity shadow-sm border border-border/20 object-cover" />
+            </div>
+            <p v-if="msg.parsedText" class="whitespace-pre-wrap break-words">{{ msg.parsedText }}</p>
             <p class="text-[10px] mt-1 opacity-50 text-right">{{ formatTime(msg.created_at) }}</p>
           </div>
         </div>
@@ -60,6 +73,7 @@
           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"
           @keydown.enter.exact.prevent="handleSend"
           @input="autoResize($event)"
+          @paste="handlePaste"
           ref="textareaRef"
         />
         <button
@@ -77,11 +91,12 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, onUnmounted, nextTick, watch } from "vue";
+import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
 import { useI18n } from "vue-i18n";
 import { MessageCircle, Send, Loader2, ShieldCheck, X, Eye } from "lucide-vue-next";
-import { getOrderMessages, sendOrderMessage } from "@/lib/api";
+import { getOrderMessages, sendOrderMessage, RESOURCES_BASE_URL } from "@/lib/api";
 import { useAuthStore } from "@/stores/auth";
+import { toast } from "vue-sonner";
 
 import { getWsUrl } from "@/lib/utils";
 
@@ -103,8 +118,27 @@ const wsConnected = ref(false);
 const messagesContainer = ref<HTMLElement | null>(null);
 const textareaRef = ref<HTMLTextAreaElement | null>(null);
 const otherPartyOnline = ref(false);
+const selectedImage = ref<string | null>(null);
 let cooldownInterval: ReturnType<typeof setInterval> | null = null;
 
+const parsedMessages = computed(() => {
+  return messages.value.map(msg => {
+    let text = msg.message;
+    let imageUrl = null;
+    try {
+      const parsed = JSON.parse(msg.message);
+      if (parsed && typeof parsed === 'object') {
+        if (parsed.image_url) imageUrl = parsed.image_url;
+        if (parsed.text !== undefined) text = parsed.text;
+        else if (parsed.image_url) text = ""; // If only image_url is present, don't render the json string as text
+      }
+    } catch {
+      // Normal string message (fallback)
+    }
+    return { ...msg, parsedText: text, imageUrl };
+  });
+});
+
 function startCooldown() {
   if (authStore.user?.role === 'admin') return;
   cooldownLeft.value = 10;
@@ -208,7 +242,7 @@ async function handleSend() {
 
   isSending.value = true;
   try {
-    // If WS is connected, send through it for "True Chat" experience
+    // If WS is connected and text only, send through it for "True Chat" experience
     if (ws && wsConnected.value && ws.readyState === WebSocket.OPEN) {
       ws.send(JSON.stringify({ type: "message", text }));
       newMessage.value = "";
@@ -217,7 +251,7 @@ async function handleSend() {
       }
     } else {
       // Fallback to API if WS is down
-      const response = await sendOrderMessage(props.orderId, text, locale.value);
+      const response = await sendOrderMessage(props.orderId, text, null, locale.value);
       newMessage.value = "";
       if (textareaRef.value) {
         textareaRef.value.style.height = "auto";
@@ -228,7 +262,7 @@ async function handleSend() {
         const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
         messages.value.push({
           id: response.id,
-          message: text,
+          message: response.message || text,
           is_from_admin: myRole === 'admin',
           created_at: new Date().toISOString()
         });
@@ -245,6 +279,61 @@ async function handleSend() {
   }
 }
 
+async function handlePaste(e: ClipboardEvent) {
+  const items = e.clipboardData?.items;
+  if (!items) return;
+  for (let i = 0; i < items.length; i++) {
+    if (items[i].type.indexOf("image") !== -1) {
+      const file = items[i].getAsFile();
+      if (file) {
+        e.preventDefault();
+        await uploadImage(file);
+      }
+    }
+  }
+}
+
+async function uploadImage(file: File) {
+  if (isSending.value) return;
+  isSending.value = true;
+  try {
+    const response = await sendOrderMessage(props.orderId, "", file, locale.value);
+    // Add message to list only if it hasn't arrived via WS yet
+    if (!messages.value.some(m => m.id == response.id)) {
+      const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
+      messages.value.push({
+        id: response.id,
+        message: response.message,
+        is_from_admin: myRole === 'admin',
+        created_at: new Date().toISOString()
+      });
+      scrollToBottom();
+    }
+    startCooldown();
+  } catch (e: any) {
+    console.error("Failed to upload image:", e);
+    toast.error(t("errors.upload_failed") || "Failed to upload image");
+  } finally {
+    isSending.value = false;
+  }
+}
+
+function getImageUrl(url: string) {
+  if (!url) return '';
+  if (url.startsWith('http')) return url;
+  return `${RESOURCES_BASE_URL}/${url}`;
+}
+
+function openImageModal(url: string) {
+  selectedImage.value = url;
+}
+
+function handleKeydown(e: KeyboardEvent) {
+  if (e.key === 'Escape' && selectedImage.value) {
+    selectedImage.value = null;
+  }
+}
+
 function scrollToBottom() {
   nextTick(() => {
     if (messagesContainer.value) {
@@ -269,10 +358,12 @@ onMounted(async () => {
   await loadMessages();
   scrollToBottom();
   connectWebSocket();
+  window.addEventListener('keydown', handleKeydown);
 });
 
 onUnmounted(() => {
   disconnectWebSocket();
+  window.removeEventListener('keydown', handleKeydown);
 });
 
 watch(() => props.orderId, async () => {

+ 7 - 3
src/lib/api.ts

@@ -563,15 +563,18 @@ export const getOrderMessages = async (orderId: number, lang: string = i18n.glob
   return response.json();
 };
 
-export const sendOrderMessage = async (orderId: number, message: string, lang: string = i18n.global.locale.value) => {
+export const sendOrderMessage = async (orderId: number, message: string, file?: File | null, lang: string = i18n.global.locale.value) => {
   const token = localStorage.getItem("token");
+  const formData = new FormData();
+  if (message) formData.append("message", message);
+  if (file) formData.append("file", file);
+
   const response = await fetch(`${API_BASE_URL}/orders/${orderId}/messages?lang=${lang}`, {
     method: 'POST',
     headers: {
-      'Content-Type': 'application/json',
       'Authorization': `Bearer ${token}`
     },
-    body: JSON.stringify({ message })
+    body: formData
   });
   if (response.status === 429) {
      const err = await response.json();
@@ -581,6 +584,7 @@ export const sendOrderMessage = async (orderId: number, message: string, lang: s
   return response.json();
 };
 
+
 export const authPing = async () => {
   const token = localStorage.getItem("token");
   if (!token) return null;