Bläddra i källkod

朋友圈前端实现

wangqi49 3 dagar sedan
förälder
incheckning
434cbde671

+ 2 - 0
webchat-front-client/package-lock.json

@@ -12,6 +12,8 @@
         "ant-design-vue": "^4.0.0-rc.6",
         "axios": "^1.7.9",
         "highlight.js": "^11.11.1",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.21",
         "markdown-it": "^14.1.0",
         "marked": "^15.0.6",
         "pattern.css": "^1.0.0",

+ 2 - 0
webchat-front-client/package.json

@@ -13,6 +13,8 @@
     "ant-design-vue": "^4.0.0-rc.6",
     "axios": "^1.7.9",
     "highlight.js": "^11.11.1",
+    "lodash": "^4.17.21",
+    "lodash-es": "^4.17.21",
     "markdown-it": "^14.1.0",
     "marked": "^15.0.6",
     "pattern.css": "^1.0.0",

+ 0 - 1
webchat-front-client/src/main.js

@@ -7,7 +7,6 @@ import Antd from 'ant-design-vue';
 import 'ant-design-vue/dist/reset.css';
 import * as Icons from '@ant-design/icons-vue'; // 引入图标库
 import { message } from 'ant-design-vue';
-// import 'pattern.css';
 
 const app = createApp(App)
 

BIN
webchat-front-client/src/static/images/moment-bg.png


+ 33 - 2
webchat-front-client/src/views/chat.vue

@@ -35,7 +35,7 @@
           />
           <span class="message-red-point" :style="{ display: redPointStatus }"></span>
         </div>
-        <WechatOutlined 
+        <ChromeOutlined 
           @click="handleIconClick('moment')" 
           :style="{ color: iconColors.moment }" 
           @mouseover="handleIconHover('moment')"
@@ -181,6 +181,20 @@
         <a-button type="primary" @click="aggreeGroupVideoOffer" style="width: 60px; background-color: #1FB759; margin-left: 20px;">接 听</a-button>
       </div>
   </div>
+
+  <div ref="modalContainer">
+    <a-modal
+      :getContainer="() => $refs.modalContainer"
+      class="moment-modal"
+      :visible="momentComponentVisible"
+      :footer="null"
+      :closable="false"
+      :bodyStyle="{ padding: 0 }"
+      style="height: 700px; width: 370px; border-radius: 3px; padding: 0px;"
+      >
+      <MomentList @closeMoment="momentComponentVisible=false"></MomentList>
+    </a-modal>
+  </div>
 </template>
 
 <script>
@@ -194,6 +208,7 @@ import FriendList from '../views/FriendList.vue';
 import ChattingList from '../views/Chatting.vue';
 import WaitConfirmList from '../views/WaitConfirmList.vue';
 import ChatCore from '../views/ChatCore.vue';
