Skip to content

社交應用實戰案例

本文將介紹如何使用 uni-app 開發一個功能完善的社交應用,包括應用架構設計、核心功能實現和關鍵程式碼範例。

1. 應用概述

1.1 功能特點

社交應用是行動網路時代最受歡迎的應用類型之一,主要功能包括:

  • 使用者認證:註冊、登入、第三方登入、找回密碼
  • 個人資料:頭像、暱稱、個人簡介、興趣標籤
  • 社交關係:好友新增、關注、粉絲管理
  • 內容分享:動態發佈、圖文內容、話題討論
  • 互動功能:按讚、評論、轉發、收藏
  • 即時通訊:私聊、群聊、語音訊息、圖片分享
  • 內容發現:個人化推薦、熱門話題、附近的人

1.2 技術架構

前端技術棧

  • uni-app:跨平台開發框架,實現一次開發多端執行
  • Vue.js:響應式資料綁定,提供元件化開發模式
  • Vuex:狀態管理,處理複雜元件間通訊
  • Socket.io:即時通訊,支援即時訊息收發

後端技術棧

  • Node.js/Express:伺服器端框架,處理API請求
  • MongoDB:資料儲存,適合社交應用的非結構化資料
  • Redis:快取和會話管理,提高存取速度
  • WebSocket:即時通訊支援,保持長連線

2. 專案結構

├── components            // 自訂元件
│   ├── chat-item         // 聊天列表項元件
│   ├── comment-item      // 評論項元件
│   ├── post-card         // 動態卡片元件
│   └── user-card         // 使用者卡片元件
├── pages                 // 頁面資料夾
│   ├── auth              // 認證相關頁面
│   ├── chat              // 聊天相關頁面
│   ├── discover          // 發現頁面
│   ├── post              // 動態相關頁面
│   └── user              // 使用者相關頁面
├── store                 // Vuex 狀態管理
│   ├── index.js          // 組裝模組並匯出
│   ├── modules           // 狀態模組
│   │   ├── user.js       // 使用者狀態
│   │   ├── post.js       // 動態狀態
│   │   └── chat.js       // 聊天狀態
├── utils                 // 工具函數
│   ├── request.js        // 請求封裝
│   ├── socket.js         // WebSocket封裝
│   └── validator.js      // 表單驗證
├── static                // 靜態資源
├── App.vue               // 應用入口
├── main.js               // 主入口
├── manifest.json         // 設定檔
└── pages.json            // 頁面設定

3. 核心功能實現

3.1 使用者認證系統

使用者認證是社交應用的基礎,包括註冊、登入、第三方登入等功能。

狀態管理設計

js
// store/modules/user.js
export default {
  namespaced: true,
  state: {
    token: uni.getStorageSync('token') || '',
    userInfo: uni.getStorageSync('userInfo') ? JSON.parse(uni.getStorageSync('userInfo')) : null,
    isLogin: Boolean(uni.getStorageSync('token'))
  },
  mutations: {
    SET_TOKEN(state, token) {
      state.token = token;
      state.isLogin = Boolean(token);
      uni.setStorageSync('token', token);
    },
    SET_USER_INFO(state, userInfo) {
      state.userInfo = userInfo;
      uni.setStorageSync('userInfo', JSON.stringify(userInfo));
    },
    LOGOUT(state) {
      state.token = '';
      state.userInfo = null;
      state.isLogin = false;
      uni.removeStorageSync('token');
      uni.removeStorageSync('userInfo');
    }
  },
  actions: {
    // 登入
    async login({ commit }, params) {
      try {
        const res = await this._vm.$api.user.login(params);
        commit('SET_TOKEN', res.data.token);
        commit('SET_USER_INFO', res.data.userInfo);
        return res.data;
      } catch (error) {
        throw error;
      }
    },
    
    // 註冊
    async register({ commit }, params) {
      try {
        const res = await this._vm.$api.user.register(params);
        commit('SET_TOKEN', res.data.token);
        commit('SET_USER_INFO', res.data.userInfo);
        return res.data;
      } catch (error) {
        throw error;
      }
    },
    
    // 取得使用者資訊
    async getUserInfo({ commit }) {
      try {
        const res = await this._vm.$api.user.getUserInfo();
        commit('SET_USER_INFO', res.data);
        return res.data;
      } catch (error) {
        throw error;
      }
    },
    
    // 登出
    logout({ commit }) {
      commit('LOGOUT');
    }
  }
};

登入頁面實現

vue
<!-- pages/auth/login/login.vue -->
<template>
  <view class="login-container">
    <view class="logo-box">
      <image src="/static/logo.png" class="logo"></image>
      <text class="app-name">社交圈</text>
    </view>
    
    <view class="form-box">
      <view class="input-group">
        <text class="iconfont icon-user"></text>
        <input 
          type="text" 
          v-model="form.username" 
          placeholder="使用者名稱/手機號/信箱" 
          class="input"
        />
      </view>
      
      <view class="input-group">
        <text class="iconfont icon-lock"></text>
        <input 
          :type="showPassword ? 'text' : 'password'" 
          v-model="form.password" 
          placeholder="請輸入密碼" 
          class="input"
        />
        <text 
          class="iconfont" 
          :class="showPassword ? 'icon-eye' : 'icon-eye-close'"
          @click="togglePasswordVisibility"
        ></text>
      </view>
      
      <button class="login-btn" @click="handleLogin" :loading="loading">登入</button>
      
      <view class="action-links">
        <navigator url="/pages/auth/register/register" class="link">註冊帳號</navigator>
        <navigator url="/pages/auth/forgot-password/forgot-password" class="link">忘記密碼</navigator>
      </view>
    </view>
    
    <view class="third-party-login">
      <view class="divider">
        <text class="divider-text">其他登入方式</text>
      </view>
      
      <view class="third-party-icons">
        <view class="icon-item" @click="thirdPartyLogin('wechat')">
          <text class="iconfont icon-wechat"></text>
        </view>
        <view class="icon-item" @click="thirdPartyLogin('qq')">
          <text class="iconfont icon-qq"></text>
        </view>
        <view class="icon-item" @click="thirdPartyLogin('weibo')">
          <text class="iconfont icon-weibo"></text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import { mapActions } from 'vuex';

