Explorar o código

fix:修复聊天页面信息不显示时间的问题;不能发送图片信息的问题;

yangyi hai 4 meses
pai
achega
e5cd439ab7

+ 53 - 0
src/api/endpoints/attachment.ts

@@ -0,0 +1,53 @@
+/**
+ * Generated by orval v7.0.1 🍺
+ * Do not edit manually.
+ * OpenAPI definition
+ * OpenAPI spec version: v0
+ */
+import type {
+  ResultListString,
+  ResultString,
+  UploadImageBody,
+  UploadImagesBody,
+} from "../models";
+import { customAxiosInstance } from "../../util/axios-instance";
+
+export const getAttachment = () => {
+  /**
+   * 批量上传多张图片,返回图片访问 URL 列表,需要登录
+   * @summary 批量图片上传
+   */
+  const uploadImages = (uploadImagesBody: UploadImagesBody) => {
+    const formData = new FormData();
+    uploadImagesBody.files.forEach((value) => formData.append("files", value));
+
+    return customAxiosInstance<ResultListString>({
+      url: `/attachment/upload/images`,
+      method: "POST",
+      headers: { "Content-Type": "multipart/form-data" },
+      data: formData,
+    });
+  };
+  /**
+   * 上传单张图片,返回图片访问 URL,需要登录
+   * @summary 单张图片上传
+   */
+  const uploadImage = (uploadImageBody: UploadImageBody) => {
+    const formData = new FormData();
+    formData.append("file", uploadImageBody.file);
+
+    return customAxiosInstance<ResultString>({
+      url: `/attachment/upload/image`,
+      method: "POST",
+      headers: { "Content-Type": "multipart/form-data" },
+      data: formData,
+    });
+  };
+  return { uploadImages, uploadImage };
+};
+export type UploadImagesResult = NonNullable<
+  Awaited<ReturnType<ReturnType<typeof getAttachment>["uploadImages"]>>
+>;
+export type UploadImageResult = NonNullable<
+  Awaited<ReturnType<ReturnType<typeof getAttachment>["uploadImage"]>>
+>;

+ 4 - 0
src/api/models/index.ts

@@ -33,11 +33,13 @@ export * from "./resultItemVO";
 export * from "./resultListCategory";
 export * from "./resultListConversationVO";
 export * from "./resultListItemVO";
+export * from "./resultListString";
 export * from "./resultLong";
 export * from "./resultPageResultItemVO";
 export * from "./resultPageResultMessageVO";
 export * from "./resultPageResultOrderVO";
 export * from "./resultPageResultUser";
+export * from "./resultString";
 export * from "./resultUserProfileVO";
 export * from "./resultVoid";
 export * from "./reviewRequest";
@@ -46,6 +48,8 @@ export * from "./searchItemsParams";
 export * from "./sendMessageRequest";
 export * from "./statusRequest";
 export * from "./submitAuthBody";
+export * from "./uploadImageBody";
+export * from "./uploadImagesBody";
 export * from "./user";
 export * from "./userProfileRequest";
 export * from "./userProfileVO";

+ 18 - 0
src/api/models/resultListString.ts

@@ -0,0 +1,18 @@
+/**
+ * Generated by orval v7.0.1 🍺
+ * Do not edit manually.
+ * OpenAPI definition
+ * OpenAPI spec version: v0
+ */
+
+/**
+ * 统一响应对象
+ */
+export interface ResultListString {
+  /** 状态码 */
+  code?: number;
+  /** 响应数据 */
+  data?: string[];
+  /** 消息 */
+  message?: string;
+}

+ 18 - 0
src/api/models/resultString.ts

@@ -0,0 +1,18 @@
+/**
+ * Generated by orval v7.0.1 🍺
+ * Do not edit manually.
+ * OpenAPI definition
+ * OpenAPI spec version: v0
+ */
+
+/**
+ * 统一响应对象
+ */
+export interface ResultString {
+  /** 状态码 */
+  code?: number;
+  /** 响应数据 */
+  data?: string;
+  /** 消息 */
+  message?: string;
+}

+ 11 - 0
src/api/models/uploadImageBody.ts

@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.0.1 🍺
+ * Do not edit manually.
+ * OpenAPI definition
+ * OpenAPI spec version: v0
+ */
+
+export type UploadImageBody = {
+  /** 图片文件,支持 jpg、jpeg、png、gif、webp 格式,最大 10MB */
+  file: Blob;
+};

+ 11 - 0
src/api/models/uploadImagesBody.ts

@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.0.1 🍺
+ * Do not edit manually.
+ * OpenAPI definition
+ * OpenAPI spec version: v0
+ */
+
+export type UploadImagesBody = {
+  /** 图片文件列表,支持 jpg、jpeg、png、gif、webp 格式,每个文件最大 10MB */
+  files: Blob[];
+};

+ 2 - 0
src/util/axios-instance.ts

