Files
nl-im-uniapp/DEVELOPMENT.md
2025-12-09 09:06:06 +08:00

47 KiB
Raw Blame History

NL-IM UniApp 移动端开发手册

基于 nl-im-vue-ts Web 端项目迁移,支持 AppAndroid/iOS和微信小程序

目录


一、项目概述

1.1 技术栈

技术 版本 说明
UniApp 3.0+ 跨平台框架
Vue 3.4.x 前端框架
TypeScript 5.x 类型支持
Pinia 2.2.x 状态管理
wot-design-uni 1.13+ UI 组件库
Vite 5.x 构建工具

1.2 支持平台

平台 状态 说明
Android 支持 App
iOS 支持 App
微信小程序 支持 主要平台
H5 可选 移动端网页

1.3 设计风格

  • UI 参考手机 QQ
  • 支持浅色/暗色两种主题
  • 适配不同屏幕尺寸
  • 遵循各平台设计规范

二、目录结构

nl-im-uniapp/src/
├── api/                        # API 接口层
│   ├── modules/                # 业务模块
│   │   ├── auth.ts             # 认证(登录/注册/验证码)
│   │   ├── user.ts             # 用户(信息/列表)
│   │   ├── contact.ts          # 联系人(好友/分组/申请)
│   │   ├── conversation.ts     # 会话(列表/未读/置顶)
│   │   ├── message.ts          # 消息(发送/历史/同步)
│   │   ├── room.ts             # 群聊(创建/成员/设置)
│   │   ├── moment.ts           # 朋友圈(动态/点赞/评论)
│   │   ├── attachment.ts       # 附件(上传/下载)
│   │   └── system.ts           # 系统(健康检查/配置)
│   ├── request/                # 请求封装
│   │   ├── index.ts            # uni.request 封装
│   │   └── types.ts            # 请求/响应类型
│   └── websocket/              # 实时通讯
│       └── index.ts            # WebSocket 管理器
│
├── components/                 # 公共组件
│   ├── chat/                   # 聊天组件
│   │   ├── MessageBubble.vue   # 消息气泡容器
│   │   ├── MessageInput.vue    # 消息输入框
│   │   ├── MessageList.vue     # 消息列表
│   │   └── bubble/             # 气泡类型
│   │       ├── TextBubble.vue
│   │       ├── ImageBubble.vue
│   │       ├── AudioBubble.vue
│   │       ├── VideoBubble.vue
│   │       └── FileBubble.vue
│   ├── common/                 # 通用组件
│   │   ├── AppNavBar.vue       # 导航栏
│   │   ├── AppTabBar.vue       # 底部标签栏
│   │   ├── AppDrawer.vue       # 侧边抽屉
│   │   ├── AppAvatar.vue       # 头像
│   │   ├── AppSearch.vue       # 搜索框
│   │   ├── AppEmpty.vue        # 空状态
│   │   └── AppLoading.vue      # 加载状态
│   ├── contact/                # 联系人组件
│   │   ├── ContactItem.vue     # 联系人项
│   │   └── ContactGroup.vue    # 联系人分组
│   └── moment/                 # 朋友圈组件
│       ├── MomentCard.vue      # 动态卡片
│       ├── MomentPublisher.vue # 发布器
│       └── MomentComment.vue   # 评论组件
│
├── composables/                # 组合式函数
│   ├── useTheme.ts             # 主题管理
│   ├── useAuth.ts              # 认证状态
│   ├── useSocket.ts            # WebSocket
│   └── useUpload.ts            # 文件上传
│
├── pages/                      # 页面
│   ├── index/                  # 首页(会话)
│   │   └── index.vue
│   ├── chat/                   # 聊天
│   │   ├── index.vue           # 聊天详情
│   │   └── group-info.vue      # 群信息
│   ├── contact/                # 联系人
│   │   ├── index.vue           # 联系人列表
│   │   ├── detail.vue          # 好友详情
│   │   ├── add.vue             # 添加好友
│   │   ├── requests.vue        # 好友申请
│   │   └── groups.vue          # 群聊列表
│   ├── moment/                 # 朋友圈
│   │   ├── index.vue           # 动态列表
│   │   ├── detail.vue          # 动态详情
│   │   ├── publish.vue         # 发布动态
│   │   └── notify.vue          # 消息通知
│   ├── login/                  # 登录
│   │   └── index.vue
│   ├── profile/                # 个人中心
│   │   ├── index.vue           # 个人资料
│   │   └── edit.vue            # 编辑资料
│   ├── settings/               # 设置
│   │   └── index.vue
│   └── search/                 # 搜索
│       └── index.vue
│
├── stores/                     # Pinia Store
│   ├── auth.ts                 # 认证
│   ├── chat.ts                 # 聊天
│   ├── contact.ts              # 联系人
│   ├── conversation.ts         # 会话
│   ├── moment.ts               # 朋友圈
│   └── app.ts                  # 全局应用
│
├── types/                      # 类型定义
│   ├── api.ts                  # API 类型
│   ├── conversation.ts         # 会话类型
│   ├── message.ts              # 消息类型
│   ├── moment.ts               # 朋友圈类型
│   └── user.ts                 # 用户类型
│
├── utils/                      # 工具函数
│   ├── storage.ts              # 本地存储
│   ├── format.ts               # 格式化
│   ├── message.ts              # 消息处理
│   └── platform.ts             # 平台判断
│
├── styles/                     # 样式
│   ├── variables.scss          # CSS 变量
│   ├── theme.scss              # 主题定义
│   └── common.scss             # 公共样式
│
├── static/                     # 静态资源
│   ├── images/
│   └── icons/
│
├── App.vue                     # 根组件
├── main.ts                     # 入口文件
├── pages.json                  # 页面配置
├── manifest.json               # 应用配置
├── theme.json                  # 主题配置
└── uni.scss                    # uni 变量