+import MomentList from '../views/moment/list.vue';
 
 export default defineComponent({
   components: {
@@ -209,7 +224,8 @@ export default defineComponent({
     WaitConfirmList,
     FriendList,
     ChattingList,
-    ChatCore
+    ChatCore,
+    MomentList
   },
   setup() {
     const userId = ref('');
@@ -249,6 +265,7 @@ export default defineComponent({
     const groupVideoOfferCardVisible = ref(false);
     const parentSetUser = ref(null);
     const openVideo = ref(false);
+    const momentComponentVisible = ref(false);
 
     // 用对象记录每个图标的颜色状态
     const iconColors = ref({
@@ -360,6 +377,9 @@ export default defineComponent({
         case 'bell':
           currentCenterComponent.value = 'WaitConfirmList';
           break;
+        case 'moment': 
+          momentComponentVisible.value = true;  
+          break;
         case 'logout':
           logout();
           break;
@@ -730,6 +750,7 @@ export default defineComponent({
       createGroupVisible,
       selectedFriends,
       createGroupLoading,
+      momentComponentVisible,
       aggreeGroupVideoOffer,
       rejectGroupVideoOffer,
       rejectVideoOffer,
@@ -960,4 +981,14 @@ export default defineComponent({
     border: 1px solid rgb(241, 240, 240);
     box-shadow: 0px 0px 30px rgb(215, 215, 215);
   }
+  .moment-content .ant-modal-content {
+    padding: 0 !important;
+  }
+  /* 穿透scoped限制,覆盖Modal内部结构 */
+:deep(.ant-modal-content) {
+  padding: 0 !important;
+}
+:deep(.ant-modal-body) {
+  padding: 0 !important;
+}
 </style>

+ 0 - 0
webchat-front-client/src/views/moment/detail.vue


+ 364 - 0
webchat-front-client/src/views/moment/list.vue

@@ -0,0 +1,364 @@
+<template>
+  <div class="moment-header-container">
+    <img src="../../static/images/moment-bg.png" style="width: 100%;">
+    <CloseOutlined class="close-icon" @click="closeMoment"/>
+    <InstagramFilled class="pub-icon" @click="showPublishPage"/>
+  </div>
+  <div class="user-info-container">
+      <img :src="loginUser.photo" style="float: right; height: 60px; border-radius: 5px;">
+      <b style="float: right; margin-right: 10px; color: white;">
+          {{loginUser.userName}}
+      </b>
+  </div>
+
+  <div class="moment-list-container">
+
+    <div class="feed-container" ref="feedContainer">
+      <a-list item-layout="vertical" class="moment-list" :data-source="feedList" :loading="loading">
+        <template #renderItem="{ item }">
+          <a-list-item :key="item.id">  <!-- 添加唯一key -->
+            <!-- 动态内容展示 -->
+            <div class="feed-item">
+              <div class="feed-header">
+                <!-- 头部:头像+用户信息 -->
+                  <a-avatar 
+                    :src="item.authorInfo.photo" 
+                    class="user-avatar"
+                    :size="32"
+                    style="margin-left: -40px; float: left; border-radius: 3px;"
+                  />
+                  <div class="user-info" style="text-align: left; float: left;">
+                    <p class="user-name" style="line-height: 22px; margin-bottom: 0px;">{{ item.authorInfo.userName }}</p>
+                    <p class="user-signature" style="font-size: 10px; line-height: 10px;">{{ item.authorInfo.signature }}</p>
+                  </div>
+              </div>
+              <div class="feed-content">{{ item.content }}</div>
+              <div 
+                  class="feed-images"
+                  :class="`count-${Math.min(item.images.length, 9)}`" 
+                  v-if="item.images"
+                >
+                  <a-image
+                    v-for="(image, index) in item.images"
+                    :key="index"
+                    :src="image.resource"
+                    :width="width"
+                    :height="height"
+                  />
+              </div>
+              <div 
+                  v-if="item.video" style="height: auto;"
+                >
+                <video 
+                    autoplay
+                    muted
+                    style="max-height: 250px; float: left; max-width: 95%;"
+                    controls 
+                    :poster="item.video.resource" 
+                >
+                    <source :src="item.video.resource" type="video/mp4">
+                </video>
+                <div style="clear: both;"></div>
+              </div>
+
+              <div class="moment-footer">
+                {{formatTime(item.publishTime)}}
+                <span style="margin-left: 20px;">{{item.ipAddress}}</span>
+              </div>
+            </div>
+          </a-list-item>
+        </template>
+      </a-list>
+      <div v-if="loading" class="loading-more">
+        <a-spin />
+      </div>
+  </div>
+
+  </div>
+
+  <a-modal
+    :visible="momentPublishVisible"
+    @cancel="momentPublishVisible = false"
+    :footer="null"
+    :closable="false"
+    :bodyStyle="{ padding: 0 }"
+    style="height: 500px; width: 300px; border-radius: 3px; padding: 0px; margin-top: 80px;"
+    >
+    <MomentPublish 
+      @success="handlePublishSuccess"
+      @cancel="momentPublishVisible = false" >
+    </MomentPublish>
+  </a-modal>
+
+</template>
+
+<script>
+import axios from 'axios';
+import { inject, defineComponent, ref, onMounted, onUnmounted, watch } from 'vue';
+import { List as AList, Image as AImage, Spin as ASpin } from 'ant-design-vue';
+import MomentPublish from '../moment/publish.vue';
+export default defineComponent({
+  props: {
+      
+  },
+  components: {
+    MomentPublish
+  },
+  setup(props, {emit}) {
+
+    const loginUser = ref(inject('loginUser'));
+    const feedList = ref([]);
+    const loading = ref(false);
+    const feedContainer = ref(null);
+    const momentPublishVisible = ref(false);
+    const lastMomentId = ref(99999999999);
+   
+    // 模拟数据加载函数
+    const loadMoreFeeds = async () => {
+      loading.value = true;
+      try {
+        // 这里替换为实际的API请求
+        axios.get(`/client-service/moment/timeline?lastMomentId=` + lastMomentId.value, {
+          // 这里可以添加请求的配置,例如 headers 或 params
+          headers: {
+            'Content-Type': 'application/json',
+            'origin-url': window.location.href
+          }
+        }).then(function (response) {
+          loading.value = false;
+          if (response.data.code === 40001) {
+            // 未登录
+            window.location.href = response.data.redirect_url;
+          } else if (response.data.code === 200) {
+            const moments = response.data.data;
+            if(moments.length > 0) {
+              feedList.value = [...feedList.value, ...moments];
+              // 更新最后ID
+              lastMomentId.value = moments[moments.length-1].id; 
+            }
+          }
+        }).catch(function (error) {
+          // 处理错误
+          console.error(error);
+        });
+      } catch (error) {
+        console.error('加载数据失败:', error);
+      } finally {
+        loading.value = false;
+      }
+    };
+
+    // 在 setup() 中替换这部分
+    const throttle = (func, wait) => {
+      let timeout;
+      return (...args) => {
+        if (!timeout) {
+          timeout = setTimeout(() => {
+            func.apply(this, args);
+            timeout = null;
+          }, wait);
+        }
+      };
+    };
+
+    // 修改滚动处理函数
+    const handleScroll = throttle(() => {
+      const container = feedContainer.value
+      if (!container) return
+
+      // 调试输出(保留用于问题诊断)
+      console.log(`滚动位置: ${container.scrollTop + container.clientHeight}/${container.scrollHeight}`)
+
+      // 改进的判断逻辑
+      const scrollThreshold = 50 // 缓冲像素
+      const isBottomReached = container.scrollTop + container.clientHeight + scrollThreshold >= container.scrollHeight
+
+      if (isBottomReached && !loading.value) {
+        loadMoreFeeds()
+      }
+    }, 200);
+
+    const showPublishPage = () => {
+      momentPublishVisible.value = true;
+    }
+
+    const closeMoment = () => {
+        emit("closeMoment")
+    }
+
+    const handlePublishSuccess = () => {
+      momentPublishVisible.value = false;
+      setTimeout(() => {
+        lastMomentId.value = 99999999999;
+        feedList.value = [];
+        loadMoreFeeds();
+      }, 3000);
+    }
+
+    const formatTime = (timestamp) => {
+      const now = new Date()
+      const target = new Date(timestamp)
+      const diffMinutes = Math.round((now - target) / 60000)
+
+      if (diffMinutes < 60) {
+        return `${diffMinutes}分钟前`
+      } else if (target.toDateString() === now.toDateString()) {
+        return target.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
+      } else {
+        return `${target.getMonth() + 1}月${target.getDate()}日`
+      }
+    }
+
+    onUnmounted(() => {
+      feedContainer.value?.removeEventListener('scroll', handleScroll);
+    });
+
+    onMounted(() => {
+      loadMoreFeeds();
+      feedContainer.value?.addEventListener('scroll', handleScroll);
+    });
+  
+    
+    return {
+      feedList,
+      loading,
+      loginUser,
+      momentPublishVisible,
+      feedContainer,
+      formatTime,
+      showPublishPage,
+      closeMoment,
+      handlePublishSuccess
+    };
+  }
+});
+</script>
+
+<style scoped>
+  .moment-header-container {
+      position: relative;
+      width: 100%;
+      height: 180px;
+      background-color: black;
+      overflow: hidden;
+  }
+  .user-info-container {
+      position: relative;
+      width: 90%;
+      height: 60px;
+      margin-top: -30px;
+      margin-left: 5%;
+  }
+  .moment-list-container {
+    position: relative;
+    width: 90%;
+    height: 60px;
+    margin-top: 0px;
+    margin-left: 5%;
+    height: 400px
+  }
+  .pub-icon, .close-icon {
+    position: absolute;
+    right: 27px;
+    top: 20px;
+    color: white;
+    font-size: 20px;
+  }
+  .close-icon {
+    left: 20px;
+  }
+  .feed-container {
+    height: 400px;
+    border: 0;
+    overflow-y: auto;
+    border-radius: 4px;
+    border: 0px;
+    overflow: auto !important;
+    scrollbar-width: none !important; /* Firefox */
+    -ms-overflow-style: none !important; /* IE/Edge */
+  }
+
+.feed-item {
+padding: 0px 16px;
+border-bottom: none;
+}
+.feed-header {
+position: relative;
+width: 100%;
+height: 45px;
+text-align: left;
+}
+
+.user-name {
+font-weight: bold;
+}
+
+.feed-content {
+margin-bottom: 12px;
+line-height: 1.5;
+}
+
+.feed-images {
+width: 100%;
+height: auto;
+}
+
+.loading-more {
+display: flex;
+justify-content: center;
+padding: 16px 0;
+}
+
+.parent-container {
+isolation: isolate; /* 阻止样式继承 */
+}
+
+.feed-images {
+display: flex;
+flex-wrap: wrap;
+gap: 12px;
+}
+
+/* 单张图片样式 */
+.count-1 {
+justify-content: center;
+
+::v-deep .ant-image {
+  max-width: 80%;
+  height: auto;
+}
+}
+
+/* 两张图片样式 */
+.count-2 {
+flex-wrap: nowrap; /* 关键修复点 */
+::v-deep .ant-image {
+  flex: 0 0 100px; /* 严格固定尺寸 */
+  margin-right: 12px; /* 替代gap避免计算误差 */
+}
+}
+
+/* 三张及以上图片 */
+.count-3 ::v-deep .ant-image,
+.count-4 ::v-deep .ant-image,
+.count-5 ::v-deep .ant-image,
+.count-6 ::v-deep .ant-image,
+.count-7 ::v-deep .ant-image,
+.count-8 ::v-deep .ant-image,
+.count-9 ::v-deep .ant-image {
+flex: 0 0 calc(33.333% - 8px);
+}
+
+/* 单张图片比例保持 */
+::v-deep .ant-image-img {
+object-fit: cover;
+width: 100%;
+height: 100%;
+}
+.moment-footer {
+line-height: 30px;
+font-size: 12px;
+text-align: left;
+color: gray;
+}
+</style>

+ 243 - 0
webchat-front-client/src/views/moment/publish.vue

@@ -0,0 +1,243 @@
+<template>
+
+<div style="margin: 0">
+    <a-textarea
+      v-model:value="content"
+      placeholder="这一刻的想法..."
+      :auto-size="{ minRows: 5, maxRows: 10 }"
+      class="moment-content"
+    />
+</div>
+
+<div class="media-container" style="width: 250px;">
+
+    <div v-if="images != null && images.length > 0">
+        <div v-for="image in images" class="moment-image">
+            <img :src="image">
+        </div>
+    </div>
+    <div v-if="video != null">
+        <video 
+            autoplay
+            style="max-height: 250px; float: left; max-width: 100%;"
+            controls 
+            :poster="video" 
+        >
+            <source :src="video" type="video/mp4">
+        </video>
+    </div>
+    <button class="uploadMediaButton" @click="uploadMedia" v-if="uploadBtnVidible">
+        <PlusOutlined />
+    </button>
+    <!-- 隐藏的文件上传input -->
+    <input 
+        type="file"
+        ref="mediaUpload"
+        accept="image/png, image/jpeg, image/jpg, video/mp4"
+        style="display: none;"
+        @change="handleFileUpload"
+    />
+    <div style="clear: both;"></div>
+</div>
+
+<div class="button-container" style="margin-top: 60px;">
+    <button class="cancelButton" @click="cancelPublish">
+        取消
+    </button>
+    <button class="publishButton" @click="publish">
+        发表
+    </button>
+</div>
+
+</template>
+
+<script>
+  import axios from 'axios';
+  import { inject, defineComponent, ref, onMounted, onUnmounted, watch } from 'vue';
+  import { message } from 'ant-design-vue';
+  export default defineComponent({
+    props: {
+        
+    },
+    components: {
+        
+    },
+    setup(props, { emit }) {
+
+      const loginUser = ref(inject('loginUser'));
+      const mediaUpload = ref(null);
+      const uploadUrl = `/client-service/chat/file/upload`; // 上传API
+      const images = ref([]); 
+      const video = ref(null);
+      const content = ref("");
+      const uploadBtnVidible = ref(true);
+      
+
+      const uploadMedia = () => {
+        mediaUpload.value.click();
+      }
+
+       // 处理文件选择
+       const handleFileUpload = async (e) => {
+            const file = e.target.files[0];
+            if (!file) return;
+             // 验证文件类型(兼容浏览器绕过 accept 的情况)
+            const allowedTypes = ['image/png', 'image/jpeg', 'video/mp4'];
+            if (!allowedTypes.includes(file.type)) {
+                alert('仅支持 PNG、JPEG 图片和 MP4 视频');
+                return;
+            }
+            // 校验文件大小(示例限制2MB)
+            if (file.size > 20 * 1024 * 1024) {
+                message.error('图片大小不能超过20MB');
+                return;
+            }
+            // 方式2:上传到服务器(示例使用axios)
+            try {
+                const formData = new FormData();
+                formData.append('file', file);
+                const response = await axios.post(uploadUrl, formData, {
+                    headers: {
+                    'Content-Type': 'multipart/form-data',
+                    'origin-url': window.location.href,
+                    'upload-path': 'moment/media'
+                    }
+                });
+                if (response.data.code === 40001) {
+                    window.location.href = response.data.redirect_url;
+                }
+                // 预览红包封面图
+                if (response.data.data.type == 'IMAGE') {
+                    video.value = null;
+                    images.value.push(response.data.data.url);
+                } else  if (response.data.data.type == 'VIDEO') {
+                    images.value = [];
+                    video.value = response.data.data.url;
+                }
+                if(images.value.length == 9 || video.value != null) {
+                    uploadBtnVidible.value = false;
+                } else {
+                    uploadBtnVidible.value = true;
+                }
+            } catch (error) {
+                message.error('上传失败');
+                console.error('Upload error:', error);
+            }
+        };
+      
+
+      const publish = async () => {
+            if (!content.value || (video == null && images.length == 0)) {
+                message.error("内容为空");
+                return ;
+            }
+            try {
+                // 调用接口发送登录请求
+                const response = await axios.post(`/client-service/moment/publish`, 
+                {
+                    content: content.value,
+                    images: images.value,
+                    video: video.value
+                });
+                if (response.data.code === 40001) {
+                    window.location.href = response.data.redirect_url;
+                } else if (response.data.code != 200) {
+                    message.error(response.data.msg);
+                }
+                // 发布成功
+                message.info("动态已发布,审核中");
+                emit('success');
+            } catch (error) {
+                message.error("发布异常", error);
+            }
+
+      }
+
+      const cancelPublish = () => {
+        emit('cancel');
+        // 可选:清空已上传内容
+        content.value = '';
+        images.value = [];
+        video.value = null;
+      }
+      onMounted(() => {
+        
+      });
+      onUnmounted(() => {
+        
+      });
+      
+      return {
+        uploadBtnVidible,
+        content,
+        images,
+        video,
+        mediaUpload,
+        cancelPublish,
+        uploadMedia,
+        handleFileUpload,
+        publish
+      };
+    }
+  });
+
+</script>
+
+
+<style scoped>
+/* 穿透组件作用域,覆盖内部样式 */
+.moment-content  {
+  border: none !important;
+  box-shadow: none !important;
+}
+.moment-image {
+    width: 70px;
+    height: 70px;
+    border: none;
+    float: left;
+    margin-right: 10px;
+    margin-bottom: 10px;
+    overflow: hidden;
+}
+.moment-image img {
+    width: 100%;
+    min-height: 100%;
+}
+.uploadMediaButton {
+    width: 70px;
+    height: 70px;
+    border: none;
+    background-color: whitesmoke;
+}
+.uploadMediaButton:hover {
+    cursor: pointer;
+    background-color: rgb(233, 232, 232);
+}
+.button-container {
+    display: flex;
+    justify-content: center;
+    gap: 20px; /* 或使用 margin-right 兼容方案 */
+    margin-top: 60px;
+}
+/* 保持原有按钮样式 */
+.cancelButton, .publishButton {
+    width: 80px; 
+    height: 30px;
+    border-radius: 5px;
+    border: 0px;
+    /* 添加过渡效果提升交互 */
+    transition: all 0.3s;
+}
+.publishButton {
+    background-color: rgb(59, 206, 59);
+    color: white;
+}
+.cancelButton:hover {
+    cursor: pointer;
+    background-color: rgba(19, 158, 19, 0.1);
+}
+.publishButton:hover {
+    cursor: pointer;
+    background-color: rgb(36, 175, 36);
+}
+</style>

+ 4 - 0
webchat-front-client/vite.config.js

@@ -24,6 +24,10 @@ export default defineConfig({
         target: 'http://localhost', 
         changeOrigin: true
       },
+      '/client-service/moment/': {
+        target: 'http://localhost', 
+        changeOrigin: true
+      },
       '/connect-service/ws/': {
         target: 'ws://localhost',
         changeOrigin: true,