@@ -31,6 +31,7 @@ const authPaths = ['/auth/login', '/auth/register']
 axiosInstance.interceptors.response.use(
   (response: AxiosResponse) => {
     const { code, message } = response.data
+      console.log(response.data,1)
     if (code === 200) {
       return response.data
     }
@@ -44,6 +45,7 @@ axiosInstance.interceptors.response.use(
     return Promise.reject(new Error(message || '请求失败'))
   },
   (error) => {
+      console.log(error,2)
     if (error.response?.status === 401) {
       const userStore = useUserStore()
       userStore.logout()

+ 103 - 14
src/view/Chat.vue

@@ -14,9 +14,12 @@
         :class="['message-row', msg.fromUserId === currentUserId ? 'own' : 'other']"
       >
         <el-avatar :src="msg.fromUserAvatar" :size="32" />
-        <div class="message-bubble">
-          <p>{{ msg.content }}</p>
-          <el-image v-if="msg.imageUrl" :src="msg.imageUrl" fit="cover" class="msg-image" />
+        <div class="message-body">
+          <div class="message-bubble">
+            <el-image v-if="msg.imageUrl" :src="msg.imageUrl" fit="cover" class="msg-image" :preview-src-list="[msg.imageUrl]" preview-teleported />
+            <p v-if="msg.content">{{ msg.content }}</p>
+          </div>
+          <span class="message-time">{{ formatTime(msg.createdAt) }}</span>
         </div>
       </div>
       <div v-if="loading" class="loading-more">
@@ -25,6 +28,16 @@
     </div>
 
     <div class="input-area">
+      <input ref="fileInputRef" type="file" accept="image/*" hidden @change="handleFileChange" />
+      <div v-if="currentImageUrl" class="image-preview">
+        <el-image :src="currentImageUrl" fit="cover" class="preview-image" />
+        <el-button size="small" circle @click="currentImageUrl = ''" class="preview-remove">
+          <el-icon><Close /></el-icon>
+        </el-button>
+      </div>
+      <el-button circle @click="fileInputRef?.click()">
+        <el-icon><Picture /></el-icon>
+      </el-button>
       <el-input
         v-model="text"
         type="textarea"
@@ -42,8 +55,10 @@
 <script setup lang="ts">
 import { ref, onMounted, nextTick } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
-import { ArrowLeft, Loading } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import { ArrowLeft, Close, Loading, Picture } from '@element-plus/icons-vue'
 import { getMessages } from '../api/endpoints/messages'
+import { getAttachment } from '../api/endpoints/attachment'
 import { useUserStore } from '../store'
 import type { MessageVO } from '../api/models'
 
@@ -55,6 +70,47 @@ const messages = ref<MessageVO[]>([])
 const text = ref('')
 const loading = ref(false)
 const partnerName = ref('')
+const fileInputRef = ref<HTMLInputElement | null>(null)
+const currentImageUrl = ref('')
+
+const formatTime = (iso?: string) => {
+  if (!iso) return ''
+  const d = new Date(iso)
+  const pad = (n: number) => n.toString().padStart(2, '0')
+  const now = new Date()
+  const sameYear = d.getFullYear() === now.getFullYear()
+  if (sameYear && d.getDate() === now.getDate() && d.getMonth() === now.getMonth()) {
+    return `${pad(d.getHours())}:${pad(d.getMinutes())}`
+  }
+  if (sameYear) {
+    return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+  }
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+}
+
+const handleFileChange = async (e: Event) => {
+  const target = e.target as HTMLInputElement
+  const file = target.files?.[0]
+  if (!file) return
+  if (!file.type.startsWith('image/')) {
+    ElMessage.error('只能上传图片')
+    return
+  }
+  if (file.size > 10 * 1024 * 1024) {
+    ElMessage.error('图片大小不能超过 10MB')
+    return
+  }
+  try {
+    const res = await getAttachment().uploadImage({ file })
+    if (res.code === 200 && res.data) {
+      currentImageUrl.value = res.data
+    }
+  } catch {
+    // 拦截器已展示错误信息
+  } finally {
+    target.value = ''
+  }
+}
 
 const currentUserId = userStore.state.userId
 const targetUserId = Number(route.params.userId)
@@ -87,17 +143,18 @@ const loadMessages = async () => {
 }
 
 const handleSend = async () => {
-  if (!text.value.trim()) return
+  if (!text.value.trim() && !currentImageUrl.value) return
+  const content = text.value.trim() || undefined
+  const imageUrl = currentImageUrl.value || undefined
+  currentImageUrl.value = ''
+  text.value = ''
   try {
-    const res = await getMessages().sendMessage({ toUserId: targetUserId, content: text.value.trim() })
+    const res = await getMessages().sendMessage({ toUserId: targetUserId, content, imageUrl })
     if (res.code === 200) {
-      messages.value.push({
-        id: res.data,
-        fromUserId: currentUserId,
-        content: text.value.trim(),
-        createdAt: new Date().toISOString(),
-      } as MessageVO)
-      text.value = ''
+      const msg: MessageVO = { id: res.data, fromUserId: currentUserId ?? undefined, createdAt: new Date().toISOString() }
+      if (content) msg.content = content
+      if (imageUrl) msg.imageUrl = imageUrl
+      messages.value.push(msg)
       await scrollToBottom()
     }
   } catch {
@@ -141,12 +198,21 @@ onMounted(loadMessages)
 .message-row.own {
   flex-direction: row-reverse;
 }
-.message-bubble {
+.message-body {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
   max-width: 70%;
+}
+.message-row.own .message-body {
+  align-items: flex-end;
+}
+.message-bubble {
   padding: 10px 14px;
   border-radius: 12px;
   font-size: 14px;
   line-height: 1.5;
+  word-break: break-word;
 }
 .message-row.other .message-bubble {
   background: #f0f0f0;
@@ -157,6 +223,11 @@ onMounted(loadMessages)
   color: #fff;
   border-bottom-right-radius: 4px;
 }
+.message-time {
+  font-size: 11px;
+  color: #c0c4cc;
+  padding: 0 4px;
+}
 .msg-image {
   width: 120px;
   height: 120px;
@@ -178,4 +249,22 @@ onMounted(loadMessages)
 .input-area .el-button {
   align-self: flex-end;
 }
+.image-preview {
+  position: relative;
+  align-self: flex-end;
+}
+.preview-image {
+  width: 60px;
+  height: 60px;
+  border-radius: 4px;
+}
+.preview-remove {
+  position: absolute;
+  top: -8px;
+  right: -8px;
+  padding: 0;
+  width: 18px;
+  height: 18px;
+}
+
 </style>