|
|
@@ -0,0 +1,623 @@
|
|
|
+<template>
|
|
|
+ <div class="decoration-chat">
|
|
|
+ <div class="chat-header">
|
|
|
+ <el-button text @click="handleBack" class="back-btn">
|
|
|
+ <el-icon><ArrowLeft /></el-icon>
|
|
|
+ </el-button>
|
|
|
+ <span class="chat-title">{{ ownerName || "联系业主" }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div ref="chatContainerRef" class="chat-content" @scroll="handleScroll">
|
|
|
+ <div v-if="loading" class="loading-wrap">
|
|
|
+ <el-icon class="is-loading">
|
|
|
+ <Loading />
|
|
|
+ </el-icon>
|
|
|
+ <span>加载中...</span>
|
|
|
+ </div>
|
|
|
+ <div v-else class="message-list">
|
|
|
+ <div v-for="(msg, index) in messages" :key="msg.id || index" :class="['message-item', isMine(msg) ? 'mine' : 'other']">
|
|
|
+ <el-avatar class="msg-avatar" :src="isMine(msg) ? myAvatar : ownerAvatar" :size="40">
|
|
|
+ <!-- <span class="avatar-fallback">{{ (isMine(msg) ? myName : ownerName).slice(0, 1) }}</span> -->
|
|
|
+ </el-avatar>
|
|
|
+ <div class="msg-body">
|
|
|
+ <div class="msg-time">
|
|
|
+ {{ msg.createdTime }}
|
|
|
+ </div>
|
|
|
+ <div class="msg-bubble">
|
|
|
+ <!-- 文本 -->
|
|
|
+ <div v-if="msg.type === 1 || msg.type === '1'" class="msg-text">
|
|
|
+ {{ msg.content || msg.text }}
|
|
|
+ </div>
|
|
|
+ <!-- 图片 -->
|
|
|
+ <el-image
|
|
|
+ v-else-if="msg.type === 2 || msg.type === '2'"
|
|
|
+ :src="msg.content || msg.text"
|
|
|
+ fit="cover"
|
|
|
+ class="msg-image"
|
|
|
+ :preview-src-list="[msg.content || msg.text]"
|
|
|
+ />
|
|
|
+ <!-- 视频 -->
|
|
|
+ <video
|
|
|
+ v-else-if="msg.type === 8 || msg.type === '8'"
|
|
|
+ :src="msg.content || msg.text"
|
|
|
+ class="msg-video"
|
|
|
+ controls
|
|
|
+ preload="metadata"
|
|
|
+ />
|
|
|
+ <!-- 分享卡片等 -->
|
|
|
+ <div v-else-if="msg.type === 3 || msg.type === '3'" class="msg-share">
|
|
|
+ <span v-if="typeof (msg.content || msg.text) === 'object'">
|
|
|
+ {{ (msg.content || msg.text)?.title || "分享链接" }}
|
|
|
+ </span>
|
|
|
+ <span v-else>{{ msg.content || msg.text }}</span>
|
|
|
+ </div>
|
|
|
+ <!-- 其他类型 -->
|
|
|
+ <div v-else class="msg-text">
|
|
|
+ {{ msg.content || msg.text || "[消息]" }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="chat-input-bar">
|
|
|
+ <input ref="imageInputRef" type="file" accept="image/*" class="hidden-file-input" @change="handleImageSelect" />
|
|
|
+ <input ref="videoInputRef" type="file" accept="video/*" class="hidden-file-input" @change="handleVideoSelect" />
|
|
|
+ <div class="input-row">
|
|
|
+ <div class="tool-btns">
|
|
|
+ <el-tooltip content="图片" placement="top">
|
|
|
+ <el-button text :disabled="sending || uploading" @click="triggerImageInput" class="tool-btn">
|
|
|
+ <el-icon :size="22">
|
|
|
+ <Picture />
|
|
|
+ </el-icon>
|
|
|
+ </el-button>
|
|
|
+ </el-tooltip>
|
|
|
+ <el-tooltip content="视频" placement="top">
|
|
|
+ <el-button text :disabled="sending || uploading" @click="triggerVideoInput" class="tool-btn">
|
|
|
+ <el-icon :size="22">
|
|
|
+ <VideoPlay />
|
|
|
+ </el-icon>
|
|
|
+ </el-button>
|
|
|
+ </el-tooltip>
|
|
|
+ </div>
|
|
|
+ <el-input
|
|
|
+ v-model="inputText"
|
|
|
+ type="textarea"
|
|
|
+ :rows="2"
|
|
|
+ :maxlength="500"
|
|
|
+ show-word-limit
|
|
|
+ placeholder="请输入消息"
|
|
|
+ resize="none"
|
|
|
+ @keydown.enter.exact.prevent="handleSend"
|
|
|
+ />
|
|
|
+ <el-button type="primary" :loading="sending" @click="handleSend" class="send-btn"> 发送 </el-button>
|
|
|
+ </div>
|
|
|
+ <div v-if="uploading" class="uploading-tip">上传中...</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts" name="decorationChat">
|
|
|
+import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
|
|
|
+import { useRoute, useRouter } from "vue-router";
|
|
|
+import { ElMessage } from "element-plus";
|
|
|
+import { ArrowLeft, Loading, Picture, VideoPlay } from "@element-plus/icons-vue";
|
|
|
+import { useWebSocketStore } from "@/stores/modules/websocket";
|
|
|
+import { getChatRecord, messageRead, socketStatus, uploadChatFile } from "@/api/modules/storeDecoration";
|
|
|
+import { localGet } from "@/utils";
|
|
|
+
|
|
|
+const route = useRoute();
|
|
|
+const router = useRouter();
|
|
|
+const socketStore = useWebSocketStore();
|
|
|
+
|
|
|
+// WebSocket 基础地址(与商家端一致)
|
|
|
+const WS_BASE = (import.meta.env.VITE_WS_BASE || "ws://120.26.186.130:8000/alienStore/socket/").replace(/\/$/, "");
|
|
|
+
|
|
|
+// 会话信息
|
|
|
+const sendId = ref("");
|
|
|
+const receiverId = ref("");
|
|
|
+const ownerName = ref("");
|
|
|
+const ownerAvatar = ref("");
|
|
|
+const requirementId = ref("");
|
|
|
+
|
|
|
+const messages = ref<any[]>([]);
|
|
|
+const loading = ref(true);
|
|
|
+const sending = ref(false);
|
|
|
+const uploading = ref(false);
|
|
|
+const inputText = ref("");
|
|
|
+const chatContainerRef = ref<HTMLElement>();
|
|
|
+const imageInputRef = ref<HTMLInputElement | null>(null);
|
|
|
+const videoInputRef = ref<HTMLInputElement | null>(null);
|
|
|
+let cleanMessageFn: (() => void) | null = null;
|
|
|
+let statusTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
+
|
|
|
+const userInfo = computed(() => localGet("geeker-user")?.userInfo || {});
|
|
|
+
|
|
|
+// 自己头像与名称(用于气泡旁头像)
|
|
|
+const myAvatar = computed(
|
|
|
+ () =>
|
|
|
+ userInfo.value?.headImg ||
|
|
|
+ userInfo.value?.avatar ||
|
|
|
+ userInfo.value?.userImage ||
|
|
|
+ "http://localhost:5173/static/activity/avatar.svg"
|
|
|
+);
|
|
|
+
|
|
|
+const isMine = (msg: any) => String(msg.senderId || "") === String(sendId.value);
|
|
|
+
|
|
|
+const handleBack = () => router.go(-1);
|
|
|
+
|
|
|
+const scrollToBottom = () => {
|
|
|
+ nextTick(() => {
|
|
|
+ const el = chatContainerRef.value;
|
|
|
+ if (el) el.scrollTop = el.scrollHeight;
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const handleSend = async () => {
|
|
|
+ const text = inputText.value?.trim();
|
|
|
+ if (!text || !receiverId.value) return;
|
|
|
+ if (!socketStore.isConnected) {
|
|
|
+ ElMessage.warning("连接已断开,请稍后重试");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ sending.value = true;
|
|
|
+ const ok = await socketStore.sendMessage({
|
|
|
+ category: "message",
|
|
|
+ type: 1,
|
|
|
+ receiverId: "store_" + receiverId.value,
|
|
|
+ senderId: sendId.value,
|
|
|
+ text
|
|
|
+ });
|
|
|
+ sending.value = false;
|
|
|
+ if (ok) {
|
|
|
+ messages.value = [
|
|
|
+ ...messages.value,
|
|
|
+ {
|
|
|
+ id: `temp_${Date.now()}`,
|
|
|
+ type: 1,
|
|
|
+ content: text,
|
|
|
+ senderId: sendId.value,
|
|
|
+ receiverId: "store_" + receiverId.value,
|
|
|
+ createdTime: formatTime(new Date())
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ inputText.value = "";
|
|
|
+ scrollToBottom();
|
|
|
+ } else {
|
|
|
+ ElMessage.error("发送失败");
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 解析上传接口返回的 fileUrl(与商家端一致)
|
|
|
+const getFileUrlFromRes = (res: any): string => {
|
|
|
+ const data = res?.data;
|
|
|
+ if (Array.isArray(data) && data.length > 0) {
|
|
|
+ const first = data[0];
|
|
|
+ return typeof first === "string" ? first : first?.fileUrl || first?.url || first?.path || "";
|
|
|
+ }
|
|
|
+ if (typeof data === "string") return data;
|
|
|
+ if (data && typeof data === "object") return data.fileUrl || data.url || data.path || "";
|
|
|
+ return "";
|
|
|
+};
|
|
|
+
|
|
|
+const triggerImageInput = () => imageInputRef.value?.click();
|
|
|
+const triggerVideoInput = () => videoInputRef.value?.click();
|
|
|
+
|
|
|
+const handleImageSelect = async (e: Event) => {
|
|
|
+ const target = e.target as HTMLInputElement;
|
|
|
+ const file = target.files?.[0];
|
|
|
+ target.value = "";
|
|
|
+ if (!file || !receiverId.value) return;
|
|
|
+ if (!socketStore.isConnected) {
|
|
|
+ ElMessage.warning("连接已断开,请稍后重试");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ uploading.value = true;
|
|
|
+ try {
|
|
|
+ const form = new FormData();
|
|
|
+ form.append("file", file);
|
|
|
+ const res: any = await uploadChatFile(form);
|
|
|
+ const fileUrl = getFileUrlFromRes(res);
|
|
|
+ if (!fileUrl) {
|
|
|
+ ElMessage.error("图片上传失败");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const ok = await socketStore.sendMessage({
|
|
|
+ category: "message",
|
|
|
+ type: 2,
|
|
|
+ receiverId: "store_" + receiverId.value,
|
|
|
+ senderId: sendId.value,
|
|
|
+ text: fileUrl
|
|
|
+ });
|
|
|
+ if (ok) {
|
|
|
+ messages.value = [
|
|
|
+ ...messages.value,
|
|
|
+ {
|
|
|
+ id: `temp_${Date.now()}`,
|
|
|
+ type: 2,
|
|
|
+ content: fileUrl,
|
|
|
+ senderId: sendId.value,
|
|
|
+ receiverId: receiverId.value,
|
|
|
+ createdTime: formatTime(new Date())
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ scrollToBottom();
|
|
|
+ } else {
|
|
|
+ ElMessage.error("发送失败");
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error("发送图片失败", err);
|
|
|
+ ElMessage.error("图片上传或发送失败");
|
|
|
+ } finally {
|
|
|
+ uploading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const handleVideoSelect = async (e: Event) => {
|
|
|
+ const target = e.target as HTMLInputElement;
|
|
|
+ const file = target.files?.[0];
|
|
|
+ target.value = "";
|
|
|
+ if (!file || !receiverId.value) return;
|
|
|
+ if (!socketStore.isConnected) {
|
|
|
+ ElMessage.warning("连接已断开,请稍后重试");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ uploading.value = true;
|
|
|
+ try {
|
|
|
+ const form = new FormData();
|
|
|
+ form.append("file", file);
|
|
|
+ const res: any = await uploadChatFile(form);
|
|
|
+ const fileUrl = getFileUrlFromRes(res);
|
|
|
+ if (!fileUrl) {
|
|
|
+ ElMessage.error("视频上传失败");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const ok = await socketStore.sendMessage({
|
|
|
+ category: "message",
|
|
|
+ type: 8,
|
|
|
+ receiverId: "store_" + receiverId.value,
|
|
|
+ senderId: sendId.value,
|
|
|
+ text: fileUrl
|
|
|
+ });
|
|
|
+ if (ok) {
|
|
|
+ messages.value = [
|
|
|
+ ...messages.value,
|
|
|
+ {
|
|
|
+ id: `temp_${Date.now()}`,
|
|
|
+ type: 8,
|
|
|
+ content: fileUrl,
|
|
|
+ senderId: sendId.value,
|
|
|
+ receiverId: receiverId.value,
|
|
|
+ createdTime: formatTime(new Date())
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ scrollToBottom();
|
|
|
+ } else {
|
|
|
+ ElMessage.error("发送失败");
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error("发送视频失败", err);
|
|
|
+ ElMessage.error("视频上传或发送失败");
|
|
|
+ } finally {
|
|
|
+ uploading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const loadChatRecord = async () => {
|
|
|
+ if (!receiverId.value || !sendId.value) {
|
|
|
+ loading.value = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ loading.value = true;
|
|
|
+ await messageRead({ receiverId: sendId.value, senderId: receiverId.value });
|
|
|
+ const res: any = await getChatRecord({
|
|
|
+ receiverId: sendId.value,
|
|
|
+ senderId: "store_" + receiverId.value
|
|
|
+ });
|
|
|
+ const list = res?.data?.messageList || res?.messageList || [];
|
|
|
+ list.forEach((item: any) => {
|
|
|
+ item.messageId = item.id;
|
|
|
+ });
|
|
|
+ const filterData = list.filter((v: any) => !(v.type === "5" && v.senderId === sendId.value));
|
|
|
+ messages.value = filterData;
|
|
|
+ } catch (e) {
|
|
|
+ console.error("获取聊天记录失败", e);
|
|
|
+ } finally {
|
|
|
+ loading.value = false;
|
|
|
+ scrollToBottom();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const initWebSocket = async () => {
|
|
|
+ const phone = userInfo.value?.phone || localGet("iphone");
|
|
|
+ if (!phone) {
|
|
|
+ ElMessage.warning("未获取到商家信息,无法连接");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ sendId.value = `store_${phone}`;
|
|
|
+ const wsUrl =
|
|
|
+ WS_BASE.startsWith("wss") || WS_BASE.startsWith("ws")
|
|
|
+ ? `${WS_BASE}/store_${phone}`
|
|
|
+ : WS_BASE.replace("https", "wss").replace("http", "ws") + `/store_${phone}`;
|
|
|
+
|
|
|
+ if (!socketStore.isConnected || socketStore.lastConnectedUrl !== wsUrl) {
|
|
|
+ await socketStore.connect(wsUrl);
|
|
|
+ }
|
|
|
+ if (cleanMessageFn) {
|
|
|
+ cleanMessageFn();
|
|
|
+ cleanMessageFn = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 归一化 ID:统一去掉 store_/user_ 前缀再比较,避免业主端与商家端 ID 格式不一致导致收不到
|
|
|
+ const normalizeId = (id: string) => {
|
|
|
+ if (!id) return "";
|
|
|
+ const s = String(id).trim();
|
|
|
+ return s.replace(/^(store_|user_)/i, "");
|
|
|
+ };
|
|
|
+
|
|
|
+ cleanMessageFn = socketStore.subscribe("message", (val: any) => {
|
|
|
+ const sId = String(sendId.value);
|
|
|
+ const rId = String(receiverId.value);
|
|
|
+ const vSender = String(val.senderId ?? "");
|
|
|
+ const vReceiver = String(val.receiverId ?? "");
|
|
|
+ const sIdNorm = normalizeId(sId);
|
|
|
+ const rIdNorm = normalizeId(rId);
|
|
|
+ const vSenderNorm = normalizeId(vSender);
|
|
|
+ const vReceiverNorm = normalizeId(vReceiver);
|
|
|
+ // 当前会话:对方发给我(vReceiver 是我)且 vSender 是当前业主;或反向
|
|
|
+ const isCurrent = (vReceiver === sId || vReceiverNorm === sIdNorm) && (vSender === rId || vSenderNorm === rIdNorm);
|
|
|
+ const isCurrentReverse = (vReceiver === rId || vReceiverNorm === rIdNorm) && (vSender === sId || vSenderNorm === sIdNorm);
|
|
|
+ if (!isCurrent && !isCurrentReverse) return;
|
|
|
+
|
|
|
+ const newMsg = {
|
|
|
+ ...val,
|
|
|
+ id: val.messageId || val.id,
|
|
|
+ content: val.text || val.content,
|
|
|
+ createdTime: val.createdTime || formatTime(new Date())
|
|
|
+ };
|
|
|
+ const contentOrText = String(newMsg.content ?? val.text ?? "").trim();
|
|
|
+ const isFromMe = vSender === sId || vSenderNorm === sIdNorm;
|
|
|
+
|
|
|
+ // 1) 先按服务器 id 去重
|
|
|
+ let existingIndex = messages.value.findIndex(item => newMsg.id && (item.id === newMsg.id || item.messageId === newMsg.id));
|
|
|
+ // 2) 若是自己发的消息,可能是服务端回显:用“临时消息(temp_) + 相同内容”替换,避免显示两条
|
|
|
+ if (existingIndex === -1 && isFromMe) {
|
|
|
+ const sameContent = (a: any) => String(a?.content ?? a?.text ?? "").trim();
|
|
|
+ existingIndex = messages.value.findIndex(
|
|
|
+ item =>
|
|
|
+ String(item.senderId ?? "") === sId &&
|
|
|
+ String(item.id ?? "").startsWith("temp_") &&
|
|
|
+ sameContent(item) === sameContent(newMsg) &&
|
|
|
+ (newMsg.type == null || Number(item.type) === Number(newMsg.type))
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (existingIndex !== -1) {
|
|
|
+ messages.value = messages.value.map((m, i) => (i === existingIndex ? { ...m, ...newMsg } : m));
|
|
|
+ } else {
|
|
|
+ messages.value = [...messages.value, newMsg];
|
|
|
+ }
|
|
|
+ nextTick(() => scrollToBottom());
|
|
|
+
|
|
|
+ if (vSender !== sId) {
|
|
|
+ socketStore.sendMessage({ ...val, category: "receipt" }).catch((err: any) => console.error("回执发送失败", err));
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ statusTimer = setInterval(() => {
|
|
|
+ socketStatus().catch(() => {});
|
|
|
+ }, 5000);
|
|
|
+};
|
|
|
+
|
|
|
+const formatTime = (d: Date) => {
|
|
|
+ const y = d.getFullYear();
|
|
|
+ const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
|
+ const day = String(d.getDate()).padStart(2, "0");
|
|
|
+ const h = String(d.getHours()).padStart(2, "0");
|
|
|
+ const min = String(d.getMinutes()).padStart(2, "0");
|
|
|
+ const s = String(d.getSeconds()).padStart(2, "0");
|
|
|
+ return `${y}-${m}-${day} ${h}:${min}:${s}`;
|
|
|
+};
|
|
|
+
|
|
|
+const handleScroll = () => {};
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => route.query,
|
|
|
+ q => {
|
|
|
+ if (q?.receiverId) {
|
|
|
+ receiverId.value = String(q.receiverId);
|
|
|
+ ownerName.value = decodeURIComponent(String(q.uName || q.ownerName || "业主"));
|
|
|
+ ownerAvatar.value = q.userImage
|
|
|
+ ? decodeURIComponent(String(q.userImage))
|
|
|
+ : "http://localhost:5173/static/activity/avatar.svg";
|
|
|
+ requirementId.value = String(q.id || q.requirementId || "");
|
|
|
+ loadChatRecord();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+);
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ receiverId.value = String(route.query.receiverId || "");
|
|
|
+ ownerName.value = decodeURIComponent(String(route.query.uName || route.query.ownerName || "业主"));
|
|
|
+ ownerAvatar.value = route.query.userImage
|
|
|
+ ? decodeURIComponent(String(route.query.userImage))
|
|
|
+ : "http://localhost:5173/static/activity/avatar.svg";
|
|
|
+ requirementId.value = String(route.query.id || route.query.requirementId || "");
|
|
|
+
|
|
|
+ if (!receiverId.value) {
|
|
|
+ ElMessage.warning("缺少会话信息");
|
|
|
+ loading.value = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await initWebSocket();
|
|
|
+ await loadChatRecord();
|
|
|
+});
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ if (cleanMessageFn) {
|
|
|
+ cleanMessageFn();
|
|
|
+ cleanMessageFn = null;
|
|
|
+ }
|
|
|
+ if (statusTimer) {
|
|
|
+ clearInterval(statusTimer);
|
|
|
+ statusTimer = null;
|
|
|
+ }
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.decoration-chat {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: calc(100vh - 100px);
|
|
|
+ background: #f4f6fb;
|
|
|
+}
|
|
|
+.chat-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 12px 16px;
|
|
|
+ background: #ffffff;
|
|
|
+ border-bottom: 1px solid #ebeef5;
|
|
|
+ .back-btn {
|
|
|
+ margin-right: 12px;
|
|
|
+ font-size: 18px;
|
|
|
+ }
|
|
|
+ .chat-title {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+}
|
|
|
+.chat-content {
|
|
|
+ flex: 1;
|
|
|
+ padding: 16px;
|
|
|
+ overflow-y: auto;
|
|
|
+ .loading-wrap {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 24px;
|
|
|
+ color: #909399;
|
|
|
+ }
|
|
|
+ .message-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 16px;
|
|
|
+ }
|
|
|
+ .message-item {
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+ align-items: flex-start;
|
|
|
+ .msg-avatar {
|
|
|
+ flex-shrink: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ .avatar-fallback {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ font-size: 16px;
|
|
|
+ color: #ffffff;
|
|
|
+ background: #c0c4cc;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ &.mine {
|
|
|
+ flex-direction: row-reverse;
|
|
|
+ .msg-body {
|
|
|
+ align-items: flex-end;
|
|
|
+ }
|
|
|
+ .msg-bubble {
|
|
|
+ color: #ffffff;
|
|
|
+ background: #409eff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .msg-body {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: flex-start;
|
|
|
+ max-width: 70%;
|
|
|
+ }
|
|
|
+ .msg-time {
|
|
|
+ margin-bottom: 4px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ }
|
|
|
+ .msg-bubble {
|
|
|
+ padding: 10px 14px;
|
|
|
+ word-break: break-word;
|
|
|
+ background: #ffffff;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
|
|
|
+ }
|
|
|
+ .msg-text {
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1.5;
|
|
|
+ }
|
|
|
+ .msg-image {
|
|
|
+ max-width: 200px;
|
|
|
+ max-height: 200px;
|
|
|
+ cursor: pointer;
|
|
|
+ border-radius: 4px;
|
|
|
+ }
|
|
|
+ .msg-video {
|
|
|
+ max-width: 240px;
|
|
|
+ max-height: 180px;
|
|
|
+ background: #000000;
|
|
|
+ border-radius: 4px;
|
|
|
+ }
|
|
|
+ .msg-share {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #606266;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+.hidden-file-input {
|
|
|
+ position: absolute;
|
|
|
+ width: 0;
|
|
|
+ height: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ pointer-events: none;
|
|
|
+ opacity: 0;
|
|
|
+}
|
|
|
+.chat-input-bar {
|
|
|
+ position: relative;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 12px 16px;
|
|
|
+ background: #ffffff;
|
|
|
+ border-top: 1px solid #ebeef5;
|
|
|
+ .input-row {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ align-items: flex-end;
|
|
|
+ :deep(.el-input) {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .tool-btns {
|
|
|
+ display: flex;
|
|
|
+ flex-shrink: 0;
|
|
|
+ gap: 4px;
|
|
|
+ align-items: center;
|
|
|
+ .tool-btn {
|
|
|
+ padding: 6px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ :deep(.el-textarea__inner) {
|
|
|
+ border-radius: 8px;
|
|
|
+ }
|
|
|
+ .send-btn {
|
|
|
+ flex-shrink: 0;
|
|
|
+ align-self: flex-end;
|
|
|
+ }
|
|
|
+ .uploading-tip {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|