export default {
  data() {
    return {
      form: {
        username: '',
        password: ''
      },
      showPassword: false,
      loading: false
    }
  },
  methods: {
    ...mapActions('user', ['login']),
    
    // 切換密碼可見性
    togglePasswordVisibility() {
      this.showPassword = !this.showPassword;
    },
    
    // 處理登入
    async handleLogin() {
      // 表單驗證
      if (!this.form.username.trim()) {
        uni.showToast({ title: '請輸入使用者名稱', icon: 'none' });
        return;
      }
      
      if (!this.form.password) {
        uni.showToast({ title: '請輸入密碼', icon: 'none' });
        return;
      }
      
      this.loading = true;
      
      try {
        // 呼叫登入介面
        await this.login(this.form);
        
        // 登入成功,跳轉到首頁
        uni.switchTab({ url: '/pages/index/index' });
      } catch (error) {
        uni.showToast({
          title: error.message || '登入失敗,請重試',
          icon: 'none'
        });
      } finally {
        this.loading = false;
      }
    },
    
    // 第三方登入
    thirdPartyLogin(type) {
      // 根據不同平台呼叫不同的登入API
      uni.login({
        provider: type === 'wechat' ? 'weixin' : (type === 'qq' ? 'qq' : 'sinaweibo'),
        success: (loginRes) => {
          this.handleThirdPartyLoginSuccess(type, loginRes);
        },
        fail: (err) => {
          uni.showToast({
            title: `${type}登入失敗`,
            icon: 'none'
          });
        }
      });
    },
    
    // 處理第三方登入成功
    async handleThirdPartyLoginSuccess(type, loginRes) {
      try {
        // 呼叫後端介面,驗證第三方登入憑證
        await this.login({
          type: type,
          code: loginRes.code
        });
        
        // 登入成功,跳轉到首頁
        uni.switchTab({ url: '/pages/index/index' });
      } catch (error) {
        uni.showToast({
          title: error.message || '登入失敗,請重試',
          icon: 'none'
        });
      }
    }
  }
}
</script>

3.2 動態發佈功能

動態發佈是社交應用的核心功能,支援文字、圖片、影片等多媒體內容。

vue
<!-- pages/post/publish/publish.vue -->
<template>
  <view class="publish-container">
    <view class="header">
      <text class="cancel-btn" @click="cancel">取消</text>
      <text class="title">發佈動態</text>
      <text class="publish-btn" :class="{ disabled: !canPublish }" @click="publish">發佈</text>
    </view>
    
    <view class="content-area">
      <textarea 
        v-model="content" 
        placeholder="分享新鮮事..." 
        class="content-input"
        :maxlength="500"
        auto-height
      />
      
      <view class="media-grid" v-if="mediaList.length > 0">
        <view 
          class="media-item" 
          v-for="(item, index) in mediaList" 
          :key="index"
        >
          <image 
            v-if="item.type === 'image'" 
            :src="item.url" 
            mode="aspectFill"
            class="media-preview"
          />
          <video 
            v-if="item.type === 'video'" 
            :src="item.url" 
            class="media-preview"
            controls
          />
          <text class="remove-btn" @click="removeMedia(index)">×</text>
        </view>
        
        <view 
          class="add-media-btn" 
          v-if="mediaList.length < 9"
          @click="chooseMedia"
        >
          <text class="iconfont icon-plus"></text>
        </view>
      </view>
      
      <view class="add-media-area" v-else>
        <view class="add-media-btn large" @click="chooseMedia">
          <text class="iconfont icon-camera"></text>
          <text class="add-text">新增照片/影片</text>
        </view>
      </view>
    </view>
    
    <view class="toolbar">
      <view class="tool-item" @click="chooseMedia">
        <text class="iconfont icon-image"></text>
        <text>照片</text>
      </view>
      
      <view class="tool-item" @click="chooseLocation">
        <text class="iconfont icon-location"></text>
        <text>位置</text>
      </view>
      
      <view class="tool-item" @click="addTopic">
        <text class="iconfont icon-topic"></text>
        <text>話題</text>
      </view>
      
      <view class="tool-item" @click="setPrivacy">
        <text class="iconfont icon-privacy"></text>
        <text>隱私</text>
      </view>
    </view>
    
    <!-- 位置選擇彈窗 -->
    <uni-popup ref="locationPopup" type="bottom">
      <view class="location-popup">
        <view class="popup-header">
          <text class="popup-title">選擇位置</text>
          <text class="close-btn" @click="closeLocationPopup">×</text>
        </view>
        <view class="location-search">
          <input 
            type="text" 
            v-model="locationKeyword" 
            placeholder="搜尋位置"
            @input="searchLocation"
          />
        </view>
        <scroll-view scroll-y class="location-list">
          <view 
            class="location-item"
            v-for="(item, index) in locationList"
            :key="index"
            @click="selectLocation(item)"
          >
            <text class="location-name">{{item.name}}</text>
            <text class="location-address">{{item.address}}</text>
          </view>
        </scroll-view>
      </view>
    </uni-popup>
    
    <!-- 話題選擇彈窗 -->
    <uni-popup ref="topicPopup" type="bottom">
      <view class="topic-popup">
        <view class="popup-header">
          <text class="popup-title">選擇話題</text>
          <text class="close-btn" @click="closeTopicPopup">×</text>
        </view>
        <view class="topic-search">
          <input 
            type="text" 
            v-model="topicKeyword" 
            placeholder="搜尋或建立話題"
            @input="searchTopic"
          />
        </view>
        <scroll-view scroll-y class="topic-list">
          <view 
            class="topic-item"
            v-for="(item, index) in topicList"
            :key="index"
            @click="selectTopic(item)"
          >
            <text class="topic-name">#{{item.name}}#</text>
            <text class="topic-count">{{item.postCount}}則動態</text>
          </view>
        </scroll-view>
      </view>
    </uni-popup>
  </view>
