Files
nl-im-uniapp/DEVELOPMENT.md

1618 lines
47 KiB
Markdown
Raw Normal View History

2025-12-09 09:06:06 +08:00
# NL-IM UniApp 移动端开发手册
> 基于 nl-im-vue-ts Web 端项目迁移,支持 AppAndroid/iOS和微信小程序
## 目录
- [一、项目概述](#一项目概述)
- [二、目录结构](#二目录结构)
- [三、页面设计](#三页面设计qq-风格)
- [四、API 接口](#四api-接口文档)
- [五、类型定义](#五类型定义)
- [六、状态管理](#六状态管理pinia-store)
- [七、WebSocket](#七websocket-通讯)
- [八、主题系统](#八主题系统)
- [九、平台适配](#九平台适配)
- [十、UI 组件](#十ui-组件wot-design-uni)
- [十一、工具函数](#十一工具函数)
- [十二、开发规范](#十二开发规范)
- [十三、快速开始](#十三快速开始)
- [十四、功能清单](#十四功能清单)
---
## 一、项目概述
### 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 页面路由配置
```json
// 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 基础类型
```typescript
// 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 会话类型
```typescript
// 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 消息类型
```typescript
// 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 朋友圈类型
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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 管理器
```typescript
// 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 变量
```scss
// 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 主题切换
```typescript
// 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 使用示例
```vue
<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 条件编译
```typescript
// 代码中
// #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
```
```vue
<!-- 模板中 -->
<!-- #ifdef APP-PLUS -->
<view>仅 App 显示</view>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<button open-type="share">分享</button>
<!-- #endif -->
```
### 9.2 平台工具函数
```typescript
// 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 存储封装
```typescript
// 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 自动导入配置
```typescript
// 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 格式化函数
```typescript
// 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 消息处理
```typescript
// 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 组件模板
```vue
<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 环境准备
```bash
# 安装 Node.js 18+
# 安装 pnpm
npm install -g pnpm
# 安装 HBuilderX可选用于 App 开发)
```
### 13.2 项目启动
```bash
# 进入项目
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. 相关资源
- [UniApp 官方文档](https://uniapp.dcloud.net.cn/)
- [wot-design-uni 组件库](https://wot-design-uni.cn/)
- [Pinia 文档](https://pinia.vuejs.org/)
- [Vue 3 文档](https://cn.vuejs.org/)
### B. 后端 API 文档
请参考 `nl-im-vue-ts` 项目或后端接口文档。
### C. 更新日志
| 日期 | 版本 | 更新内容 |
|------|------|----------|
| 2024-12-08 | v0.1.0 | 初始化开发手册 |