三、页面设计QQ 风格)

3.1 页面路由配置

// pages.json
{
  "pages": [
    { "path": "pages/index/index", "style": { "navigationStyle": "custom" } },
    { "path": "pages/chat/index", "style": { "navigationBarTitleText": "聊天" } },
    { "path": "pages/contact/index", "style": { "navigationStyle": "custom" } },
    { "path": "pages/moment/index", "style": { "navigationStyle": "custom" } },
    { "path": "pages/login/index", "style": { "navigationBarTitleText": "登录" } }
  ],
  "tabBar": {
    "list": [
      { "pagePath": "pages/index/index", "text": "消息", "iconPath": "...", "selectedIconPath": "..." },
      { "pagePath": "pages/contact/index", "text": "联系人", "iconPath": "...", "selectedIconPath": "..." },
      { "pagePath": "pages/moment/index", "text": "朋友圈", "iconPath": "...", "selectedIconPath": "..." }
    ]
  }
}

3.2 首页(会话页)

路径: pages/index/index.vue

┌─────────────────────────────────────┐
│  [头像]     消息           [+]      │  ← 自定义导航栏
├─────────────────────────────────────┤
│  🔍 搜索                            │  ← 搜索框
├─────────────────────────────────────┤
│  ┌─────────────────────────────┐    │
│  │ [头像] 张三          14:30  │    │
│  │        你好,在吗?    [3]  │    │  ← 会话项
│  └─────────────────────────────┘    │
│  ┌─────────────────────────────┐    │
│  │ [头像] 工作群        昨天   │    │
│  │        李四: 收到      🔇   │    │
│  └─────────────────────────────┘    │
│               ...                    │
├─────────────────────────────────────┤
│    消息      联系人      朋友圈     │  ← TabBar
└─────────────────────────────────────┘

功能点:

  • 点击头像 → 弹出左侧抽屉
  • 点击加号 → 显示菜单(创建群聊/添加好友/扫一扫)
  • 点击搜索 → 跳转搜索页
  • 长按会话 → 显示操作菜单(置顶/删除/免打扰)
  • 左滑会话 → 快捷操作

左侧抽屉内容:

┌────────────────────┐
│      [大头像]      │
│       昵称         │
│     个性签名       │
├────────────────────┤
│  👤  个人资料      │
│  ⚙️  设置          │
│  🌙  深色模式 [开关]│
├────────────────────┤
│  📤  退出登录      │
└────────────────────┘

3.3 聊天页

路径: pages/chat/index.vue

┌─────────────────────────────────────┐
│  [<]  张三 / 工作群(10)       [···] │  ← 导航栏(群聊显示成员数和更多按钮)
├─────────────────────────────────────┤
│                                     │
│          ┌──────────┐               │
│          │  你好    │  14:30        │  ← 对方消息(左侧)
│          └──────────┘               │
│                                     │
│                    ┌──────────┐     │
│           14:31    │  在的    │     │  ← 我的消息(右侧)
│                    └──────────┘     │
│                                     │
│              系统消息               │  ← 系统/通知消息(居中)
│                                     │
├─────────────────────────────────────┤
│  [😊] [📷] [📁]  输入消息...  [发送] │  ← 输入栏
└─────────────────────────────────────┘

消息类型展示:

类型 展示方式
文本 气泡+文字
图片 缩略图,点击预览
语音 播放条+时长
视频 封面+播放按钮
文件 图标+文件名+大小
系统 居中灰色文字

3.4 联系人页

路径: pages/contact/index.vue

┌─────────────────────────────────────┐
│           联系人                    │
├─────────────────────────────────────┤
│  🔍 搜索                            │
├─────────────────────────────────────┤
│  ┌─────────────────────────────┐    │
│  │ [👥] 新朋友              [2] │    │  ← 功能入口
│  │ [👪] 群聊                    │    │
│  │ [🔔] 群通知              [1] │    │
│  └─────────────────────────────┘    │
├─────────────────────────────────────┤
│  A ─────────────────────────        │
│  │ [头像] 阿明                      │  ← 按字母分组
│  │ [头像] 阿强                      │
│  B ─────────────────────────        │
│  │ [头像] 白云                      │
│               ...                    │  ← 右侧字母索引
└─────────────────────────────────────┘

3.5 朋友圈页

路径: pages/moment/index.vue

┌─────────────────────────────────────┐
│                             朋友圈   │
├─────────────────────────────────────┤
│  ┌─────────────────────────────┐    │
│  │       [封面背景图]          │    │
│  │                     [头像]  │    │  ← 个人封面区
│  │                       昵称  │    │
│  └─────────────────────────────┘    │
├─────────────────────────────────────┤
│  ┌─────────────────────────────┐    │
│  │ [头像] 张三       2小时前   │    │
│  │ 今天天气真好!              │    │
│  │ [图片] [图片] [图片]        │    │  ← 动态卡片
│  │ ────────────────────        │    │
│  │ ❤️ 李四、王五               │    │
│  │ 李四: 确实不错              │    │
│  │ [评论输入...]         [❤️]  │    │
│  └─────────────────────────────┘    │
│               ...                    │
│                              [📷]   │  ← 悬浮发布按钮
└─────────────────────────────────────┘