</template>

<script>
import uniPopup from '@/components/uni-popup/uni-popup.vue';

export default {
  components: {
    uniPopup
  },
  data() {
    return {
      content: '',
      mediaList: [],
      selectedLocation: null,
      selectedTopics: [],
      privacy: 'public', // public, friends, private
      
      locationKeyword: '',
      locationList: [],
      topicKeyword: '',
      topicList: [],
      
      uploading: false
    }
  },
  computed: {
    canPublish() {
      return (this.content.trim() || this.mediaList.length > 0) && !this.uploading;
    }
  },
  methods: {
    // 取消發佈
    cancel() {
      if (this.content || this.mediaList.length > 0) {
        uni.showModal({
          title: '確認取消',
          content: '取消後內容將不會儲存,確定要離開嗎?',
          success: (res) => {
            if (res.confirm) {
              uni.navigateBack();
            }
          }
        });
      } else {
        uni.navigateBack();
      }
    },
    
    // 選擇媒體檔案
    chooseMedia() {
      uni.showActionSheet({
        itemList: ['拍照', '從相簿選擇', '錄影'],
        success: (res) => {
          switch (res.tapIndex) {
            case 0:
              this.takePhoto();
              break;
            case 1:
              this.chooseImage();
              break;
            case 2:
              this.recordVideo();
              break;
          }
        }
      });
    },
    
    // 拍照
    takePhoto() {
      uni.chooseImage({
        count: 1,
        sourceType: ['camera'],
        success: (res) => {
          this.uploadMedia(res.tempFilePaths[0], 'image');
        }
      });
    },
    
    // 選擇圖片
    chooseImage() {
      const remainCount = 9 - this.mediaList.length;
      uni.chooseImage({
        count: remainCount,
        sourceType: ['album'],
        success: (res) => {
          res.tempFilePaths.forEach(filePath => {
            this.uploadMedia(filePath, 'image');
          });
        }
      });
    },
    
    // 錄影
    recordVideo() {
      uni.chooseVideo({
        sourceType: ['camera'],
        maxDuration: 60,
        success: (res) => {
          this.uploadMedia(res.tempFilePath, 'video');
        }
      });
    },
    
    // 上傳媒體檔案
    async uploadMedia(filePath, type) {
      this.uploading = true;
      
      try {
        // 顯示上傳進度
        const uploadTask = uni.uploadFile({
          url: this.$api.upload.uploadMedia,
          filePath: filePath,
          name: 'file',
          header: {
            'Authorization': `Bearer ${this.$store.state.user.token}`
          },
          success: (res) => {
            const data = JSON.parse(res.data);
            if (data.code === 200) {
              this.mediaList.push({
                type: type,
                url: data.data.url,
                localPath: filePath
              });
            } else {
              uni.showToast({
                title: '上傳失敗',
                icon: 'none'
              });
            }
          },
          fail: (err) => {
            uni.showToast({
              title: '上傳失敗',
              icon: 'none'
            });
          },
          complete: () => {
            this.uploading = false;
          }
        });
        
        // 監聽上傳進度
        uploadTask.onProgressUpdate((res) => {
          console.log('上傳進度', res.progress);
        });
        
      } catch (error) {
        this.uploading = false;
        uni.showToast({
          title: '上傳失敗',
          icon: 'none'
        });
      }
    },
    
    // 移除媒體檔案
    removeMedia(index) {
      this.mediaList.splice(index, 1);
    },
    
    // 選擇位置
    chooseLocation() {
      this.getCurrentLocation();
      this.$refs.locationPopup.open();
    },
    
    // 取得目前位置
    getCurrentLocation() {
      uni.getLocation({
        type: 'gcj02',
        success: (res) => {
          this.searchNearbyLocations(res.latitude, res.longitude);
        },
        fail: (err) => {
          uni.showToast({
            title: '取得位置失敗',
            icon: 'none'
          });
        }
      });
    },
    
    // 搜尋附近位置
    async searchNearbyLocations(lat, lng) {
      try {
        const res = await this.$api.location.searchNearby({
          latitude: lat,
          longitude: lng
        });
        this.locationList = res.data;
      } catch (error) {
        console.error('搜尋位置失敗', error);
      }
    },
    
    // 搜尋位置
    async searchLocation() {
      if (!this.locationKeyword.trim()) return;
      
      try {
        const res = await this.$api.location.search({
          keyword: this.locationKeyword
        });
        this.locationList = res.data;
      } catch (error) {
        console.error('搜尋位置失敗', error);
      }
    },
    
    // 選擇位置
    selectLocation(location) {
      this.selectedLocation = location;
      this.closeLocationPopup();
    },
    
    // 關閉位置彈窗
    closeLocationPopup() {
      this.$refs.locationPopup.close();
    },
    
    // 新增話題
    addTopic() {
      this.loadHotTopics();
      this.$refs.topicPopup.open();
    },
    
    // 載入熱門話題
    async loadHotTopics() {
      try {
        const res = await this.$api.topic.getHotTopics();
        this.topicList = res.data;
      } catch (error) {
        console.error('載入話題失敗', error);
      }
    },
    
    // 搜尋話題
    async searchTopic() {
      if (!this.topicKeyword.trim()) {
        this.loadHotTopics();
        return;
      }
      
      try {
        const res = await this.$api.topic.search({
          keyword: this.topicKeyword
        });
        this.topicList = res.data;
      } catch (error) {
        console.error('搜尋話題失敗', error);
      }
    },
    
    // 選擇話題
    selectTopic(topic) {
      if (!this.selectedTopics.find(t => t.id === topic.id)) {
        this.selectedTopics.push(topic);
        this.content += ` #${topic.name}# `;
      }
      this.closeTopicPopup();
    },
    
    // 關閉話題彈窗
    closeTopicPopup() {
      this.$refs.topicPopup.close();
    },
    
    // 設定隱私
    setPrivacy() {
      uni.showActionSheet({
        itemList: ['公開', '好友可見', '僅自己可見'],
        success: (res) => {
          const privacyMap = ['public', 'friends', 'private'];
          this.privacy = privacyMap[res.tapIndex];
        }
      });
    },
    
    // 發佈動態
    async publish() {
      if (!this.canPublish) return;
      
      try {
        const postData = {
          content: this.content.trim(),
          mediaList: this.mediaList.map(item => ({
            type: item.type,
            url: item.url
          })),
          location: this.selectedLocation,
          topics: this.selectedTopics.map(topic => topic.id),
          privacy: this.privacy
        };
        
        await this.$api.post.create(postData);
        
        uni.showToast({
          title: '發佈成功',
          icon: 'success'
        });
        
        // 返回上一頁並重新整理
        uni.navigateBack();
        
      } catch (error) {
        uni.showToast({
          title: error.message || '發佈失敗',
          icon: 'none'
        });
      }
    }
  }
}
</script>

