浏览代码

feat: Add message deletion with context menu

unknown 2 月之前
父节点
当前提交
cae70ca011
共有 3 个文件被更改,包括 116 次插入3 次删除
  1. 48 0
      backend/routers/chat.py
  2. 56 3
      src/components/OrderChat.vue
  3. 12 0
      src/lib/api.ts

+ 48 - 0
backend/routers/chat.py

@@ -142,3 +142,51 @@ async def post_order_message(
         "first_name": first_name,
         "email": email
     }
+
+@router.delete("/orders/{order_id}/messages/{msg_id}")
+async def delete_order_message(order_id: int, msg_id: int, user: dict = Depends(get_current_user)):
+    user_id = user.get("id")
+    role = user.get("role")
+    is_admin = (role == 'admin')
+
+    # Check order access
+    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")
+
+    # Fetch the message
+    msg = db.execute_query("SELECT * FROM order_messages WHERE id = %s AND order_id = %s", (msg_id, order_id))
+    if not msg:
+        raise HTTPException(status_code=404, detail="Message not found")
+
+    # Only admin or the sender can delete the message
+    if not is_admin and msg[0]['user_id'] != user_id:
+        raise HTTPException(status_code=403, detail="Not authorized to delete this message")
+
+    # Try to delete associated file if any
+    try:
+        import json
+        import os
+        import config
+        msg_data = json.loads(msg[0]['message'])
+        image_url = msg_data.get("image_url")
+        if image_url:
+            # image_url looks like "uploads/chats/filename"
+            file_path = os.path.join(config.BASE_DIR, image_url)
+            if os.path.exists(file_path):
+                os.remove(file_path)
+    except Exception as e:
+        print(f"Error deleting chat image: {e}")
+
+    # Delete message from database
+    db.execute_commit("DELETE FROM order_messages WHERE id = %s", (msg_id,))
+
+    # Broadcast deletion
+    await manager.broadcast_to_order(order_id, {
+        "id": msg_id,
+        "type": "message_deleted"
+    })
+
+    return {"message": "Deleted successfully"}

+ 56 - 3
src/components/OrderChat.vue

@@ -25,6 +25,17 @@
       </div>
     </div>
 
+    <!-- Context Menu -->
+    <div v-if="contextMenu.visible" 
+         class="fixed z-[200] bg-card/90 backdrop-blur-md border border-border/50 shadow-xl rounded-xl py-1 min-w-[120px] animate-in fade-in zoom-in-95 duration-100"
+         :style="{ top: `${contextMenu.y}px`, left: `${contextMenu.x}px` }"
+         @click.stop>
+      <button @click="deleteMessage(contextMenu.msgId)" 
+              class="w-full text-left px-4 py-2 text-sm text-rose-500 hover:bg-rose-500/10 font-bold transition-colors flex items-center gap-2">
+        <Trash2 class="w-4 h-4" /> {{ t("admin.actions.delete") }}
+      </button>
+    </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">
@@ -44,10 +55,11 @@
           :class="msg.is_from_admin ? 'justify-start' : 'justify-end'"
         >
           <div
-            class="max-w-[80%] px-3.5 py-2 rounded-2xl text-sm leading-relaxed shadow-sm"
+            class="max-w-[80%] px-3.5 py-2 rounded-2xl text-sm leading-relaxed shadow-sm transition-opacity hover:opacity-95"
             :class="msg.is_from_admin
               ? 'bg-secondary/80 text-foreground rounded-bl-md'
               : 'bg-primary text-primary-foreground rounded-br-md'"
+            @contextmenu.prevent="openContextMenu($event, msg)"
           >
             <div v-if="msg.is_from_admin" class="flex items-center gap-1.5 mb-1">
               <ShieldCheck class="w-3 h-3 opacity-70" />
@@ -100,8 +112,8 @@
 <script setup lang="ts">
 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, RESOURCES_BASE_URL } from "@/lib/api";
+import { MessageCircle, Send, Loader2, ShieldCheck, X, Eye, Trash2 } from "lucide-vue-next";
+import { getOrderMessages, sendOrderMessage, deleteOrderMessage, RESOURCES_BASE_URL } from "@/lib/api";
 import { useAuthStore } from "@/stores/auth";
 import { toast } from "vue-sonner";
 
@@ -127,6 +139,38 @@ const textareaRef = ref<HTMLTextAreaElement | null>(null);
 const otherPartyOnline = ref(false);
 const selectedImage = ref<string | null>(null);
 let cooldownInterval: ReturnType<typeof setInterval> | null = null;
+const contextMenu = ref({ visible: false, x: 0, y: 0, msgId: null as number | null });
+
+const openContextMenu = (e: MouseEvent, msg: any) => {
+  const myRole = authStore.user?.role;
+  const isMine = (myRole === 'admin' && msg.is_from_admin) || (myRole !== 'admin' && !msg.is_from_admin);
+  if (myRole !== 'admin' && !isMine) return; // Users can only delete their own messages, Admin can delete any
+  
+  // Keep menu on screen
+  let x = e.clientX;
+  let y = e.clientY;
+  if (window.innerWidth - x < 150) x -= 120;
+  if (window.innerHeight - y < 100) y -= 50;
+
+  contextMenu.value = { visible: true, x, y, msgId: msg.id };
+};
+
+const closeContextMenu = () => {
+  contextMenu.value.visible = false;
+};
+
+async function deleteMessage(msgId: number | null) {
+  if (!msgId) return;
+  try {
+    await deleteOrderMessage(props.orderId, msgId, locale.value);
+    messages.value = messages.value.filter(m => m.id !== msgId);
+    toast.success("Message deleted");
+  } catch (err: any) {
+    toast.error(err.message);
+  } finally {
+    closeContextMenu();
+  }
+}
 
 const parsedMessages = computed(() => {
   return messages.value.map(msg => {
@@ -185,6 +229,11 @@ function connectWebSocket() {
         return;
       }
       
+      if (msg.type === "message_deleted") {
+        messages.value = messages.value.filter(m => m.id != msg.id);
+        return;
+      }
+      
       // If it's a message (type 'message' or has essential fields)
       if (msg.type === "message" || (msg.message && msg.id)) {
         // Use lenient comparison (==) for ID to avoid string/int mismatches
@@ -366,11 +415,15 @@ onMounted(async () => {
   scrollToBottom();
   connectWebSocket();
   window.addEventListener('keydown', handleKeydown);
+  document.addEventListener('click', closeContextMenu);
+  document.addEventListener('scroll', closeContextMenu, true);
 });
 
 onUnmounted(() => {
   disconnectWebSocket();
   window.removeEventListener('keydown', handleKeydown);
+  document.removeEventListener('click', closeContextMenu);
+  document.removeEventListener('scroll', closeContextMenu, true);
 });
 
 watch(() => props.orderId, async () => {

+ 12 - 0
src/lib/api.ts

@@ -584,6 +584,18 @@ export const sendOrderMessage = async (orderId: number, message: string, file?:
   return response.json();
 };
 
+export const deleteOrderMessage = async (orderId: number, msgId: number, lang: string = i18n.global.locale.value) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/orders/${orderId}/messages/${msgId}?lang=${lang}`, {
+    method: 'DELETE',
+    headers: {
+      'Authorization': `Bearer ${token}`
+    }
+  });
+  if (!response.ok) throw new Error("Failed to delete message");
+  return response.json();
+};
+
 
 export const authPing = async () => {
   const token = localStorage.getItem("token");