四、API 接口文档

基础路径: /api,所有接口返回统一格式 { code: 0, message: "", result: {} }

4.1 认证模块 /auth

接口 方法 路径 参数 说明
登录 POST /login { account, password, remember? } 返回 token 和用户信息
注册 POST /register { email, phone, password, confirm_password, code, agree_terms } -
发送邮箱验证码 POST /send-email-code { target, type: 'email' } -
发送短信验证码 POST /send-sms-code { target, type: 'sms' } -
检查Token GET /check-token - 验证登录状态

4.2 用户模块 /user

接口 方法 路径 参数 说明
获取我的信息 GET /user/my-info - -
获取用户列表 GET /user/list ?page=1&page_size=20 分页
更新用户信息 POST /user/update { id, updates: {...} } -

4.3 联系人模块 /contacts

接口 方法 路径 参数 说明
获取联系人列表 GET /contacts - -
搜索用户 GET /contacts/search ?keyword=xxx&limit=20 -
添加好友 POST /contacts/add-friend { to_user_id, message? } 发送申请
获取好友申请 GET /contacts/friend-requests - -
接受申请 POST /contacts/accept-request { request_id } -
拒绝申请 POST /contacts/reject-request { request_id } -
获取好友详情 GET /contacts/{id} - -
更新好友信息 POST /contacts/update/{id} { remark_name?, is_top?, is_muted?, ... } -
删除好友 POST /contacts/delete/{id} - -
获取分组列表 GET /contacts/groups - -
创建分组 POST /contacts/groups { group_name } -

4.4 会话模块 /conversations

接口 方法 路径 参数 说明
获取会话列表 GET /conversations - -
重置未读数 POST /conversations/reset-unread { target_id } -
更新会话 POST /conversations/update { target_id, is_top?, is_muted? } -
删除会话 POST /conversations/delete { target_id } -
按房间获取会话 GET /conversations/by-room/{roomId} - -

4.5 消息模块 /messages

接口 方法 路径 参数 说明
发送消息 POST /send { room_id, message_type, content, duration?, extra? } -
获取历史消息 GET /messages ?room_id=xxx&page=1&page_size=50 -
同步消息 GET /messages/sync ?room_id=xxx&page=1&page_size=50 -

4.6 群聊模块 /groups

接口 方法 路径 参数 说明
获取群列表 GET /groups - 带分组信息
创建群聊 POST /groups { name, avatar?, member_ids, admin_ids? } -
获取群信息 GET /groups/{roomId} - -
获取群成员 GET /groups/{roomId}/members ?keyword=xxx 可搜索
邀请成员 POST /groups/{roomId}/members { member_ids: [] } -
移除成员 POST /groups/{roomId}/members/{userId}/remove - -
修改群信息 POST /groups/{roomId}/update { name?, avatar? } -
修改成员角色 POST /groups/{roomId}/members/{userId}/role { role: 0|1|2 } 0成员/1管理/2群主
退出群聊 POST /groups/{roomId}/quit - -
解散群聊 POST /groups/{roomId}/dissolve - 仅群主
获取群公告 GET /groups/{roomId}/announcement - -
更新群公告 POST /groups/{roomId}/announcement { announcement } -
群通知列表 GET /group-notifications ?page=1&page_size=20 -

4.7 朋友圈模块 /moments

接口 方法 路径 参数 说明
获取动态列表 GET /moments ?page=1&page_size=20 -
发布动态 POST /moments { content, media_type, media_urls?, visibility, ... } -
获取动态详情 GET /moments/{id} - -
删除动态 DELETE /moments/{id} - 仅自己
获取用户动态 GET /moments/user/{userId} ?page=1&page_size=20 -
点赞 POST /moments/{id}/like - -
取消点赞 DELETE /moments/{id}/like - -
获取评论 GET /moments/{id}/comments - -
发表评论 POST /moments/{id}/comments { content, reply_to_comment_id? } -
删除评论 DELETE /moments/comments/{commentId} - -
获取通知 GET /moments/notifications ?page=1&page_size=20 -
标记已读 POST /moments/notifications/read { ids?: [], all?: true } -
未读数 GET /moments/notifications/unread-count - -

4.8 附件模块 /attachments

接口 方法 路径 参数 说明
上传附件 POST /attachments/upload FormData: { file, type } multipart
获取附件 GET /attachments/{id} - -
附件列表 GET /attachments ?type=xxx&page=1&page_size=20 -
删除附件 POST /attachments/delete/{id} - -

4.9 系统模块

接口 方法 路径 参数 说明
健康检查 GET /health - -
ICE服务器 GET /ice-servers ?user_id=xxx WebRTC配置
在线状态 GET /check-user-online ?user_id=xxx -
绑定客户端 POST /bind { user_id, client_id } WebSocket

五、类型定义

5.1 基础类型

// types/api.ts

/** 用户 */
interface User {
  id: string
  email: string
  phone: string
  name: string
  avatar: string
  desc: string
  region: string
  created_at: string
  updated_at: string
}