3.3 即時聊天功能

即時聊天是社交應用的重要功能,需要實現訊息的即時收發和狀態同步。

js
// utils/socket.js - WebSocket 封裝
class SocketManager {
  constructor() {
    this.socket = null;
    this.isConnected = false;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;
    this.reconnectInterval = 3000;
    this.messageQueue = [];
    this.eventHandlers = new Map();
  }
  
  // 連線
  connect(token) {
    if (this.socket && this.isConnected) {
      return Promise.resolve();
    }
    
    return new Promise((resolve, reject) => {
      try {
        this.socket = uni.connectSocket({
          url: `${process.env.VUE_APP_WS_URL}?token=${token}`,
          success: () => {
            console.log('WebSocket 連線成功');
          },
          fail: (err) => {
            console.error('WebSocket 連線失敗', err);
            reject(err);
          }
        });
        
        this.socket.onOpen(() => {
          this.isConnected = true;
          this.reconnectAttempts = 0;
          
          // 發送佇列中的訊息
          this.flushMessageQueue();
          
          resolve();
        });
        
        this.socket.onMessage((res) => {
          this.handleMessage(JSON.parse(res.data));
        });
        
        this.socket.onClose(() => {
          this.isConnected = false;
          this.handleReconnect();
        });
        
        this.socket.onError((err) => {
          console.error('WebSocket 錯誤', err);
          this.isConnected = false;
        });
        
      } catch (error) {
        reject(error);
      }
    });
  }
  
  // 斷線重連
  handleReconnect() {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      this.reconnectAttempts++;
      console.log(`嘗試重連 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
      
      setTimeout(() => {
        const token = uni.getStorageSync('token');
        if (token) {
          this.connect(token);
        }
      }, this.reconnectInterval);
    }
  }
  
  // 處理收到的訊息
  handleMessage(data) {
    const { type, payload } = data;
    
    // 觸發對應的事件處理器
    if (this.eventHandlers.has(type)) {
      const handlers = this.eventHandlers.get(type);
      handlers.forEach(handler => handler(payload));
    }
  }
  
  // 發送訊息
  send(type, payload) {
    const message = { type, payload };
    
    if (this.isConnected) {
      this.socket.send({
        data: JSON.stringify(message)
      });
    } else {
      // 連線斷開時,將訊息加入佇列
      this.messageQueue.push(message);
    }
  }
  
  // 清空訊息佇列
  flushMessageQueue() {
    while (this.messageQueue.length > 0) {
      const message = this.messageQueue.shift();
      this.socket.send({
        data: JSON.stringify(message)
      });
    }
  }
  
  // 註冊事件處理器
  on(type, handler) {
    if (!this.eventHandlers.has(type)) {
      this.eventHandlers.set(type, []);
    }
    this.eventHandlers.get(type).push(handler);
  }
  
  // 移除事件處理器
  off(type, handler) {
    if (this.eventHandlers.has(type)) {
      const handlers = this.eventHandlers.get(type);
      const index = handlers.indexOf(handler);
      if (index > -1) {
        handlers.splice(index, 1);
      }
    }
  }
  
  // 斷開連線
  disconnect() {
    if (this.socket) {
      this.socket.close();
      this.socket = null;
      this.isConnected = false;
    }
  }
}

export default new SocketManager();
vue
<!-- pages/chat/conversation/conversation.vue -->
<template>
  <view class="conversation-container">
    <!-- 聊天標題欄 -->
    <view class="chat-header">
      <view class="back-btn" @click="goBack">
        <text class="iconfont icon-arrow-left"></text>
      </view>
      <view class="chat-info">
        <text class="chat-title">{{chatInfo.name}}</text>
        <text class="online-status" v-if="chatInfo.type === 'private'">
          {{chatInfo.isOnline ? '線上' : '離線'}}
        </text>
      </view>
      <view class="more-btn" @click="showMoreActions">
        <text class="iconfont icon-more"></text>
      </view>
    </view>
    
    <!-- 訊息列表 -->
    <scroll-view 
      scroll-y 
      class="message-list"
      :scroll-top="scrollTop"
      :scroll-with-animation="true"
      @scrolltoupper="loadMoreMessages"
    >
      <view class="message-item" v-for="(message, index) in messages" :key="message.id">
        <!-- 時間分隔線 -->
        <view class="time-divider" v-if="shouldShowTime(message, index)">
          <text class="time-text">{{formatMessageTime(message.timestamp)}}</text>
        </view>
        
        <!-- 訊息內容 -->
        <view class="message-wrapper" :class="{ 'own-message': message.senderId === currentUserId }">
          <!-- 頭像 -->
          <image 
            :src="message.senderAvatar" 
            class="avatar"
            v-if="message.senderId !== currentUserId"
          />
          
          <!-- 訊息氣泡 -->
          <view class="message-bubble" :class="getMessageBubbleClass(message)">
            <!-- 文字訊息 -->
            <text v-if="message.type === 'text'" class="message-text">{{message.content}}</text>
            
            <!-- 圖片訊息 -->
            <image 
              v-if="message.type === 'image'" 
              :src="message.content" 
              mode="widthFix"
              class="message-image"
              @click="previewImage(message.content)"
            />
            
            <!-- 語音訊息 -->
            <view v-if="message.type === 'voice'" class="voice-message" @click="playVoice(message)">
              <text class="iconfont icon-voice"></text>
              <text class="voice-duration">{{message.duration}}''</text>
            </view>
            
            <!-- 檔案訊息 -->
            <view v-if="message.type === 'file'" class="file-message" @click="downloadFile(message)">
              <text class="iconfont icon-file"></text>
              <view class="file-info">
                <text class="file-name">{{message.fileName}}</text>
                <text class="file-size">{{formatFileSize(message.fileSize)}}</text>
              </view>
            </view>
          </view>
          
          <!-- 訊息狀態 -->
          <view class="message-status" v-if="message.senderId === currentUserId">
            <text class="iconfont icon-loading" v-if="message.status === 'sending'"></text>
            <text class="iconfont icon-check" v-if="message.status === 'sent'"></text>
            <text class="iconfont icon-check-double" v-if="message.status === 'delivered'"></text>
            <text class="iconfont icon-check-double read" v-if="message.status === 'read'"></text>
            <text class="iconfont icon-error" v-if="message.status === 'failed'" @click="resendMessage(message)"></text>
          </view>
        </view>
      </view>
      
      <!-- 載入更多指示器 -->
      <view class="loading-more" v-if="loadingMore">
        <text>載入中...</text>
      </view>
    </scroll-view>
    
    <!-- 輸入區域 -->
    <view class="input-area">
      <view class="input-toolbar">
        <text class="tool-btn iconfont icon-voice" @click="toggleVoiceInput"></text>
        <text class="tool-btn iconfont icon-emoji" @click="showEmojiPanel"></text>
        <text class="tool-btn iconfont icon-plus" @click="showMoreOptions"></text>
      </view>
      
      <!-- 文字輸入 -->
      <view class="text-input-wrapper" v-if="!isVoiceMode">
        <textarea 
          v-model="inputText" 
          placeholder="輸入訊息..."
          class="text-input"
          :auto-height="true"
          :maxlength="1000"
          @input="onInputChange"
          @focus="onInputFocus"
          @blur="onInputBlur"
        />
        <button 
          class="send-btn" 
          :class="{ disabled: !canSend }"
          @click="sendTextMessage"
        >發送</button>
      </view>
      
      <!-- 語音輸入 -->
      <view class="voice-input-wrapper" v-else>
        <button 
          class="voice-btn"
          :class="{ recording: isRecording }"
          @touchstart="startRecording"
          @touchend="stopRecording"
          @touchcancel="cancelRecording"
        >
          {{isRecording ? '鬆開發送' : '按住說話'}}
        </button>
      </view>
    </view>
    
    <!-- 表情符號面板 -->
    <view class="emoji-panel" v-if="showEmoji">
      <scroll-view scroll-x class="emoji-categories">
        <view 
          class="emoji-category"
          :class="{ active: currentEmojiCategory === category.key }"
          v-for="category in emojiCategories"
          :key="category.key"
          @click="switchEmojiCategory(category.key)"
        >
          {{category.name}}
        </view>
      </scroll-view>
      
      <scroll-view scroll-y class="emoji-list">
        <view 
          class="emoji-item"
          v-for="emoji in currentEmojis"
          :key="emoji.code"
          @click="insertEmoji(emoji)"
        >
          {{emoji.char}}
        </view>
      </scroll-view>
    </view>
    
    <!-- 更多選項面板 -->
    <view class="more-options-panel" v-if="showMore">
      <view class="option-grid">
        <view class="option-item" @click="chooseImage">
          <text class="iconfont icon-image"></text>
          <text>照片</text>
        </view>
        
        <view class="option-item" @click="takePhoto">
          <text class="iconfont icon-camera"></text>
          <text>拍照</text>
        </view>
        
        <view class="option-item" @click="chooseFile">
          <text class="iconfont icon-file"></text>
          <text>檔案</text>
        </view>
        
        <view class="option-item" @click="shareLocation">
          <text class="iconfont icon-location"></text>
          <text>位置</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import { mapState } from 'vuex';
import socketManager from '@/utils/socket.js';

export default {
  data() {
    return {
      chatId: '',
      chatInfo: {},
      messages: [],
      inputText: '',
      isVoiceMode: false,
      isRecording: false,
      showEmoji: false,
      showMore: false,
      
      scrollTop: 0,
      loadingMore: false,
      hasMoreMessages: true,
      
      currentEmojiCategory: 'recent',
      emojiCategories: [
        { key: 'recent', name: '最近' },
        { key: 'people', name: '表情' },
        { key: 'nature', name: '自然' },
        { key: 'objects', name: '物品' }
      ],
      
      recordingTimer: null,
      recordingStartTime: 0
    }
  },
  computed: {
    ...mapState('user', ['userInfo']),
    
    currentUserId() {
      return this.userInfo?.id;
    },
    
    canSend() {
      return this.inputText.trim().length > 0;
    },
    
    currentEmojis() {
      // 根據當前分類返回表情符號列表
      return this.getEmojisByCategory(this.currentEmojiCategory);
    }
  },
  onLoad(options) {
    this.chatId = options.id;
    this.initChat();
  },
  onShow() {
    this.scrollToBottom();
  },
  onUnload() {
    this.cleanup();
  },
  methods: {
    // 初始化聊天
    async initChat() {
      try {
        // 載入聊天資訊
        await this.loadChatInfo();
        
        // 載入歷史訊息
        await this.loadMessages();
        
        // 建立 WebSocket 連線
        await this.connectSocket();
        
        // 標記訊息為已讀
        this.markMessagesAsRead();
        
      } catch (error) {
        console.error('初始化聊天失敗', error);
        uni.showToast({
          title: '載入失敗',
          icon: 'none'
        });
      }
    },
    
    // 載入聊天資訊
    async loadChatInfo() {
      const res = await this.$api.chat.getChatInfo(this.chatId);
      this.chatInfo = res.data;
    },
    
    // 載入訊息
    async loadMessages(loadMore = false) {
      if (loadMore && !this.hasMoreMessages) return;
      
      this.loadingMore = loadMore;
      
      try {
        const params = {
          chatId: this.chatId,
          limit: 20
        };
        
        if (loadMore && this.messages.length > 0) {
          params.before = this.messages[0].timestamp;
        }
        
        const res = await this.$api.chat.getMessages(params);
        const newMessages = res.data.messages;
        
        if (loadMore) {
          this.messages = [...newMessages, ...this.messages];
        } else {
          this.messages = newMessages;
          this.$nextTick(() => {
            this.scrollToBottom();
          });
        }
        
        this.hasMoreMessages = newMessages.length === params.limit;
        
      } catch (error) {
        console.error('載入訊息失敗', error);
      } finally {
        this.loadingMore = false;
      }
    },
    
    // 建立 Socket 連線
    async connectSocket() {
      const token = uni.getStorageSync('token');
      await socketManager.connect(token);
      
      // 監聽新訊息
      socketManager.on('newMessage', this.handleNewMessage);
      
      // 監聽訊息狀態更新
      socketManager.on('messageStatusUpdate', this.handleMessageStatusUpdate);
      
      // 監聽使用者線上狀態
      socketManager.on('userOnlineStatus', this.handleUserOnlineStatus);
    },
    
    // 處理新訊息
    handleNewMessage(message) {
      if (message.chatId === this.chatId) {
        this.messages.push(message);
        this.$nextTick(() => {
          this.scrollToBottom();
        });
        
        // 標記為已讀
        this.markMessageAsRead(message.id);
      }
    },
    
    // 處理訊息狀態更新
    handleMessageStatusUpdate(data) {
      const { messageId, status } = data;
      const message = this.messages.find(m => m.id === messageId);
      if (message) {
        message.status = status;
      }
    },
    
    // 處理使用者線上狀態
    handleUserOnlineStatus(data) {
      if (this.chatInfo.type === 'private' && data.userId === this.chatInfo.userId) {
        this.chatInfo.isOnline = data.isOnline;
      }
    },
    
    // 發送文字訊息
    async sendTextMessage() {
      if (!this.canSend) return;
      
      const messageId = this.generateMessageId();
      const message = {
        id: messageId,
        chatId: this.chatId,
        senderId: this.currentUserId,
        senderAvatar: this.userInfo.avatar,
        type: 'text',
        content: this.inputText.trim(),
        timestamp: Date.now(),
        status: 'sending'
      };
      
      // 新增到訊息列表
      this.messages.push(message);
      this.inputText = '';
      
      // 滾動到底部
      this.$nextTick(() => {
        this.scrollToBottom();
      });
      
      try {
        // 發送到伺服器
        socketManager.send('sendMessage', message);
        
        // 更新狀態為已發送
        message.status = 'sent';
        
      } catch (error) {
        console.error('發送訊息失敗', error);
        message.status = 'failed';
      }
    },
    
    // 重新發送訊息
    resendMessage(message) {
      message.status = 'sending';
      socketManager.send('sendMessage', message);
    },
    
    // 開始錄音
    startRecording() {
      this.isRecording = true;
      this.recordingStartTime = Date.now();
      
      uni.startRecord({
        success: (res) => {
          console.log('開始錄音');
        },
        fail: (err) => {
          console.error('錄音失敗', err);
          this.isRecording = false;
        }
      });
    },
    
    // 停止錄音
    stopRecording() {
      if (!this.isRecording) return;
      
      this.isRecording = false;
      const duration = Math.floor((Date.now() - this.recordingStartTime) / 1000);
      
      if (duration < 1) {
        uni.showToast({
          title: '錄音時間太短',
          icon: 'none'
        });
        return;
      }
      
      uni.stopRecord({
        success: (res) => {
          this.sendVoiceMessage(res.tempFilePath, duration);
        },
        fail: (err) => {
          console.error('停止錄音失敗', err);
        }
      });
    },
    
    // 取消錄音
    cancelRecording() {
      if (this.isRecording) {
        this.isRecording = false;
        uni.stopRecord();
      }
    },
    
    // 發送語音訊息
    async sendVoiceMessage(filePath, duration) {
      try {
        // 上傳語音檔案
        const uploadRes = await this.uploadFile(filePath);
        
        const messageId = this.generateMessageId();
        const message = {
          id: messageId,
          chatId: this.chatId,
          senderId: this.currentUserId,
          senderAvatar: this.userInfo.avatar,
          type: 'voice',
          content: uploadRes.url,
          duration: duration,
          timestamp: Date.now(),
          status: 'sending'
        };
        
        this.messages.push(message);
        this.$nextTick(() => {
          this.scrollToBottom();
        });
        
        socketManager.send('sendMessage', message);
        message.status = 'sent';
        
      } catch (error) {
        console.error('發送語音訊息失敗', error);
        uni.showToast({
          title: '發送失敗',
          icon: 'none'
        });
      }
    },
    
    // 選擇圖片
    chooseImage() {
      uni.chooseImage({
        count: 9,
        success: (res) => {
          res.tempFilePaths.forEach(filePath => {
            this.sendImageMessage(filePath);
          });
        }
      });
      this.showMore = false;
    },
    
    // 發送圖片訊息
    async sendImageMessage(filePath) {
      try {
        const uploadRes = await this.uploadFile(filePath);
        
        const messageId = this.generateMessageId();
        const message = {
          id: messageId,
          chatId: this.chatId,
          senderId: this.currentUserId,
          senderAvatar: this.userInfo.avatar,
          type: 'image',
          content: uploadRes.url,
          timestamp: Date.now(),
          status: 'sending'
        };
        
        this.messages.push(message);
        this.$nextTick(() => {
          this.scrollToBottom();
        });
        
        socketManager.send('sendMessage', message);
        message.status = 'sent';
        
      } catch (error) {
        console.error('發送圖片失敗', error);
        uni.showToast({
          title: '發送失敗',
          icon: 'none'
        });
      }
    },
    
    // 上傳檔案
    uploadFile(filePath) {
      return new Promise((resolve, reject) => {
        uni.uploadFile({
          url: this.$api.upload.uploadFile,
          filePath: filePath,
          name: 'file',
          header: {
            'Authorization': `Bearer ${this.$store.state.user.token}`
          },
          success: (res) => {
            const data = JSON.parse(res.data);
            if (data.code === 200) {
              resolve(data.data);
            } else {
              reject(new Error(data.message));
            }
          },
          fail: reject
        });
      });
    },
    
    // 標記訊息為已讀
    markMessagesAsRead() {
      const unreadMessages = this.messages.filter(m => 
        m.senderId !== this.currentUserId && m.status !== 'read'
      );
      
      if (unreadMessages.length > 0) {
        socketManager.send('markAsRead', {
          chatId: this.chatId,
          messageIds: unreadMessages.map(m => m.id)
        });
      }
    },
    
    // 標記單個訊息為已讀
    markMessageAsRead(messageId) {
      socketManager.send('markAsRead', {
        chatId: this.chatId,
        messageIds: [messageId]
      });
    },
    
    // 滾動到底部
    scrollToBottom() {
      this.$nextTick(() => {
        this.scrollTop = 999999;
      });
    },
    
    // 載入更多訊息
    loadMoreMessages() {
      if (!this.loadingMore && this.hasMoreMessages) {
        this.loadMessages(true);
      }
    },
    
    // 生成訊息ID
    generateMessageId() {
      return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
    },
    
    // 格式化訊息時間
    formatMessageTime(timestamp) {
      const date = new Date(timestamp);
      const now = new Date();
      
      if (date.toDateString() === now.toDateString()) {
        return date.toLocaleTimeString('zh-TW', { 
          hour: '2-digit', 
          minute: '2-digit' 
        });
      } else {
        return date.toLocaleDateString('zh-TW', {
          month: 'short',
          day: 'numeric',
          hour: '2-digit',
          minute: '2-digit'
        });
      }
    },
    
    // 是否顯示時間
    shouldShowTime(message, index) {
      if (index === 0) return true;
      
      const prevMessage = this.messages[index - 1];
      const timeDiff = message.timestamp - prevMessage.timestamp;
      
      // 超過5分鐘顯示時間
      return timeDiff > 5 * 60 * 1000;
    },
    
    // 取得訊息氣泡樣式
    getMessageBubbleClass(message) {
      const classes = [];
      
      if (message.senderId === this.currentUserId) {
        classes.push('own-bubble');
      } else {
        classes.push('other-bubble');
      }
      
      if (message.type === 'image') {
        classes.push('image-bubble');
      }
      
      return classes;
    },
    
    // 清理資源
    cleanup() {
      socketManager.off('newMessage', this.handleNewMessage);
      socketManager.off('messageStatusUpdate', this.handleMessageStatusUpdate);
      socketManager.off('userOnlineStatus', this.handleUserOnlineStatus);
    },
    
    // 返回
    goBack() {
      uni.navigateBack();
    }
  }
}
</script>

4. 最佳實踐

4.1 效能最佳化

良好的效能是社交應用成功的關鍵:

  1. 圖片載入最佳化:使用適當的圖片格式和尺寸,實現懶載入
  2. 訊息分頁載入:避免一次載入過多歷史訊息
  3. 虛擬列表:對於長列表使用虛擬滾動技術
  4. 快取策略:合理使用本地儲存快取使用者資料和聊天記錄
js
// 圖片懶載入範例
export default {
  data() {
    return {
      imageObserver: null
    }
  },
  mounted() {
    this.initImageLazyLoad();
  },
  methods: {
    initImageLazyLoad() {
      // 建立 Intersection Observer
      this.imageObserver = uni.createIntersectionObserver(this);
      
      this.imageObserver.relativeToViewport().observe('.lazy-image', (res) => {
        if (res.intersectionRatio > 0) {
          // 圖片進入可視區域,開始載入
          const target = res.target;
          const dataSrc = target.dataset.src;
          
          if (dataSrc) {
            target.src = dataSrc;
            target.removeAttribute('data-src');
            this.imageObserver.unobserve(target);
          }
        }
      });
    }
  }
}

4.2 資料安全

社交應用涉及大量使用者隱私資料,必須重視資料安全:

  1. 資料加密:敏感資料傳輸和儲存加密
  2. 權限控制:細粒度的使用者權限管理
  3. 內容審核:自動化內容審核機制
  4. 隱私設定:完善的隱私設定選項
js
// 表單驗證範例
const validator = {
  // 使用者名稱驗證
  username(value) {
    if (!value) return '使用者名稱不能為空';
    if (value.length < 3) return '使用者名稱至少3個字元';
    if (value.length > 20) return '使用者名稱不能超過20個字元';
    if (!/^[a-zA-Z0-9_\u4e00-\u9fa5]+$/.test(value)) return '使用者名稱只能包含字母、數字、底線和中文';
    return '';
  },
  
  // 密碼驗證
  password(value) {
    if (!value) return '密碼不能為空';
    if (value.length < 6) return '密碼至少6個字元';
    if (value.length > 20) return '密碼不能超過20個字元';
    if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) return '密碼必須包含大小寫字母和數字';
    return '';
  },
  
  // 手機號驗證
  phone(value) {
    if (!value) return '手機號不能為空';
    if (!/^1[3-9]\d{9}$/.test(value)) return '手機號格式不正確';
    return '';
  },
  
  // 信箱驗證
  email(value) {
    if (!value) return '信箱不能為空';
    if (!/^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(value)) return '信箱格式不正確';
    return '';
  }
};

4.3 使用者體驗最佳化

良好的使用者體驗是社交應用成功的關鍵:

  1. 響應式設計:適配不同螢幕尺寸和裝置
  2. 骨架屏:在內容載入過程中顯示骨架屏,減少使用者等待感
  3. 下拉重新整理和上拉載入:提供流暢的列表互動體驗
  4. 狀態回饋:操作後給予及時的狀態回饋
  5. 離線支援:支援基本的離線瀏覽功能
vue
<!-- 骨架屏範例 -->
<template>
  <view class="skeleton-container" v-if="loading">
    <view class="skeleton-item" v-for="i in 5" :key="i">
      <view class="skeleton-avatar"></view>
      <view class="skeleton-content">
        <view class="skeleton-title"></view>
        <view class="skeleton-text"></view>
        <view class="skeleton-text short"></view>
      </view>
    </view>
  </view>
  <view v-else>
    <!-- 實際內容 -->
  </view>
</template>

<style lang="scss">
.skeleton-container {
  padding: 20rpx;
  
  .skeleton-item {
    display: flex;
    padding: 20rpx 0;
    border-bottom: 1rpx solid #f5f5f5;
    
    .skeleton-avatar {
      width: 80rpx;
      height: 80rpx;
      border-radius: 50%;
      background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%);
      background-size: 400% 100%;
      animation: skeleton-loading 1.4s ease infinite;
    }
    
    .skeleton-content {
      flex: 1;
      margin-left: 20rpx;
      
      .skeleton-title {
        height: 32rpx;
        width: 40%;
        background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%);
        background-size: 400% 100%;
        animation: skeleton-loading 1.4s ease infinite;
        margin-bottom: 16rpx;
      }
      
      .skeleton-text {
        height: 24rpx;
        width: 100%;
        background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%);
        background-size: 400% 100%;
        animation: skeleton-loading 1.4s ease infinite;
        margin-bottom: 16rpx;
        
        &.short {
          width: 60%;
        }
      }
    }
  }
}

@keyframes skeleton-loading {
  0% {
    background-position: 100% 50%;
  }
  100% {
    background-position: 0 50%;
  }
}
</style>

5. 總結與拓展

5.1 開發要點總結

  1. 模組化設計:將應用拆分為多個功能模組,提高程式碼可維護性
  2. 狀態管理:使用Vuex集中管理應用狀態,處理複雜的資料流
  3. 即時通訊:基於WebSocket實現即時訊息功能,提供良好的聊天體驗
  4. 效能最佳化:針對大量資料和頻繁網路請求進行最佳化,提高應用回應速度
  5. 安全措施:重視使用者資料安全和隱私保護,實施必要的安全措施

5.2 功能拓展方向

基於社交應用的基礎功能,可以考慮以下拓展方向:

  1. 社群功能:新增興趣小組、話題討論等社群功能
  2. 內容推薦:基於使用者興趣和行為的個人化內容推薦
  3. 直播功能:支援使用者發起和觀看直播
  4. 電商功能:整合電商功能,支援商品展示和交易
  5. AR互動:新增AR濾鏡、特效等互動功能

5.3 商業化思路

社交應用的商業化路徑通常包括:

  1. 廣告變現:基於使用者畫像的精準廣告投放
  2. 會員訂閱:提供進階功能的付費會員服務
  3. 虛擬商品:販售表情包、主題、裝飾等虛擬商品
  4. 電商佣金:透過商品推薦獲得佣金收入
  5. 資料服務:為企業提供匿名化的使用者行為資料分析

5.4 參考資源

透過本案例的學習,您可以掌握使用 uni-app 開發功能完善的社交應用的核心技術和最佳實踐。在實際開發過程中,建議根據具體需求調整架構設計和功能實現,持續最佳化使用者體驗和應用效能。

一次開發,多端部署 - 讓跨平台開發更簡單