/** 联系人 */
interface Contact {
  id: string
  user_id: string
  contact_user_id: string
  room_id?: string
  room_type?: 'p2p' | 'group'
  is_group?: boolean
  member_count?: number
  owner_id?: string
  remark_name?: string
  group_id?: number
  is_top: boolean
  is_muted: boolean
  is_special_care?: boolean
  is_blocked?: boolean
  user?: User
}

/** 好友申请 */
interface FriendRequest {
  id: number
  from_user_id: string
  to_user_id: string
  message: string
  status: 'pending' | 'accepted' | 'rejected'
  created_at: string
  from_user?: User
  to_user?: User
}

/** 分页响应 */
interface PaginatedResponse<T> {
  data: T[]
  total: number
  page: number
  size: number
}

5.2 会话类型

// types/conversation.ts

interface Conversation {
  id: number
  user_id: string
  target_id: string
  type: number                 // 1:私聊 2:群聊
  room_type?: 'p2p' | 'group'
  room_id?: string
  is_group?: boolean
  member_count?: number
  owner_id?: string
  name?: string
  avatar?: string
  unread_count: number
  is_top: boolean
  is_muted: boolean
  is_special_care?: boolean
  last_message: string
  last_time: number
  target_user?: User
}

5.3 消息类型

// types/message.ts

/** 消息类型枚举 */
enum MessageType {
  TEXT = 0,           // 文本
  IMAGE = 1,          // 图片
  AUDIO = 2,          // 语音
  VIDEO = 3,          // 视频
  SYSTEM = 4,         // 系统消息
  FRIEND_NOTIFY = 5,  // 好友通知
  SIGNAL = 6,         // WebRTC信令
  GROUP_NOTIFY = 7,   // 群通知
  FILE = 8,           // 文件
  MOMENTS_NOTIFY = 9, // 朋友圈通知
}

/** 聊天消息 */
interface ChatMessage {
  id: number
  room_id: string
  sender_user_id: string
  receiver_user_id?: string
  message_type: number
  content: string
  duration: number
  extra?: string | Record<string, any>
  created_at: string
  isSelf?: boolean     // 前端标记
}

/** 消息扩展信息 */
interface MessageExtra {
  url?: string         // 媒体URL
  name?: string        // 文件名
  size?: number        // 文件大小
  duration?: number    // 时长(秒)
  width?: number       // 图片/视频宽
  height?: number      // 图片/视频高
  attachment_id?: number
}

/** 发送消息请求 */
interface SendMessageRequest {
  room_id: string
  message_type: number
  content: string
  duration?: number
  extra?: string
}

5.4 朋友圈类型

// types/moment.ts

/** 动态 */
interface Moment {
  id: number
  user_id: string
  content: string
  media_type: 0 | 1 | 2          // 0=纯文字 1=图片 2=视频
  media_urls: string[]
  location?: string
  visibility: 0 | 1 | 2 | 3      // 0=公开 1=仅好友 2=部分可见 3=部分不可见
  visible_user_ids?: string[]
  mention_user_ids?: string[]
  like_count: number
  comment_count: number
  created_at: string
  user?: User
  is_liked: boolean
  likes?: MomentLike[]
  comments?: MomentComment[]
}

/** 点赞 */
interface MomentLike {
  id: number
  moment_id: number
  user_id: string
  created_at: string
  user?: User
}

/** 评论 */
interface MomentComment {
  id: number
  moment_id: number
  user_id: string
  reply_to_comment_id?: number
  reply_to_user_id?: string
  content: string
  created_at: string
  user?: User
  reply_to_user?: User
}

/** 通知 */
interface MomentNotification {
  id: number
  user_id: string
  from_user_id: string
  moment_id: number
  type: 1 | 2 | 3 | 4            // 1=点赞 2=评论 3=回复 4=@提及
  comment_id?: number
  is_read: boolean
  created_at: string
  from_user?: User
  moment?: Moment
}

六、状态管理Pinia Store

6.1 Auth Store

// stores/auth.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as authApi from '@/api/modules/auth'
import * as userApi from '@/api/modules/user'
import { storage } from '@/utils/storage'
import type { User, LoginRequest } from '@/types/api'

export const useAuthStore = defineStore('auth', () => {
  const token = ref<string | null>(storage.getToken())
  const user = ref<User | null>(storage.getUserInfo())
  const isAuthenticated = ref(!!token.value)

  async function login(data: LoginRequest) {
    const res = await authApi.login(data)
    token.value = res.token
    user.value = res.user
    isAuthenticated.value = true
    storage.setToken(res.token)
    storage.setUserId(res.user.id)
    storage.setUserInfo(res.user)
    return res
  }

  async function register(data: any) {
    await authApi.register(data)
  }

  function logout() {
    token.value = null
    user.value = null
    isAuthenticated.value = false
    storage.clear()
  }

  async function checkAuth() {
    if (!token.value) return false
    try {
      await authApi.checkToken()
      const userInfo = await userApi.getMyInfo()
      user.value = userInfo
      storage.setUserInfo(userInfo)
      return true
    } catch {
      logout()
      return false
    }
  }

  function updateUserInfo(userInfo: User) {
    user.value = userInfo
    storage.setUserInfo(userInfo)
  }

  return {
    token, user, isAuthenticated,
    login, register, logout, checkAuth, updateUserInfo
  }
})

6.2 Conversation Store

// stores/conversation.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Conversation } from '@/types/conversation'
import * as conversationApi from '@/api/modules/conversation'

export const useConversationStore = defineStore('conversation', () => {
  const conversations = ref<Conversation[]>([])
  const loading = ref(false)

  const totalUnread = computed(() =>
    conversations.value.reduce((acc, c) => acc + (c.is_muted ? 0 : c.unread_count), 0)
  )

  async function loadConversations() {
    loading.value = true
    try {
      const list = await conversationApi.getConversationList()
      conversations.value = list.map(processConversation).sort(sortByTopAndTime)
    } finally {
      loading.value = false
    }
  }

  function handleMessageUpdate(message: ChatMessage, isSelf: boolean, isCurrentChat: boolean) {
    // 更新会话最后消息和未读数
  }

  async function clearUnread(targetId: string) {
    const conv = conversations.value.find(c => c.target_id === targetId || c.room_id === targetId)
    if (conv && conv.unread_count > 0) {
      conv.unread_count = 0
      await conversationApi.resetUnread(targetId)
    }
  }

  return {
    conversations, loading, totalUnread,
    loadConversations, handleMessageUpdate, clearUnread
  }
})

6.3 Chat Store

// stores/chat.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ChatMessage, Contact } from '@/types/api'

export const useChatStore = defineStore('chat', () => {
  const currentTarget = ref<Contact | null>(null)
  const messages = ref<Record<string, ChatMessage[]>>({})
  const contacts = ref<Contact[]>([])

  const totalUnread = computed(() =>
    contacts.value.reduce((acc, c) => acc + (c.unread || 0), 0)
  )

  function setCurrentTarget(contact: Contact | null) {
    currentTarget.value = contact
    if (contact) contact.unread = 0
  }

  function addMessage(roomId: string, message: ChatMessage) {
    if (!messages.value[roomId]) messages.value[roomId] = []
    messages.value[roomId].push(message)
  }

  function getRoomMessages(roomId: string): ChatMessage[] {
    return messages.value[roomId] || []
  }

  function setRoomMessages(roomId: string, msgs: ChatMessage[]) {
    messages.value[roomId] = msgs
  }

  function setContacts(newContacts: Contact[]) {
    contacts.value = newContacts
  }

  return {
    currentTarget, messages, contacts, totalUnread,
    setCurrentTarget, addMessage, getRoomMessages, setRoomMessages, setContacts
  }
})

6.4 Moment Store

// stores/moment.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as momentApi from '@/api/modules/moment'
import type { Moment, MomentNotification } from '@/types/moment'

export const useMomentStore = defineStore('moment', () => {
  const moments = ref<Moment[]>([])
  const currentMoment = ref<Moment | null>(null)
  const notifications = ref<MomentNotification[]>([])
  const unreadCount = ref(0)
  const loading = ref(false)
  const pagination = ref({ page: 1, pageSize: 20, total: 0, hasMore: true })

  const hasUnread = computed(() => unreadCount.value > 0)

  async function fetchMoments() {
    loading.value = true
    try {
      const res = await momentApi.getMoments(1, pagination.value.pageSize)
      moments.value = res.data || []
      pagination.value.total = res.total
      pagination.value.hasMore = moments.value.length < res.total
    } finally {
      loading.value = false
    }
  }

  async function toggleLike(momentId: number) {
    const moment = moments.value.find(m => m.id === momentId)
    if (!moment) return
    if (moment.is_liked) {
      await momentApi.unlikeMoment(momentId)
      moment.is_liked = false
      moment.like_count--
    } else {
      await momentApi.likeMoment(momentId)
      moment.is_liked = true
      moment.like_count++
    }
  }

  async function addComment(momentId: number, data: { content: string; reply_to_comment_id?: number }) {
    const comment = await momentApi.createComment(momentId, data)
    const moment = moments.value.find(m => m.id === momentId)
    if (moment) {
      if (!moment.comments) moment.comments = []
      moment.comments.push(comment)
      moment.comment_count++
    }
    return comment
  }

  return {
    moments, currentMoment, notifications, unreadCount, loading, pagination, hasUnread,
    fetchMoments, toggleLike, addComment
  }
})

七、WebSocket 通讯

7.1 WebSocket 管理器

// api/websocket/index.ts
import type { ChatMessage } from '@/types/api'
import type { MomentNotifPayload } from '@/types/moment'

export interface WebSocketMessage {
  request_type?: string
  clientId?: string
  data?: ChatMessage | MomentNotifPayload
}

export type MessageHandler = (message: ChatMessage) => void
export type MomentNotifHandler = (payload: MomentNotifPayload) => void

class WebSocketManager {
  private socketTask: UniApp.SocketTask | null = null
  private clientId: string | null = null
  private userId: string | null = null
  private messageHandlers: MessageHandler[] = []
  private momentNotifHandlers: MomentNotifHandler[] = []
  private reconnectAttempts = 0
  private maxReconnectAttempts = 5
  private reconnectDelay = 3000

  connect(userId: string): Promise<void> {
    return new Promise((resolve, reject) => {
      if (this.socketTask) {
        resolve()
        return
      }

      this.userId = userId
      const wsUrl = `ws://your-server/ws?user_id=${userId}`

      this.socketTask = uni.connectSocket({
        url: wsUrl,
        success: () => console.log('WebSocket connecting...'),
        fail: (err) => reject(err)
      })

      this.socketTask.onOpen(() => {
        console.log('WebSocket connected')
        this.reconnectAttempts = 0
        resolve()
      })

      this.socketTask.onMessage((res) => {
        try {
          const payload: WebSocketMessage = JSON.parse(res.data as string)
          if (payload.clientId) this.clientId = payload.clientId
          if (payload.request_type === 'receive_message' && payload.data) {
            this.messageHandlers.forEach(h => h(payload.data as ChatMessage))
          }
          if (payload.request_type === 'moment_notification' && payload.data) {
            this.momentNotifHandlers.forEach(h => h(payload.data as MomentNotifPayload))
          }
        } catch (e) {
          console.error('Parse message error:', e)
        }
      })

      this.socketTask.onClose(() => {
        console.log('WebSocket closed')
        this.socketTask = null
        this.attemptReconnect()
      })

      this.socketTask.onError((err) => {
        console.error('WebSocket error:', err)
        reject(err)
      })
    })
  }

  disconnect() {
    if (this.socketTask) {
      this.socketTask.close({})
      this.socketTask = null
    }
    this.clientId = null
    this.userId = null
    this.messageHandlers = []
    this.momentNotifHandlers = []
  }

  getClientId(): string | null {
    return this.clientId
  }

  onMessage(handler: MessageHandler) {
    if (!this.messageHandlers.includes(handler)) {
      this.messageHandlers.push(handler)
    }
  }

  offMessage(handler: MessageHandler) {
    const index = this.messageHandlers.indexOf(handler)
    if (index > -1) this.messageHandlers.splice(index, 1)
  }

  onMomentNotification(handler: MomentNotifHandler) {
    if (!this.momentNotifHandlers.includes(handler)) {
      this.momentNotifHandlers.push(handler)
    }
  }

  offMomentNotification(handler: MomentNotifHandler) {
    const index = this.momentNotifHandlers.indexOf(handler)
    if (index > -1) this.momentNotifHandlers.splice(index, 1)
  }

  private attemptReconnect() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts || !this.userId) return
    this.reconnectAttempts++
    setTimeout(() => {
      if (this.userId) this.connect(this.userId).catch(console.error)
    }, this.reconnectDelay)
  }
}

export const wsManager = new WebSocketManager()

八、主题系统

8.1 CSS 变量

// styles/variables.scss
page {
  // 浅色主题(默认)
  --color-primary: #07c160;
  --color-danger: #fa5151;
  --color-warning: #ffc300;
  --color-success: #07c160;

  --bg-page: #ededed;
  --bg-content: #ffffff;
  --bg-hover: #f5f5f5;
  --bg-mask: rgba(0, 0, 0, 0.5);

  --text-primary: #333333;
  --text-secondary: #666666;
  --text-tertiary: #999999;
  --text-placeholder: #cccccc;

  --border-color: #e5e5e5;
  --divider-color: #f0f0f0;

  --nav-bg: #ededed;
  --tabbar-bg: #f7f7f7;
}

page.dark {
  // 暗色主题
  --bg-page: #111111;
  --bg-content: #1e1e1e;
  --bg-hover: #2a2a2a;
  --bg-mask: rgba(0, 0, 0, 0.7);

  --text-primary: #e5e5e5;
  --text-secondary: #a0a0a0;
  --text-tertiary: #666666;
  --text-placeholder: #444444;

  --border-color: #333333;
  --divider-color: #2a2a2a;

  --nav-bg: #1a1a1a;
  --tabbar-bg: #1a1a1a;
}

8.2 主题切换

// composables/useTheme.ts
import { ref, watch } from 'vue'

const isDark = ref(false)

export function useTheme() {
  function initTheme() {
    // 读取缓存
    const cached = uni.getStorageSync('theme')
    if (cached) {
      isDark.value = cached === 'dark'
    } else {
      // 跟随系统
      const info = uni.getSystemInfoSync()
      isDark.value = info.theme === 'dark'
    }
    applyTheme()

    // 监听系统主题变化
    uni.onThemeChange?.((res) => {
      if (!uni.getStorageSync('theme')) {
        isDark.value = res.theme === 'dark'
        applyTheme()
      }
    })
  }

  function toggleTheme() {
    isDark.value = !isDark.value
    uni.setStorageSync('theme', isDark.value ? 'dark' : 'light')
    applyTheme()
  }

  function applyTheme() {
    // 设置页面根元素 class
    // #ifdef H5
    document.documentElement.className = isDark.value ? 'dark' : ''
    // #endif

    // 设置导航栏颜色
    uni.setNavigationBarColor({
      frontColor: isDark.value ? '#ffffff' : '#000000',
      backgroundColor: isDark.value ? '#1a1a1a' : '#ededed'
    })

    // 设置 TabBar 样式
    uni.setTabBarStyle({
      backgroundColor: isDark.value ? '#1a1a1a' : '#f7f7f7',
      borderStyle: isDark.value ? 'black' : 'white'
    })
  }

  return { isDark, initTheme, toggleTheme }
}

8.3 使用示例

<template>
  <view class="container">
    <text class="title">标题</text>
    <text class="desc">描述文字</text>
  </view>
</template>

<style lang="scss" scoped>
.container {
  background: var(--bg-content);
  padding: 20rpx;
}
.title {
  color: var(--text-primary);
  font-size: 32rpx;
}
.desc {
  color: var(--text-secondary);
  font-size: 28rpx;
}
</style>

九、平台适配

9.1 条件编译

// 代码中
// #ifdef APP-PLUS
console.log('这是 App')
// #endif

// #ifdef MP-WEIXIN
console.log('这是微信小程序')
// #endif

// #ifdef H5
console.log('这是 H5')
// #endif

// #ifndef MP-WEIXIN
console.log('不是微信小程序的平台')
// #endif
<!-- 模板中 -->
<!-- #ifdef APP-PLUS -->
<view> App 显示</view>
<!-- #endif -->

<!-- #ifdef MP-WEIXIN -->
<button open-type="share">分享</button>
<!-- #endif -->

9.2 平台工具函数

// utils/platform.ts

/** 是否 App */
export function isApp(): boolean {
  // #ifdef APP-PLUS
  return true
  // #endif
  // #ifndef APP-PLUS
  return false
  // #endif
}

/** 是否微信小程序 */
export function isWeixin(): boolean {
  // #ifdef MP-WEIXIN
  return true
  // #endif
  // #ifndef MP-WEIXIN
  return false
  // #endif
}

/** 选择图片 */
export function chooseImage(count = 9): Promise<string[]> {
  return new Promise((resolve, reject) => {
    uni.chooseImage({
      count,
      sizeType: ['compressed'],
      sourceType: ['album', 'camera'],
      success: (res) => resolve(res.tempFilePaths),
      fail: reject
    })
  })
}

/** 选择文件 */
export function chooseFile(): Promise<UniApp.ChooseFileSuccessCallbackResult> {
  return new Promise((resolve, reject) => {
    // #ifdef APP-PLUS
    uni.chooseFile({
      count: 1,
      success: resolve,
      fail: reject
    })
    // #endif

    // #ifdef MP-WEIXIN
    wx.chooseMessageFile({
      count: 1,
      type: 'file',
      success: resolve,
      fail: reject
    })
    // #endif
  })
}

/** 扫码 */
export function scanCode(): Promise<string> {
  return new Promise((resolve, reject) => {
    uni.scanCode({
      onlyFromCamera: false,
      success: (res) => resolve(res.result),
      fail: reject
    })
  })
}

9.3 存储封装

// utils/storage.ts
const KEYS = {
  TOKEN: 'nl_im_token',
  USER_ID: 'nl_im_user_id',
  USER_INFO: 'nl_im_user_info',
  THEME: 'nl_im_theme',
}

export const storage = {
  // Token
  setToken(token: string) {
    uni.setStorageSync(KEYS.TOKEN, token)
  },
  getToken(): string | null {
    return uni.getStorageSync(KEYS.TOKEN) || null
  },
  removeToken() {
    uni.removeStorageSync(KEYS.TOKEN)
  },

  // User ID
  setUserId(id: string) {
    uni.setStorageSync(KEYS.USER_ID, id)
  },
  getUserId(): string | null {
    return uni.getStorageSync(KEYS.USER_ID) || null
  },

  // User Info
  setUserInfo(info: any) {
    uni.setStorageSync(KEYS.USER_INFO, JSON.stringify(info))
  },
  getUserInfo(): any | null {
    const str = uni.getStorageSync(KEYS.USER_INFO)
    return str ? JSON.parse(str) : null
  },

  // Theme
  setTheme(theme: 'light' | 'dark') {
    uni.setStorageSync(KEYS.THEME, theme)
  },
  getTheme(): 'light' | 'dark' | null {
    return uni.getStorageSync(KEYS.THEME) || null
  },

  // Clear all
  clear() {
    Object.values(KEYS).forEach(key => uni.removeStorageSync(key))
  }
}

十、UI 组件wot-design-uni

10.1 常用组件

组件 用途 示例
wd-button 按钮 <wd-button type="primary">提交</wd-button>
wd-input 输入框 <wd-input v-model="value" placeholder="请输入" />
wd-cell 单元格 <wd-cell title="标题" value="内容" />
wd-popup 弹出层 <wd-popup v-model="show" position="bottom">...</wd-popup>
wd-toast 轻提示 uni.$wot.toast('提示内容')
wd-dialog 对话框 uni.$wot.confirm({ title: '提示', msg: '确定删除?' })
wd-action-sheet 动作面板 底部弹出选项
wd-search 搜索框 <wd-search v-model="keyword" />
wd-badge 徽标 <wd-badge :value="99" />
wd-icon 图标 <wd-icon name="chat" />
wd-image 图片 支持懒加载、预览
wd-swipe-action 滑动操作 左滑删除等

10.2 自动导入配置

// vite.config.ts
import Components from '@uni-helper/vite-plugin-uni-components'
import { WotResolver } from '@uni-helper/vite-plugin-uni-components/resolvers'

export default defineConfig({
  plugins: [
    Components({
      resolvers: [WotResolver()]
    })
  ]
})

十一、工具函数

11.1 格式化函数

// utils/format.ts

/** 格式化相对时间 */
export function formatTime(timestamp: number | string): string {
  const date = new Date(timestamp)
  const now = new Date()
  const diff = now.getTime() - date.getTime()
  const seconds = Math.floor(diff / 1000)
  const minutes = Math.floor(seconds / 60)
  const hours = Math.floor(minutes / 60)
  const days = Math.floor(hours / 24)

  if (seconds < 60) return '刚刚'
  if (minutes < 60) return `${minutes}分钟前`
  if (hours < 24) return `${hours}小时前`
  if (days === 1) return '昨天'
  if (days < 7) return `${days}天前`

  return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}

/** 格式化文件大小 */
export function formatSize(bytes: number): string {
  if (!bytes) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}

/** 格式化时长(秒转 MM:SS */
export function formatDuration(seconds: number): string {
  const m = Math.floor(seconds / 60)
  const s = seconds % 60
  return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}

11.2 消息处理

// utils/message.ts
import { MessageType } from '@/types/message'
import type { ChatMessage } from '@/types/api'

/** 获取消息摘要 */
export function getMessageSummary(msg: ChatMessage): string {
  switch (msg.message_type) {
    case MessageType.TEXT:
      return msg.content || ''
    case MessageType.IMAGE:
      return '[图片]'
    case MessageType.AUDIO:
      return '[语音]'
    case MessageType.VIDEO:
      return '[视频]'
    case MessageType.FILE:
      return '[文件]'
    case MessageType.SYSTEM:
    case MessageType.GROUP_NOTIFY:
      return msg.content || '[系统消息]'
    default:
      return '[未知消息]'
  }
}

/** 是否系统消息 */
export function isSystemMessage(type: number): boolean {
  return [
    MessageType.SYSTEM,
    MessageType.FRIEND_NOTIFY,
    MessageType.GROUP_NOTIFY
  ].includes(type)
}

十二、开发规范

12.1 命名规范

类型 规范 示例
文件夹 小写连字符 chat-bubble/
页面文件 小写 index.vue, detail.vue
组件文件 PascalCase MessageBubble.vue
变量/函数 camelCase handleSend, isLoading
常量 UPPER_SNAKE MESSAGE_TYPE, API_BASE
类型/接口 PascalCase User, ChatMessage
CSS 类名 BEM 或小写连字符 .message-bubble__content

12.2 组件模板

<template>
  <view class="component-name">
    <!-- 内容 -->
  </view>
</template>

<script setup lang="ts">
/**
 * 组件名称
 * @description 组件描述
 */
import { ref, computed, onMounted } from 'vue'

// Props
interface Props {
  title: string
  disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
  disabled: false
})

// Emits
const emit = defineEmits<{
  click: [id: string]
  change: [value: string]
}>()

// State
const loading = ref(false)

// Computed
const displayTitle = computed(() => props.title.toUpperCase())

// Methods
function handleClick() {
  emit('click', 'some-id')
}

// Lifecycle
onMounted(() => {
  // 初始化
})
</script>

<style lang="scss" scoped>
.component-name {
  // 样式
}
</style>

12.3 Git 提交规范

<type>(<scope>): <subject>

类型:
- feat: 新功能
- fix: 修复 bug
- docs: 文档变更
- style: 代码格式(不影响功能)
- refactor: 重构
- perf: 性能优化
- test: 测试
- chore: 构建/工具变动

示例:
feat(chat): 添加语音消息发送功能
fix(contact): 修复好友列表不刷新问题
docs: 更新开发手册

十三、快速开始

13.1 环境准备

# 安装 Node.js 18+
# 安装 pnpm
npm install -g pnpm

# 安装 HBuilderX可选用于 App 开发)

13.2 项目启动

# 进入项目
cd nl-im-uniapp

# 安装依赖
pnpm install

# 开发模式
pnpm dev              # H5
pnpm dev:mp-weixin    # 微信小程序
pnpm dev:app          # App

# 构建
pnpm build:mp-weixin  # 微信小程序
pnpm build:app        # App

13.3 微信小程序调试

  1. 运行 pnpm dev:mp-weixin
  2. 打开微信开发者工具
  3. 导入 dist/dev/mp-weixin 目录
  4. 填写 AppID

13.4 App 调试

  1. 运行 pnpm dev:app
  2. 打开 HBuilderX
  3. 运行到手机或模拟器

十四、功能清单

已规划功能

模块 功能 优先级 状态
基础 项目框架搭建 P0 待开发
基础 主题系统(浅色/暗色) P0 待开发
基础 API 请求封装 P0 待开发
基础 WebSocket 适配 P0 待开发
基础 Pinia Store P0 待开发
登录 登录/注册页 P0 待开发
首页 会话列表 P0 待开发
首页 左侧抽屉 P1 待开发
首页 加号菜单 P1 待开发
聊天 聊天详情页 P0 待开发
聊天 多类型消息展示 P0 待开发
聊天 消息输入框 P0 待开发
聊天 图片/文件发送 P1 待开发
聊天 语音消息 P2 待开发
联系人 联系人列表 P0 待开发
联系人 好友详情 P1 待开发
联系人 添加好友 P1 待开发
联系人 好友申请 P1 待开发
群聊 群聊列表 P1 待开发
群聊 创建群聊 P1 待开发
群聊 群信息/成员管理 P2 待开发
朋友圈 动态列表 P1 待开发
朋友圈 发布动态 P1 待开发
朋友圈 点赞/评论 P1 待开发
朋友圈 消息通知 P2 待开发
设置 个人资料 P1 待开发
设置 设置页 P2 待开发
高级 消息推送 P2 待开发
高级 语音/视频通话 P3 待开发

附录

A. 相关资源

B. 后端 API 文档

请参考 nl-im-vue-ts 项目或后端接口文档。

C. 更新日志

日期 版本 更新内容
2024-12-08 v0.1.0 初始化开发手册