初始化

This commit is contained in:
2025-12-09 09:06:06 +08:00
commit 2c3e9b248d
98 changed files with 26883 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
*.local
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
strict-peer-dependencies=false
auto-install-peers=true
shamefully-hoist=true

16
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
"recommendations": [
"vue.volar",
"mrmaoddxxaa.create-uniapp-view",
"uni-helper.uni-helper-vscode",
"uni-helper.uni-app-schemas-vscode",
"uni-helper.uni-highlight-vscode",
"uni-helper.uni-ui-snippets-vscode",
"uni-helper.uni-app-snippets-vscode",
"uni-helper.uni-cloud-snippets-vscode",
"yoavbls.pretty-ts-errors",
"johnsoncodehk.vscode-tsconfig-helper",
"dbaeumer.vscode-eslint",
"wot-ui.wot-ui-intellisense"
]
}

37
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,37 @@
{
"files.associations": {
"pages.json": "jsonc",
"manifest.json": "jsonc"
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
"prettier.enable": false,
"editor.formatOnSave": false,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"nvue",
"uvue",
"html",
"markdown",
"json",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"svelte",
"css",
"less",
"scss",
"pcss",
"postcss"
]
}

1617
DEVELOPMENT.md Normal file

File diff suppressed because it is too large Load Diff

1458
WOT-COMPONENTS.md Normal file

File diff suppressed because it is too large Load Diff

35
components.d.ts vendored Normal file
View File

@@ -0,0 +1,35 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by vite-plugin-uni-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
AppAvatar: typeof import('./src/components/common/AppAvatar.vue')['default']
AppEmpty: typeof import('./src/components/common/AppEmpty.vue')['default']
AppFooter: typeof import('./src/components/AppFooter.vue')['default']
AppLoading: typeof import('./src/components/common/AppLoading.vue')['default']
AppLogos: typeof import('./src/components/AppLogos.vue')['default']
AppNavBar: typeof import('./src/components/common/AppNavBar.vue')['default']
InputEntry: typeof import('./src/components/InputEntry.vue')['default']
MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default']
MessageInput: typeof import('./src/components/chat/MessageInput.vue')['default']
MomentCard: typeof import('./src/components/moment/MomentCard.vue')['default']
WdActionSheet: typeof import('wot-design-uni/components/wd-action-sheet/wd-action-sheet.vue')['default']
WdBadge: typeof import('wot-design-uni/components/wd-badge/wd-badge.vue')['default']
WdButton: typeof import('wot-design-uni/components/wd-button/wd-button.vue')['default']
WdCell: typeof import('wot-design-uni/components/wd-cell/wd-cell.vue')['default']
WdEmpty: typeof import('wot-design-uni/components/wd-empty/wd-empty.vue')['default']
WdIcon: typeof import('wot-design-uni/components/wd-icon/wd-icon.vue')['default']
WdImg: typeof import('wot-design-uni/components/wd-img/wd-img.vue')['default']
WdLoading: typeof import('wot-design-uni/components/wd-loading/wd-loading.vue')['default']
WdMessageBox: typeof import('wot-design-uni/components/wd-message-box/wd-message-box.vue')['default']
WdPopup: typeof import('wot-design-uni/components/wd-popup/wd-popup.vue')['default']
WdSearch: typeof import('wot-design-uni/components/wd-search/wd-search.vue')['default']
WdSwipeAction: typeof import('wot-design-uni/components/wd-swipe-action/wd-swipe-action.vue')['default']
WdSwitch: typeof import('wot-design-uni/components/wd-switch/wd-switch.vue')['default']
WdToast: typeof import('wot-design-uni/components/wd-toast/wd-toast.vue')['default']
}
}

3
eslint.config.js Normal file
View File

@@ -0,0 +1,3 @@
import uniHelper from '@uni-helper/eslint-config'
export default uniHelper()

21
index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="static/logo.svg">
<script>
const coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)')
|| CSS.supports('top: constant(a)'))
document.write(
`<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0${
coverSupport ? ', viewport-fit=cover' : ''}" />`)
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

59
package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "nl-im-uniapp",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "unh dev",
"build": "unh build",
"about": "unh info",
"type-check": "vue-tsc --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4080520251106001",
"@dcloudio/uni-app-harmony": "3.0.0-4080520251106001",
"@dcloudio/uni-app-plus": "3.0.0-4080520251106001",
"@dcloudio/uni-components": "3.0.0-4080520251106001",
"@dcloudio/uni-h5": "3.0.0-4080520251106001",
"@dcloudio/uni-mp-alipay": "3.0.0-4080520251106001",
"@dcloudio/uni-mp-baidu": "3.0.0-4080520251106001",
"@dcloudio/uni-mp-harmony": "3.0.0-4080520251106001",
"@dcloudio/uni-mp-jd": "3.0.0-4080520251106001",
"@dcloudio/uni-mp-kuaishou": "3.0.0-4080520251106001",
"@dcloudio/uni-mp-lark": "3.0.0-4080520251106001",
"@dcloudio/uni-mp-qq": "3.0.0-4080520251106001",
"@dcloudio/uni-mp-toutiao": "3.0.0-4080520251106001",
"@dcloudio/uni-mp-weixin": "3.0.0-4080520251106001",
"@dcloudio/uni-mp-xhs": "3.0.0-4080520251106001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4080520251106001",
"pinia": "2.2.4",
"vue": "3.4.21",
"vue-i18n": "9.6.2",
"vue-router": "4.5.1",
"wot-design-uni": "^1.13.0"
},
"devDependencies": {
"@dcloudio/types": "3.4.19",
"@dcloudio/uni-automator": "3.0.0-4080520251106001",
"@dcloudio/uni-cli-shared": "3.0.0-4080520251106001",
"@dcloudio/uni-stacktracey": "3.0.0-4080520251106001",
"@dcloudio/vite-plugin-uni": "3.0.0-4080520251106001",
"@mini-types/alipay": "^3.0.14",
"@types/node": "^24.10.1",
"@uni-helper/eslint-config": "^0.5.0",
"@uni-helper/plugin-uni": "0.1.0",
"@uni-helper/unh": "^0.2.7",
"@uni-helper/uni-types": "^1.0.0-alpha.7",
"@uni-helper/vite-plugin-uni-components": "^0.2.3",
"@vue/runtime-core": "3.4.21",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.1",
"miniprogram-api-typings": "^4.1.0",
"sass": "1.64.2",
"typescript": "^5.9.3",
"vite": "5.2.8",
"vue-tsc": "^3.1.5"
}
}

12862
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

79
src/App.vue Normal file
View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { onLaunch, onShow } from '@dcloudio/uni-app'
import { useTheme } from '@/composables/useTheme'
import { useAuthStore } from '@/stores'
const { initTheme } = useTheme()
const authStore = useAuthStore()
onLaunch(() => {
console.log('App Launch')
// 初始化主题
initTheme()
})
onShow(() => {
console.log('App Show')
// 检查登录状态
authStore.checkAuth()
})
</script>
<style lang="scss">
@import '@/styles/variables.scss';
@import '@/styles/common.scss';
/* 全局样式 */
page {
background-color: var(--bg-page);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 28rpx;
line-height: 1.5;
// wot-design-uni 主题色覆盖
--wot-color-theme: #07c160;
--wot-color-success: #07c160;
--wot-color-danger: #fa5151;
--wot-color-warning: #ffc300;
}
/* 暗色模式下的 wot-design-uni 组件适配 */
page.dark {
// Cell
--wot-cell-bg-color: var(--bg-content);
--wot-cell-title-color: var(--text-primary);
--wot-cell-label-color: var(--text-secondary);
--wot-cell-value-color: var(--text-tertiary);
--wot-cell-border-color: var(--border-color);
// Input
--wot-input-bg-color: var(--bg-content);
--wot-input-color: var(--text-primary);
--wot-input-placeholder-color: var(--text-placeholder);
// Search
--wot-search-bg-color: var(--search-bg);
// Button
--wot-button-normal-bg-color: var(--bg-content);
--wot-button-normal-color: var(--text-primary);
// Popup
--wot-popup-bg-color: var(--bg-content);
// ActionSheet
--wot-action-sheet-bg-color: var(--bg-content);
// Navbar
--wot-navbar-bg-color: var(--nav-bg);
--wot-navbar-title-color: var(--text-primary);
}
/* 隐藏滚动条 */
::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
</style>

17
src/api/index.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* API 统一导出
*/
export * as authApi from './modules/auth'
export * as userApi from './modules/user'
export * as contactApi from './modules/contact'
export * as conversationApi from './modules/conversation'
export * as messageApi from './modules/message'
export * as roomApi from './modules/room'
export * as momentApi from './modules/moment'
export * as attachmentApi from './modules/attachment'
export * as systemApi from './modules/system'
export { wsManager } from './websocket'
export type { WebSocketMessage, MessageHandler, SignalHandler, MomentNotifHandler } from './websocket'
export { default as request } from './request'

View File

@@ -0,0 +1,25 @@
/**
* 附件管理相关 API
*/
import request from '../request'
import type { Attachment, PaginatedResponse } from '@/types/api'
// 上传附件
export function uploadAttachment(filePath: string, type?: 'image' | 'video' | 'file') {
return request.upload<Attachment>('/attachments/upload', filePath, 'file', type ? { type } : undefined)
}
// 获取附件信息
export function getAttachment(id: number) {
return request.get<Attachment>(`/attachments/${id}`)
}
// 获取附件列表
export function getAttachments(type?: string, page = 1, pageSize = 20) {
return request.get<PaginatedResponse<Attachment>>('/attachments', { type, page, page_size: pageSize })
}
// 删除附件
export function deleteAttachment(id: number) {
return request.post<void>(`/attachments/delete/${id}`)
}

30
src/api/modules/auth.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* 认证相关 API
*/
import request from '../request'
import type { LoginRequest, LoginResponse, RegisterRequest, SendCodeRequest } from '@/types/api'
// 用户登录
export function login(data: LoginRequest) {
return request.post<LoginResponse>('/login', data)
}
// 用户注册
export function register(data: RegisterRequest) {
return request.post<void>('/register', data)
}
// 发送邮箱验证码
export function sendEmailCode(data: SendCodeRequest) {
return request.post<void>('/send-email-code', data)
}
// 发送短信验证码
export function sendSmsCode(data: SendCodeRequest) {
return request.post<void>('/send-sms-code', data)
}
// 检查Token有效性
export function checkToken() {
return request.get<void>('/check-token')
}

View File

@@ -0,0 +1,85 @@
/**
* 联系人管理相关 API
*/
import request from '../request'
import type { Contact, User, FriendRequest, ContactGroup } from '@/types/api'
// 获取联系人列表
export function getContacts() {
return request.get<Contact[]>('/contacts')
}
// 搜索用户
export function searchUsers(keyword: string, limit = 20) {
return request.get<User[]>('/contacts/search', { keyword, limit })
}
// 添加好友(发送申请)
export function addFriend(data: { to_user_id: string; message?: string }) {
return request.post<void>('/contacts/add-friend', data)
}
// 获取好友申请列表
export function getFriendRequests() {
return request.get<FriendRequest[]>('/contacts/friend-requests')
}
// 接受好友申请
export function acceptFriendRequest(requestId: number) {
return request.post<void>('/contacts/accept-request', { request_id: requestId })
}
// 拒绝好友申请
export function rejectFriendRequest(requestId: number) {
return request.post<void>('/contacts/reject-request', { request_id: requestId })
}
// 获取分组列表
export function getGroups() {
return request.get<ContactGroup[]>('/contacts/groups')
}
// 创建分组
export function createGroup(data: { group_name: string }) {
return request.post<ContactGroup>('/contacts/groups', data)
}
// 更新分组
export function updateGroup(id: number, data: { group_name?: string; sort_order?: number }) {
return request.post<ContactGroup>(`/contacts/groups/update/${id}`, data)
}
// 删除分组
export function deleteGroup(id: number) {
return request.post<void>(`/contacts/groups/delete/${id}`)
}
// 获取好友详情
export async function getContactDetail(id: string): Promise<Contact> {
const response = await request.get<{ contact: any; user: User }>(`/contacts/${id}`)
return {
...response.contact,
id: response.contact.id?.toString() || response.contact.contact_id,
user_id: response.contact.contact_id || response.contact.user_id,
contact_user_id: response.contact.contact_id,
room_id: response.contact.room_id,
user: response.user
} as Contact
}
// 更新好友信息
export function updateContact(id: string, data: {
remark_name?: string
group_id?: number
is_top?: boolean
is_muted?: boolean
is_special_care?: boolean
is_blocked?: boolean
}) {
return request.post<Contact>(`/contacts/update/${id}`, data)
}
// 删除好友
export function deleteContact(id: string) {
return request.post<void>(`/contacts/delete/${id}`)
}

View File

@@ -0,0 +1,39 @@
/**
* 会话列表相关 API
*/
import request from '../request'
import type { Conversation } from '@/types/conversation'
// 获取会话列表
export function getConversationList() {
return request.get<Conversation[]>('/conversations')
}
// 重置未读
export function resetUnread(targetId: string) {
return request.post<void>('/conversations/reset-unread', {
target_id: targetId
})
}
// 更新会话标记
export function updateConversation(data: {
target_id: string
is_top?: boolean
is_muted?: boolean
is_special_care?: boolean
}) {
return request.post<void>('/conversations/update', data)
}
// 删除会话
export function deleteConversation(targetId: string) {
return request.post<void>('/conversations/delete', {
target_id: targetId
})
}
// 根据 room_id 获取或创建会话
export function getConversationByRoom(roomId: string) {
return request.get<Conversation>(`/conversations/by-room/${roomId}`)
}

View File

@@ -0,0 +1,28 @@
/**
* 消息管理相关 API
*/
import request from '../request'
import type { ChatMessage, SendMessageRequest, PaginatedResponse } from '@/types/api'
// 发送消息
export function sendMessage(data: SendMessageRequest) {
return request.post<void>('/send', data)
}
// 获取历史消息
export function getMessages(roomId: string, page = 1, pageSize = 50) {
return request.get<PaginatedResponse<ChatMessage>>('/messages', {
room_id: roomId,
page,
page_size: pageSize
})
}
// 同步消息(兼容接口)
export function syncMessages(roomId: string, page = 1, pageSize = 50) {
return request.get<PaginatedResponse<ChatMessage>>('/messages/sync', {
room_id: roomId,
page,
page_size: pageSize
})
}

99
src/api/modules/moment.ts Normal file
View File

@@ -0,0 +1,99 @@
/**
* 朋友圈相关 API
*/
import request from '../request'
import type {
Moment,
MomentComment,
MomentNotification,
CreateMomentRequest,
CreateCommentRequest,
MarkReadRequest,
PaginatedResponse
} from '@/types/moment'
// ==========================================
// 动态相关
// ==========================================
// 发布动态
export function createMoment(data: CreateMomentRequest) {
return request.post<Moment>('/moments', data)
}
// 获取好友动态列表
export function getMoments(page = 1, pageSize = 20) {
return request.get<PaginatedResponse<Moment>>('/moments', { page, page_size: pageSize })
}
// 获取动态详情
export function getMomentDetail(id: number) {
return request.get<Moment>(`/moments/${id}`)
}
// 删除动态
export function deleteMoment(id: number) {
return request.delete(`/moments/${id}`)
}
// 获取指定用户的动态列表
export function getUserMoments(userId: string, page = 1, pageSize = 20) {
return request.get<PaginatedResponse<Moment>>(`/moments/user/${userId}`, { page, page_size: pageSize })
}
// ==========================================
// 点赞相关
// ==========================================
// 点赞动态
export function likeMoment(id: number) {
return request.post(`/moments/${id}/like`)
}
// 取消点赞
export function unlikeMoment(id: number) {
return request.delete(`/moments/${id}/like`)
}
// 获取动态的点赞列表
export function getMomentLikes(momentId: number) {
return request.get<Array<{ id: number; user_id: string; created_at: string }>>(`/moments/${momentId}/likes`)
}
// ==========================================
// 评论相关
// ==========================================
// 获取动态的评论列表
export function getMomentComments(momentId: number) {
return request.get<MomentComment[]>(`/moments/${momentId}/comments`)
}
// 发表评论
export function createComment(momentId: number, data: CreateCommentRequest) {
return request.post<MomentComment>(`/moments/${momentId}/comments`, data)
}
// 删除评论
export function deleteComment(commentId: number) {
return request.delete(`/moments/comments/${commentId}`)
}
// ==========================================
// 通知相关
// ==========================================
// 获取朋友圈通知列表
export function getNotifications(page = 1, pageSize = 20) {
return request.get<PaginatedResponse<MomentNotification>>('/moments/notifications', { page, page_size: pageSize })
}
// 标记通知为已读
export function markNotificationsRead(data: MarkReadRequest) {
return request.post('/moments/notifications/read', data)
}
// 获取未读通知数量
export function getUnreadCount() {
return request.get<{ count: number }>('/moments/notifications/unread-count')
}

151
src/api/modules/room.ts Normal file
View File

@@ -0,0 +1,151 @@
/**
* 房间/群聊管理相关 API
*/
import request from '../request'
import type { Room, GroupInfo, GroupMember } from '@/types/api'
// 创建房间(单聊)
export function createRoom(data: {
room_type: 'p2p' | 'group'
members: string[]
name?: string
avatar?: string
}) {
return request.post<Room>('/rooms', data)
}
// 获取房间信息
export function getRoom(id: string) {
return request.get<Room>(`/rooms/${id}`)
}
/**
* 群聊相关 API
*/
// 创建群聊
export function createGroup(data: {
name: string
avatar?: string
member_ids: string[]
admin_ids?: string[]
}) {
return request.post<GroupInfo>('/groups', data)
}
// 获取用户群聊列表(带分组信息)
export function getUserGroups() {
return request.get<Array<{
room_id: string
room_type: string
room_name: string
room_avatar: string
owner_id: string
creator_id: string
category: 'joined' | 'created' | 'managed'
role: number
last_message_time?: string
last_message?: string
created_at: string
updated_at: string
}>>('/groups')
}
// 获取群信息
export function getGroup(roomId: string) {
return request.get<GroupInfo>(`/groups/${roomId}`)
}
// 获取群成员列表
export function getGroupMembers(roomId: string, keyword?: string) {
return request.get<GroupMember[]>(`/groups/${roomId}/members`, keyword ? { keyword } : undefined)
}
// 邀请成员入群
export function inviteGroupMembers(roomId: string, data: { member_ids: string[] }) {
return request.post<void>(`/groups/${roomId}/members`, data)
}
// 移除群成员
export function removeGroupMember(roomId: string, userId: string) {
return request.post<void>(`/groups/${roomId}/members/${userId}/remove`, {})
}
// 修改群信息
export function updateGroup(roomId: string, data: { name?: string; avatar?: string }) {
return request.post<void>(`/groups/${roomId}/update`, data)
}
// 调整成员角色
export function updateMemberRole(roomId: string, userId: string, data: { role: number }) {
return request.post<void>(`/groups/${roomId}/members/${userId}/role`, data)
}
// 退出群聊
export function quitGroup(roomId: string) {
return request.post<void>(`/groups/${roomId}/quit`, {})
}
// 解散群聊
export function dissolveGroup(roomId: string) {
return request.post<void>(`/groups/${roomId}/dissolve`, {})
}
// 获取群公告
export function getGroupAnnouncement(roomId: string) {
return request.get<{ announcement: string }>(`/groups/${roomId}/announcement`)
}
// 更新群公告
export function updateGroupAnnouncement(roomId: string, announcement: string) {
return request.post<void>(`/groups/${roomId}/announcement`, { announcement })
}
// 获取群通知列表
export function getGroupNotifications(params?: {
page?: number
page_size?: number
is_read?: boolean
}) {
return request.get<{
data: Array<{
id: number
room_id: string
message_type: number
content: string
extra: string | object
is_read?: boolean
created_at: string
}>
total: number
page: number
page_size: number
}>('/group-notifications', params)
}
// 禁言成员
export function muteGroupMember(roomId: string, userId: string, duration: number) {
return request.post<void>(`/groups/${roomId}/members/${userId}/mute`, { duration })
}
// 更新群设置
export function updateGroupSettings(roomId: string, data: {
name?: string
avatar?: string
description?: string
invite_permission?: number
}) {
return request.post<void>(`/groups/${roomId}/settings`, data)
}
// 获取群设置
export function getGroupSettings(roomId: string) {
return request.get<{
name: string
avatar: string
description: string
invite_permission: number
created_at: string
updated_at: string
}>(`/groups/${roomId}/settings`)
}

25
src/api/modules/system.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* 系统接口相关 API
*/
import request from '../request'
import type { ICEServerConfig } from '@/types/api'
// 健康检查
export function healthCheck() {
return request.get<{ status: string; node: string; time: string }>('/health')
}
// 获取ICE服务器配置
export function getIceServers(userId: string) {
return request.get<ICEServerConfig[]>('/ice-servers', { user_id: userId })
}
// 检查用户在线状态
export function checkUserOnline(userId: string) {
return request.get<{ is_online: boolean }>('/check-user-online', { user_id: userId })
}
// 绑定ClientID和UserID
export function bindClient(data: { user_id: string; client_id: string }) {
return request.post<void>('/bind', data)
}

33
src/api/modules/user.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* 用户管理相关 API
*/
import request from '../request'
import type { User, PaginatedResponse } from '@/types/api'
// 获取当前用户信息
export function getMyInfo() {
return request.get<User>('/user/my-info')
}
// 获取用户列表
export function getUserList(page = 1, pageSize = 20) {
return request.get<PaginatedResponse<User>>('/user/list', {
page,
page_size: pageSize
})
}
// 创建用户(管理员功能)
export function createUser(data: Partial<User>) {
return request.post<User>('/user/create', data)
}
// 更新用户信息
export function updateUser(data: { id: string; updates: Partial<User> }) {
return request.post<User>('/user/update', data)
}
// 删除用户(管理员功能)
export function deleteUser(id: string) {
return request.post<void>('/user/delete', { id })
}

264
src/api/request/index.ts Normal file
View File

@@ -0,0 +1,264 @@
/**
* UniApp 请求封装
*/
import type { ApiResponse, RequestConfig } from './types'
import { storage } from '@/utils/storage'
// API 基础路径
const BASE_URL = 'http://127.0.0.1:12080/api'
// 默认超时时间
const TIMEOUT = 30000
/**
* 获取存储的 Token
*/
function getToken(): string | null {
return storage.getToken()
}
/**
* 获取存储的 User ID
*/
function getUserId(): string | null {
return storage.getUserId()
}
/**
* 显示加载提示
*/
function showLoading() {
uni.showLoading({
title: '加载中...',
mask: true
})
}
/**
* 隐藏加载提示
*/
function hideLoading() {
uni.hideLoading()
}
/**
* 显示错误提示
*/
function showError(message: string) {
uni.showToast({
title: message,
icon: 'none',
duration: 3000
})
}
/**
* 请求函数
*/
function request<T = any>(config: RequestConfig): Promise<T> {
return new Promise((resolve, reject) => {
const {
url,
method = 'GET',
data,
params,
header = {},
timeout = TIMEOUT,
showLoading: needLoading = false,
showError: needShowError = true
} = config
// 构建完整 URL
let fullUrl = BASE_URL + url
// 处理 GET 请求参数
if (params && Object.keys(params).length > 0) {
const queryString = Object.entries(params)
.filter(([_, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&')
if (queryString) {
fullUrl += (fullUrl.includes('?') ? '&' : '?') + queryString
}
}
// 设置请求头
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...header
}
// 添加 Token
const token = getToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
// 添加 User ID
const userId = getUserId()
if (userId) {
headers['X-User-ID'] = userId
}
// 显示加载
if (needLoading) {
showLoading()
}
// 发起请求
uni.request({
url: fullUrl,
method,
data,
header: headers,
timeout,
success: (res) => {
if (needLoading) {
hideLoading()
}
const response = res.data as ApiResponse<T>
// 检查 HTTP 状态码
if (res.statusCode !== 200) {
const errorMsg = `请求失败: ${res.statusCode}`
if (needShowError) {
showError(errorMsg)
}
reject(new Error(errorMsg))
return
}
// 检查业务状态码
if (response.code !== 0) {
const errorMsg = response.message || '请求失败'
if (needShowError) {
showError(errorMsg)
}
reject(new Error(errorMsg))
return
}
// 返回结果数据
resolve(response.result)
},
fail: (err) => {
if (needLoading) {
hideLoading()
}
const errorMsg = err.errMsg || '网络请求失败'
if (needShowError) {
showError(errorMsg)
}
reject(new Error(errorMsg))
}
})
})
}
/**
* GET 请求
*/
function get<T = any>(url: string, params?: Record<string, any>, config?: Partial<RequestConfig>): Promise<T> {
return request<T>({
url,
method: 'GET',
params,
...config
})
}
/**
* POST 请求
*/
function post<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<T> {
return request<T>({
url,
method: 'POST',
data,
...config
})
}
/**
* PUT 请求
*/
function put<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<T> {
return request<T>({
url,
method: 'PUT',
data,
...config
})
}
/**
* DELETE 请求
*/
function del<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<T> {
return request<T>({
url,
method: 'DELETE',
data,
...config
})
}
/**
* 上传文件
*/
function upload<T = any>(url: string, filePath: string, name = 'file', formData?: Record<string, any>): Promise<T> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = {}
const token = getToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const userId = getUserId()
if (userId) {
headers['X-User-ID'] = userId
}
uni.uploadFile({
url: BASE_URL + url,
filePath,
name,
formData,
header: headers,
success: (res) => {
if (res.statusCode !== 200) {
reject(new Error(`上传失败: ${res.statusCode}`))
return
}
try {
const response = JSON.parse(res.data) as ApiResponse<T>
if (response.code !== 0) {
reject(new Error(response.message || '上传失败'))
return
}
resolve(response.result)
} catch {
reject(new Error('解析响应失败'))
}
},
fail: (err) => {
reject(new Error(err.errMsg || '上传失败'))
}
})
})
}
export default {
request,
get,
post,
put,
delete: del,
upload
}
export { get, post, put, del as delete, upload, request }

30
src/api/request/types.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* API 请求/响应类型定义
*/
// 统一响应格式
export interface ApiResponse<T = any> {
code: number
message: string
result: T
}
// 分页响应
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
size: number
}
// 请求配置
export interface RequestConfig {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
data?: any
params?: Record<string, any>
header?: Record<string, string>
timeout?: number
showLoading?: boolean
showError?: boolean
}

243
src/api/websocket/index.ts Normal file
View File

@@ -0,0 +1,243 @@
/**
* WebSocket 管理器 - UniApp 适配版
*/
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 SignalHandler = (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 signalHandlers: SignalHandler[] = []
private momentNotifHandlers: MomentNotifHandler[] = []
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectDelay = 3000
private recentMomentNotifs: Set<string> = new Set()
private notifCacheTimeout = 5000
/**
* 连接 WebSocket
*/
connect(userId: string): Promise<void> {
return new Promise((resolve, reject) => {
if (this.socketTask) {
resolve()
return
}
this.userId = userId
// TODO: 替换为实际的 WebSocket 地址
const wsUrl = `ws://localhost:12080/ws?user_id=${userId}`
this.socketTask = uni.connectSocket({
url: wsUrl,
success: () => {
console.log('WebSocket connecting...')
},
fail: (err) => {
console.error('WebSocket connect failed:', err)
reject(err)
}
})
this.socketTask.onOpen(() => {
console.log('✅ WebSocket connected')
this.reconnectAttempts = 0
resolve()
})
this.socketTask.onMessage((res) => {
try {
const data = (res.data as string).trim()
const lines = data.split('\n').filter((line: string) => line.trim())
for (const line of lines) {
try {
const payload: WebSocketMessage = JSON.parse(line)
// 接收 clientId
if (payload.clientId) {
this.clientId = payload.clientId
}
// 处理接收消息
if (payload.request_type === 'receive_message' && payload.data) {
this.handleMessage(payload.data as ChatMessage)
}
// 处理朋友圈通知
if (payload.request_type === 'moment_notification' && payload.data) {
this.handleMomentNotification(payload.data as MomentNotifPayload)
}
} catch {
// 忽略单行解析错误
}
}
} catch (error) {
console.error('WebSocket message parse error:', error)
}
})
this.socketTask.onError((error) => {
console.error('WebSocket error:', error)
reject(error)
})
this.socketTask.onClose(() => {
console.log('WebSocket closed')
this.socketTask = null
this.attemptReconnect()
})
})
}
/**
* 断开连接
*/
disconnect() {
if (this.socketTask) {
this.socketTask.close({})
this.socketTask = null
}
this.clientId = null
this.userId = null
this.messageHandlers = []
this.signalHandlers = []
this.momentNotifHandlers = []
}
/**
* 获取 ClientID
*/
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)
}
}
/**
* 添加信令处理器 (WebRTC用)
*/
onSignal(handler: SignalHandler) {
if (!this.signalHandlers.includes(handler)) {
this.signalHandlers.push(handler)
}
}
/**
* 移除信令处理器
*/
offSignal(handler: SignalHandler) {
const index = this.signalHandlers.indexOf(handler)
if (index > -1) {
this.signalHandlers.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 handleMomentNotification(payload: MomentNotifPayload) {
const notifKey = `${payload.moment_id}_${payload.type}_${payload.from_user?.id || ''}_${payload.comment_id || ''}`
if (this.recentMomentNotifs.has(notifKey)) {
console.log('🔄 跳过重复朋友圈通知:', notifKey)
return
}
this.recentMomentNotifs.add(notifKey)
setTimeout(() => {
this.recentMomentNotifs.delete(notifKey)
}, this.notifCacheTimeout)
this.momentNotifHandlers.forEach((handler) => handler(payload))
}
/**
* 内部处理接收到的消息
*/
private handleMessage(message: ChatMessage) {
// 信令消息message_type = 6- 路由到 signalHandlers
if (message.message_type === 6) {
this.signalHandlers.forEach((handler) => handler(message))
return
}
// 普通消息 - 路由到 messageHandlers
this.messageHandlers.forEach((handler) => handler(message))
}
/**
* 尝试重连
*/
private attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.log('WebSocket max reconnect attempts reached')
return
}
if (!this.userId) {
return
}
this.reconnectAttempts++
console.log(`WebSocket reconnecting... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
setTimeout(() => {
if (this.userId) {
this.connect(this.userId).catch(console.error)
}
}, this.reconnectDelay)
}
}
export const wsManager = new WebSocketManager()

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
function handleClickGithub() {
if (window?.open) {
window.open('https://github.com/uni-helper/create-uni')
}
else {
uni.showToast({
icon: 'none',
title: '请使用浏览器打开',
})
}
}
</script>
<template>
<view class="footer" @click="handleClickGithub">
<image class="uni-helper-github__image" src="/static/github.svg" />
</view>
</template>
<style>
.footer{
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
color: #888;
}
.uni-helper-github__image {
display: inline-block;
height: 1em;
width: 1em;
}
</style>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
</script>
<template>
<view class="container">
<view class="uni-helper-logo">
<image class="uni-helper-logo__image" src="/static/logo.svg" />
<text class="uni-helper-logo__label green">
uni-helper
</text>
</view>
<text class="link-bar">
+
</text>
<view class="uni-helper-logo">
<image class="uni-helper-logo__image" src="/static/vite.png" />
<text class="uni-helper-logo__label purple">
Vite
</text>
</view>
</view>
</template>
<style scoped lang="scss">
.container {
display: inline-flex;
font-size: 1.5rem;
font-weight: 300;
&:hover {
.link-bar {
transform: rotate(135deg);
}
}
}
.uni-helper-logo {
display: flex;
flex-direction: column;
align-items: center;
.uni-helper-logo__image {
display: inline-block;
height: 4.5rem;
width: 4.5rem;
}
.uni-helper-logo__label {
margin-top: -0.5rem;
}
.green {
color: #22c55e;
};
.purple {
color: #a855f7;
}
}
.link-bar {
color: #9ca3af;
margin: auto 1em;
transition: all 500ms cubic-bezier(0.4, 0, 0.2, 1);
}
</style>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { ref } from 'vue'
const name = ref('')
const popupShow = ref(false)
function handleClick() {
popupShow.value = true
}
</script>
<template>
<view class="input-box">
<input
v-model="name"
placeholder="What's your name?"
>
</view>
<view>
<wd-button :disabled="!name" @click="handleClick">
Hello
</wd-button>
</view>
<wd-popup v-model="popupShow" custom-style="padding: 30px 40px;">
Hello{{ ` ${name}` }} 👏
</wd-popup>
</template>
<style scoped lang="scss">
.input-box {
margin: 1rem;
padding: 0.5rem;
border-bottom: 1px solid gray;
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<view class="message-bubble" :class="{ 'is-self': isSelf }">
<!-- 头像 -->
<view v-if="!isSelf" class="bubble-avatar">
<app-avatar
:src="avatarUrl"
:name="avatarName"
:size="72"
radius="8rpx"
/>
</view>
<!-- 消息内容 -->
<view class="bubble-content" :class="bubbleClass">
<slot />
</view>
<!-- 自己的头像 -->
<view v-if="isSelf" class="bubble-avatar">
<app-avatar
:src="selfAvatarUrl"
:name="selfAvatarName"
:size="72"
radius="8rpx"
bg-color="#10aeff"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AppAvatar from '@/components/common/AppAvatar.vue'
interface Props {
isSelf?: boolean
avatarUrl?: string
avatarName?: string
selfAvatarUrl?: string
selfAvatarName?: string
type?: 'text' | 'image' | 'audio' | 'video' | 'file'
}
const props = withDefaults(defineProps<Props>(), {
isSelf: false,
avatarUrl: '',
avatarName: '',
selfAvatarUrl: '',
selfAvatarName: '',
type: 'text'
})
const bubbleClass = computed(() => {
const classes = []
if (props.isSelf) {
classes.push('bubble-self')
} else {
classes.push('bubble-other')
}
if (props.type !== 'text') {
classes.push('bubble-media')
}
return classes
})
</script>
<style lang="scss" scoped>
.message-bubble {
display: flex;
align-items: flex-start;
margin-bottom: 24rpx;
&.is-self {
flex-direction: row-reverse;
}
}
.bubble-avatar {
margin: 0 16rpx;
}
.bubble-content {
max-width: 60%;
padding: 20rpx 24rpx;
border-radius: 16rpx;
word-break: break-all;
font-size: 30rpx;
line-height: 1.6;
&.bubble-self {
background: var(--bubble-self);
color: var(--text-primary);
border-top-right-radius: 4rpx;
}
&.bubble-other {
background: var(--bubble-other);
color: var(--text-primary);
border-top-left-radius: 4rpx;
}
&.bubble-media {
padding: 8rpx;
background: transparent;
}
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<view class="message-input">
<!-- 工具栏 -->
<view class="input-toolbar">
<wd-icon name="emotion" size="48rpx" class="tool-icon" @click="$emit('emoji')" />
<wd-icon name="picture" size="48rpx" class="tool-icon" @click="$emit('image')" />
<wd-icon name="folder" size="48rpx" class="tool-icon" @click="$emit('file')" />
</view>
<!-- 输入框 -->
<view class="input-wrap">
<wd-input
v-model="inputValue"
type="textarea"
:auto-height="true"
:rows="1"
placeholder="输入消息..."
no-border
@confirm="onSend"
/>
</view>
<!-- 发送按钮 -->
<wd-button
v-if="inputValue.trim()"
type="primary"
size="small"
@click="onSend"
>
发送
</wd-button>
<wd-icon
v-else
name="add-circle"
size="48rpx"
class="tool-icon"
@click="$emit('more')"
/>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
interface Props {
modelValue?: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: ''
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'send', value: string): void
(e: 'emoji'): void
(e: 'image'): void
(e: 'file'): void
(e: 'more'): void
}>()
const inputValue = ref(props.modelValue)
watch(() => props.modelValue, (val) => {
inputValue.value = val
})
watch(inputValue, (val) => {
emit('update:modelValue', val)
})
function onSend() {
if (!inputValue.value.trim()) return
emit('send', inputValue.value.trim())
inputValue.value = ''
}
</script>
<style lang="scss" scoped>
.message-input {
display: flex;
align-items: flex-end;
padding: 16rpx 20rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
background: var(--bg-content);
border-top: 1rpx solid var(--divider-color);
gap: 16rpx;
}
.input-toolbar {
display: flex;
gap: 16rpx;
padding-bottom: 8rpx;
}
.tool-icon {
color: var(--text-secondary);
}
.input-wrap {
flex: 1;
background: var(--search-bg);
border-radius: 16rpx;
padding: 8rpx 16rpx;
}
</style>

View File

@@ -0,0 +1,115 @@
<template>
<view class="app-avatar" :style="avatarStyle" @click="onClick">
<wd-img
v-if="src"
:src="src"
:width="sizeRpx"
:height="sizeRpx"
:round="round"
:radius="round ? '50%' : radius"
mode="aspectFill"
/>
<view
v-else
class="avatar-placeholder"
:style="placeholderStyle"
>
{{ displayText }}
</view>
<!-- 徽标 -->
<wd-badge
v-if="badge && badge > 0"
:value="badge"
:max="99"
class="avatar-badge"
/>
<view v-else-if="dot" class="avatar-dot" />
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { generateColor } from '@/utils/format'
interface Props {
src?: string
name?: string
size?: number
round?: boolean
radius?: string
badge?: number
dot?: boolean
bgColor?: string
}
const props = withDefaults(defineProps<Props>(), {
src: '',
name: '',
size: 80,
round: false,
radius: '8rpx',
badge: 0,
dot: false,
bgColor: ''
})
const emit = defineEmits<{
(e: 'click'): void
}>()
const sizeRpx = computed(() => `${props.size}rpx`)
const avatarStyle = computed(() => ({
width: sizeRpx.value,
height: sizeRpx.value
}))
const placeholderStyle = computed(() => ({
width: sizeRpx.value,
height: sizeRpx.value,
borderRadius: props.round ? '50%' : props.radius,
background: props.bgColor || generateColor(props.name),
fontSize: `${props.size * 0.4}rpx`
}))
const displayText = computed(() => {
return props.name?.charAt(0) || '?'
})
function onClick() {
emit('click')
}
</script>
<style lang="scss" scoped>
.app-avatar {
position: relative;
display: inline-block;
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 600;
}
.avatar-badge {
position: absolute;
top: -8rpx;
right: -8rpx;
}
.avatar-dot {
position: absolute;
top: 0;
right: 0;
width: 16rpx;
height: 16rpx;
background: var(--color-danger);
border-radius: 50%;
border: 2rpx solid var(--bg-content);
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<view class="app-empty">
<wd-status-tip :image="image" :tip="description" />
<slot />
</view>
</template>
<script setup lang="ts">
interface Props {
image?: string
description?: string
}
withDefaults(defineProps<Props>(), {
image: 'content',
description: '暂无数据'
})
</script>
<style lang="scss" scoped>
.app-empty {
padding: 80rpx 0;
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<view class="app-loading" :class="{ fullscreen }">
<wd-loading :type="type" :size="size" :color="color" />
<text v-if="text" class="loading-text">{{ text }}</text>
</view>
</template>
<script setup lang="ts">
interface Props {
text?: string
type?: 'circular' | 'spinner'
size?: string
color?: string
fullscreen?: boolean
}
withDefaults(defineProps<Props>(), {
text: '',
type: 'circular',
size: '48rpx',
color: '',
fullscreen: false
})
</script>
<style lang="scss" scoped>
.app-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx;
gap: 20rpx;
&.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-mask);
z-index: 1000;
}
}
.loading-text {
font-size: 28rpx;
color: var(--text-tertiary);
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<view class="app-navbar" :class="{ dark: isDark }">
<view class="navbar-status-bar" />
<view class="navbar-content">
<!-- 左侧 -->
<view class="navbar-left" @click="onLeftClick">
<slot name="left">
<template v-if="showBack">
<wd-icon name="arrow-left" size="44rpx" />
</template>
<template v-else-if="showAvatar">
<wd-img
v-if="avatarUrl"
:src="avatarUrl"
width="64rpx"
height="64rpx"
round
/>
<view v-else class="avatar-placeholder">
{{ avatarText }}
</view>
</template>
</slot>
</view>
<!-- 标题 -->
<view class="navbar-title">
<slot name="title">
<text class="title-text">{{ title }}</text>
</slot>
</view>
<!-- 右侧 -->
<view class="navbar-right" @click="onRightClick">
<slot name="right" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
interface Props {
title?: string
showBack?: boolean
showAvatar?: boolean
avatarUrl?: string
avatarText?: string
}
const props = withDefaults(defineProps<Props>(), {
title: '',
showBack: false,
showAvatar: false,
avatarUrl: '',
avatarText: '?'
})
const emit = defineEmits<{
(e: 'left-click'): void
(e: 'right-click'): void
}>()
const { isDark } = useTheme()
function onLeftClick() {
if (props.showBack) {
uni.navigateBack()
}
emit('left-click')
}
function onRightClick() {
emit('right-click')
}
</script>
<style lang="scss" scoped>
.app-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: var(--nav-bg);
}
.navbar-status-bar {
height: var(--status-bar-height, 0);
}
.navbar-content {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 30rpx;
}
.navbar-left {
min-width: 80rpx;
color: var(--text-primary);
}
.navbar-title {
flex: 1;
text-align: center;
.title-text {
font-size: 36rpx;
font-weight: 600;
color: var(--text-primary);
}
}
.navbar-right {
min-width: 80rpx;
display: flex;
justify-content: flex-end;
color: var(--text-primary);
}
.avatar-placeholder {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: 28rpx;
font-weight: 600;
}
</style>

12
src/components/index.ts Normal file
View File

@@ -0,0 +1,12 @@
// 公共组件
export { default as AppNavBar } from './common/AppNavBar.vue'
export { default as AppAvatar } from './common/AppAvatar.vue'
export { default as AppEmpty } from './common/AppEmpty.vue'
export { default as AppLoading } from './common/AppLoading.vue'
// 聊天组件
export { default as MessageBubble } from './chat/MessageBubble.vue'
export { default as MessageInput } from './chat/MessageInput.vue'
// 朋友圈组件
export { default as MomentCard } from './moment/MomentCard.vue'

View File

@@ -0,0 +1,219 @@
<template>
<view class="moment-card" @click="onClick">
<!-- 头像 -->
<view class="card-avatar">
<app-avatar
:src="moment.user?.avatar"
:name="moment.user?.name"
:size="80"
radius="8rpx"
/>
</view>
<!-- 内容 -->
<view class="card-content">
<!-- 用户名 -->
<text class="card-username">{{ moment.user?.name || '未知' }}</text>
<!-- 文本 -->
<view v-if="moment.content" class="card-text">
<text>{{ moment.content }}</text>
</view>
<!-- 图片 -->
<view v-if="moment.media_type === 1" class="card-images">
<wd-img
v-for="(url, index) in mediaUrls"
:key="index"
:src="url"
:width="imageSize"
:height="imageSize"
mode="aspectFill"
radius="8rpx"
enable-preview
:preview-src-list="mediaUrls"
/>
</view>
<!-- 视频 -->
<view v-if="moment.media_type === 2" class="card-video">
<video
:src="mediaUrls[0]"
class="video-player"
object-fit="cover"
:show-fullscreen-btn="true"
/>
</view>
<!-- 底部 -->
<view class="card-footer">
<text class="card-time">{{ formatTime(moment.created_at) }}</text>
<view class="card-actions">
<view class="action-item" @click.stop="onAction">
<wd-icon name="more" size="32rpx" />
</view>
</view>
</view>
<!-- 互动区域 -->
<view v-if="hasInteractions" class="card-interactions">
<!-- 点赞 -->
<view v-if="moment.likes?.length" class="likes-row">
<wd-icon name="heart-fill" size="28rpx" color="#fa5151" />
<text class="likes-text">
{{ moment.likes.map(l => l.user?.name || '').join('、') }}
</text>
</view>
<!-- 评论 -->
<view
v-for="comment in moment.comments"
:key="comment.id"
class="comment-row"
>
<text class="comment-user">{{ comment.user?.name || '未知' }}</text>
<text v-if="comment.reply_to_user">
回复 <text class="comment-user">{{ comment.reply_to_user.name }}</text>
</text>
<text>: {{ comment.content }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AppAvatar from '@/components/common/AppAvatar.vue'
import { formatTime } from '@/utils/format'
import { parseMediaUrls } from '@/types/moment'
import type { Moment } from '@/types/moment'
interface Props {
moment: Moment
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'click'): void
(e: 'action'): void
}>()
const mediaUrls = computed(() => parseMediaUrls(props.moment.media_urls))
const imageSize = computed(() => {
const count = mediaUrls.value.length
if (count === 1) return '400rpx'
if (count <= 4) return '200rpx'
return '150rpx'
})
const hasInteractions = computed(() => {
return (props.moment.likes?.length || 0) > 0 || (props.moment.comments?.length || 0) > 0
})
function onClick() {
emit('click')
}
function onAction() {
emit('action')
}
</script>
<style lang="scss" scoped>
.moment-card {
display: flex;
padding: 30rpx;
border-bottom: 1rpx solid var(--divider-color);
}
.card-avatar {
margin-right: 24rpx;
}
.card-content {
flex: 1;
min-width: 0;
}
.card-username {
display: block;
font-size: 30rpx;
font-weight: 500;
color: var(--text-link);
margin-bottom: 12rpx;
}
.card-text {
font-size: 30rpx;
color: var(--text-primary);
line-height: 1.6;
margin-bottom: 16rpx;
}
.card-images {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
margin-bottom: 16rpx;
}
.card-video {
margin-bottom: 16rpx;
.video-player {
width: 100%;
height: 400rpx;
border-radius: 8rpx;
}
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
.card-time {
font-size: 24rpx;
color: var(--text-tertiary);
}
.card-actions {
.action-item {
padding: 8rpx;
color: var(--text-tertiary);
}
}
}
.card-interactions {
background: var(--bg-hover);
border-radius: 8rpx;
padding: 16rpx;
.likes-row {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 26rpx;
color: var(--text-link);
margin-bottom: 8rpx;
.likes-text {
flex: 1;
}
}
.comment-row {
font-size: 26rpx;
color: var(--text-primary);
line-height: 1.8;
.comment-user {
color: var(--text-link);
}
}
}
</style>

1
src/composables/index.ts Normal file
View File

@@ -0,0 +1 @@
export { useTheme } from './useTheme'

113
src/composables/useTheme.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* 主题切换组合式函数
*/
import { ref, watch } from 'vue'
const isDark = ref(false)
const isInitialized = ref(false)
export function useTheme() {
/**
* 初始化主题
*/
function initTheme() {
if (isInitialized.value) return
// 读取缓存的主题设置
const cached = uni.getStorageSync('nl_im_theme')
if (cached) {
isDark.value = cached === 'dark'
} else {
// 跟随系统主题
try {
const info = uni.getSystemInfoSync()
isDark.value = info.theme === 'dark'
} catch {
isDark.value = false
}
}
applyTheme()
isInitialized.value = true
// 监听系统主题变化
// #ifdef APP-PLUS || MP-WEIXIN
uni.onThemeChange?.((res: { theme: 'dark' | 'light' }) => {
// 如果用户没有手动设置主题,则跟随系统
if (!uni.getStorageSync('nl_im_theme')) {
isDark.value = res.theme === 'dark'
applyTheme()
}
})
// #endif
}
/**
* 切换主题
*/
function toggleTheme() {
isDark.value = !isDark.value
uni.setStorageSync('nl_im_theme', isDark.value ? 'dark' : 'light')
applyTheme()
}
/**
* 设置主题
*/
function setTheme(dark: boolean) {
isDark.value = dark
uni.setStorageSync('nl_im_theme', dark ? 'dark' : 'light')
applyTheme()
}
/**
* 应用主题
*/
function applyTheme() {
// H5 端设置 html class
// #ifdef H5
if (typeof document !== 'undefined') {
if (isDark.value) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
// #endif
// 设置导航栏颜色
try {
uni.setNavigationBarColor({
frontColor: isDark.value ? '#ffffff' : '#000000',
backgroundColor: isDark.value ? '#1a1a1a' : '#ededed',
animation: {
duration: 200,
timingFunc: 'easeIn'
}
})
} catch {
// 忽略错误
}
// 设置 TabBar 样式
try {
uni.setTabBarStyle({
color: '#999999',
selectedColor: '#07c160',
backgroundColor: isDark.value ? '#1a1a1a' : '#f7f7f7',
borderStyle: isDark.value ? 'black' : 'white'
})
} catch {
// 忽略错误
}
}
return {
isDark,
initTheme,
toggleTheme,
setTheme
}
}
export default useTheme

14
src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createSSRApp } from 'vue'
import * as Pinia from 'pinia'
import App from './App.vue'
// wot-design-uni 样式变量通过 vite.config.ts 的 css.preprocessorOptions 配置
export function createApp() {
const app = createSSRApp(App)
app.use(Pinia.createPinia())
return {
app,
Pinia,
}
}

70
src/manifest.json Normal file
View File

@@ -0,0 +1,70 @@
{
"name": "",
"appid": "",
"description": "",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"uniStatistics": {
"enable": false
},
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
"ios": {},
"sdkConfigs": {}
}
},
"h5": {
"darkmode": true,
"themeLocation": "theme.json"
},
"quickapp": {},
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false
},
"usingComponents": true,
"darkmode": true,
"themeLocation": "theme.json"
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"vueVersion": "3"
}

169
src/pages.json Normal file
View File

@@ -0,0 +1,169 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^wd-(.*)": "wot-design-uni/components/wd-$1/wd-$1.vue"
}
},
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "消息"
}
},
{
"path": "pages/chat/index",
"style": {
"navigationBarTitleText": "聊天",
"enablePullDownRefresh": false
}
},
{
"path": "pages/contact/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "联系人"
}
},
{
"path": "pages/contact/detail",
"style": {
"navigationBarTitleText": "好友详情"
}
},
{
"path": "pages/contact/add",
"style": {
"navigationBarTitleText": "添加好友"
}
},
{
"path": "pages/contact/requests",
"style": {
"navigationBarTitleText": "新朋友"
}
},
{
"path": "pages/contact/groups",
"style": {
"navigationBarTitleText": "群聊"
}
},
{
"path": "pages/moment/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "朋友圈"
}
},
{
"path": "pages/moment/detail",
"style": {
"navigationBarTitleText": "动态详情"
}
},
{
"path": "pages/moment/publish",
"style": {
"navigationBarTitleText": "发布动态"
}
},
{
"path": "pages/moment/notify",
"style": {
"navigationBarTitleText": "消息通知"
}
},
{
"path": "pages/login/index",
"style": {
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/profile/index",
"style": {
"navigationBarTitleText": "个人资料"
}
},
{
"path": "pages/profile/edit",
"style": {
"navigationBarTitleText": "编辑资料"
}
},
{
"path": "pages/settings/index",
"style": {
"navigationBarTitleText": "设置"
}
},
{
"path": "pages/search/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "搜索"
}
},
{
"path": "pages/group/info",
"style": {
"navigationBarTitleText": "群信息"
}
},
{
"path": "pages/group/create",
"style": {
"navigationBarTitleText": "创建群聊"
}
},
{
"path": "pages/group/members",
"style": {
"navigationBarTitleText": "群成员"
}
},
{
"path": "pages/group/notify",
"style": {
"navigationBarTitleText": "群通知"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "NL-IM",
"navigationBarBackgroundColor": "#ededed",
"backgroundColor": "#ededed",
"backgroundTextStyle": "dark"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#07c160",
"backgroundColor": "#f7f7f7",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/index/index",
"text": "消息",
"iconPath": "static/icons/chat.png",
"selectedIconPath": "static/icons/chat-active.png"
},
{
"pagePath": "pages/contact/index",
"text": "联系人",
"iconPath": "static/icons/contact.png",
"selectedIconPath": "static/icons/contact-active.png"
},
{
"pagePath": "pages/moment/index",
"text": "朋友圈",
"iconPath": "static/icons/moment.png",
"selectedIconPath": "static/icons/moment-active.png"
}
]
},
"subPackages": []
}

690
src/pages/chat/index.vue Normal file
View File

@@ -0,0 +1,690 @@
<template>
<view class="chat-page">
<!-- 导航栏 -->
<wd-navbar
:title="chatName"
left-arrow
fixed
placeholder
@click-left="goBack"
>
<template #right>
<wd-icon name="more" size="44rpx" @click="showChatInfo" />
</template>
</wd-navbar>
<!-- 消息列表 -->
<scroll-view
class="message-list"
scroll-y
:scroll-into-view="scrollToId"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="loadMoreMessages"
>
<!-- 加载提示 -->
<view v-if="hasMore" class="load-more">
<wd-loading v-if="loadingMore" />
<text v-else>下拉加载更多消息</text>
</view>
<!-- 消息列表 -->
<view
v-for="(msg, index) in messages"
:key="msg.id"
:id="`msg-${msg.id}`"
class="message-item"
>
<!-- 时间分割线 -->
<view v-if="shouldShowTime(msg, index)" class="time-divider">
<text>{{ formatMessageTime(msg.created_at) }}</text>
</view>
<!-- 系统消息 -->
<view v-if="isSystemMessage(msg.message_type)" class="system-message">
<text>{{ msg.content }}</text>
</view>
<!-- 普通消息 -->
<view v-else class="message-bubble-wrap" :class="{ 'is-self': msg.isSelf }">
<!-- 头像 -->
<view v-if="!msg.isSelf" class="message-avatar">
<wd-img
v-if="targetUser?.avatar"
:src="targetUser.avatar"
width="72rpx"
height="72rpx"
radius="8rpx"
/>
<view v-else class="avatar-placeholder">
{{ targetUser?.name?.charAt(0) || '?' }}
</view>
</view>
<!-- 消息内容 -->
<view class="message-bubble" :class="getBubbleClass(msg)">
<!-- 文本消息 -->
<text v-if="msg.message_type === 0" class="text-content">{{ msg.content }}</text>
<!-- 图片消息 -->
<wd-img
v-else-if="msg.message_type === 1"
:src="getMediaUrl(msg)"
width="300rpx"
mode="widthFix"
radius="8rpx"
enable-preview
:preview-src-list="[getMediaUrl(msg)]"
/>
<!-- 语音消息 -->
<view v-else-if="msg.message_type === 2" class="audio-message" @click="playAudio(msg)">
<wd-icon :name="playingAudioId === msg.id ? 'pause' : 'play'" size="36rpx" />
<text class="audio-duration">{{ msg.duration || 0 }}"</text>
</view>
<!-- 视频消息 -->
<video
v-else-if="msg.message_type === 3"
:src="getMediaUrl(msg)"
class="video-message"
object-fit="cover"
:show-fullscreen-btn="true"
/>
<!-- 文件消息 -->
<view v-else-if="msg.message_type === 8" class="file-message" @click="openFile(msg)">
<wd-icon name="file" size="48rpx" />
<view class="file-info">
<text class="file-name text-ellipsis">{{ getFileName(msg) }}</text>
<text class="file-size">{{ formatSize(getFileSize(msg)) }}</text>
</view>
</view>
<!-- 未知消息 -->
<text v-else class="text-content">[未知消息类型]</text>
</view>
<!-- 自己的头像 -->
<view v-if="msg.isSelf" class="message-avatar">
<wd-img
v-if="currentUser?.avatar"
:src="currentUser.avatar"
width="72rpx"
height="72rpx"
radius="8rpx"
/>
<view v-else class="avatar-placeholder avatar-self">
{{ currentUser?.name?.charAt(0) || '我' }}
</view>
</view>
</view>
</view>
<!-- 底部占位 -->
<view class="bottom-placeholder" />
</scroll-view>
<!-- 输入区域 -->
<view class="input-area">
<!-- 工具栏 -->
<view class="toolbar">
<wd-icon name="emotion" size="48rpx" class="tool-icon" @click="showEmoji = true" />
<wd-icon name="picture" size="48rpx" class="tool-icon" @click="chooseImage" />
<wd-icon name="folder" size="48rpx" class="tool-icon" @click="chooseFile" />
</view>
<!-- 输入框 -->
<view class="input-wrap">
<wd-input
v-model="inputText"
type="textarea"
:auto-height="true"
:rows="1"
placeholder="输入消息..."
no-border
@confirm="sendTextMessage"
/>
</view>
<!-- 发送按钮 -->
<wd-button
v-if="inputText.trim()"
type="primary"
size="small"
@click="sendTextMessage"
>
发送
</wd-button>
<wd-icon v-else name="add-circle" size="48rpx" class="tool-icon" @click="showMore = true" />
</view>
<!-- 更多面板 -->
<wd-popup v-model="showMore" position="bottom" safe-area-inset-bottom>
<view class="more-panel">
<view class="more-item" @click="chooseImage">
<view class="more-icon"><wd-icon name="picture" size="48rpx" /></view>
<text>图片</text>
</view>
<view class="more-item" @click="shootCamera">
<view class="more-icon"><wd-icon name="camera" size="48rpx" /></view>
<text>拍摄</text>
</view>
<view class="more-item" @click="chooseFile">
<view class="more-icon"><wd-icon name="folder" size="48rpx" /></view>
<text>文件</text>
</view>
</view>
</wd-popup>
<wd-toast />
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useAuthStore, useChatStore, useConversationStore } from '@/stores'
import { wsManager } from '@/api/websocket'
import * as messageApi from '@/api/modules/message'
import { formatMessageTime, formatSize } from '@/utils/format'
import { isSystemMessage as checkSystemMessage } from '@/utils/message'
import { useToast } from 'wot-design-uni'
import type { ChatMessage } from '@/types/api'
const authStore = useAuthStore()
const chatStore = useChatStore()
const conversationStore = useConversationStore()
const toast = useToast()
// 路由参数
const roomId = ref('')
const targetId = ref('')
const chatName = ref('')
// 状态
const inputText = ref('')
const messages = ref<ChatMessage[]>([])
const scrollToId = ref('')
const refreshing = ref(false)
const loadingMore = ref(false)
const hasMore = ref(true)
const showMore = ref(false)
const showEmoji = ref(false)
const playingAudioId = ref<number | null>(null)
const page = ref(1)
// 计算属性
const currentUser = computed(() => authStore.user)
const targetUser = computed(() => {
// 从联系人中获取目标用户信息
const contact = chatStore.contacts.find(c => c.contact_user_id === targetId.value)
return contact?.user
})
// 生命周期
onLoad((options: any) => {
roomId.value = options?.roomId || ''
targetId.value = options?.targetId || ''
chatName.value = decodeURIComponent(options?.name || '聊天')
loadMessages()
setupWebSocket()
})
onMounted(() => {
// 清除未读
if (targetId.value) {
conversationStore.clearUnread(targetId.value)
}
})
onUnmounted(() => {
wsManager.offMessage(handleNewMessage)
})
// 方法
async function loadMessages() {
if (!roomId.value) return
try {
const cached = chatStore.getRoomMessages(roomId.value)
if (cached.length > 0) {
messages.value = cached.map(m => ({
...m,
isSelf: m.sender_user_id === currentUser.value?.id
}))
scrollToBottom()
return
}
const res = await messageApi.getMessages(roomId.value, 1, 50)
messages.value = (res.data || []).reverse().map((m: ChatMessage) => ({
...m,
isSelf: m.sender_user_id === currentUser.value?.id
}))
chatStore.setRoomMessages(roomId.value, messages.value)
hasMore.value = res.data.length >= 50
scrollToBottom()
} catch (error) {
console.error('加载消息失败:', error)
}
}
async function loadMoreMessages() {
if (loadingMore.value || !hasMore.value) {
refreshing.value = false
return
}
loadingMore.value = true
page.value++
try {
const res = await messageApi.getMessages(roomId.value, page.value, 50)
const newMessages = (res.data || []).reverse().map((m: ChatMessage) => ({
...m,
isSelf: m.sender_user_id === currentUser.value?.id
}))
messages.value = [...newMessages, ...messages.value]
hasMore.value = res.data.length >= 50
} catch {
page.value--
} finally {
loadingMore.value = false
refreshing.value = false
}
}
function setupWebSocket() {
wsManager.onMessage(handleNewMessage)
}
function handleNewMessage(msg: ChatMessage) {
if (msg.room_id !== roomId.value) return
const newMsg = {
...msg,
isSelf: msg.sender_user_id === currentUser.value?.id
}
messages.value.push(newMsg)
chatStore.addMessage(roomId.value, newMsg)
scrollToBottom()
}
function scrollToBottom() {
nextTick(() => {
if (messages.value.length > 0) {
scrollToId.value = `msg-${messages.value[messages.value.length - 1].id}`
}
})
}
async function sendTextMessage() {
if (!inputText.value.trim() || !roomId.value) return
const content = inputText.value.trim()
inputText.value = ''
try {
await messageApi.sendMessage({
room_id: roomId.value,
message_type: 0,
content
})
} catch {
toast.error('发送失败')
inputText.value = content
}
}
function chooseImage() {
showMore.value = false
uni.chooseImage({
count: 9,
sizeType: ['compressed'],
sourceType: ['album'],
success: async (res) => {
for (const path of res.tempFilePaths) {
await uploadAndSend(path, 1)
}
}
})
}
function shootCamera() {
showMore.value = false
uni.chooseImage({
count: 1,
sourceType: ['camera'],
success: async (res) => {
await uploadAndSend(res.tempFilePaths[0], 1)
}
})
}
function chooseFile() {
showMore.value = false
// #ifdef MP-WEIXIN
wx.chooseMessageFile({
count: 1,
type: 'file',
success: async (res: any) => {
await uploadAndSend(res.tempFiles[0].path, 8)
}
})
// #endif
// #ifdef APP-PLUS
uni.chooseFile({
count: 1,
success: async (res: any) => {
await uploadAndSend(res.tempFilePaths[0], 8)
}
})
// #endif
}
async function uploadAndSend(filePath: string, messageType: number) {
toast.loading('上传中...')
try {
// TODO: 实现文件上传
toast.close()
toast.show('文件上传功能开发中')
} catch {
toast.close()
toast.error('上传失败')
}
}
function playAudio(msg: ChatMessage) {
// TODO: 播放语音
toast.show('语音播放功能开发中')
}
function openFile(msg: ChatMessage) {
// TODO: 打开文件
toast.show('文件预览功能开发中')
}
function goBack() {
uni.navigateBack()
}
function showChatInfo() {
if (roomId.value) {
uni.navigateTo({ url: `/pages/group/info?roomId=${roomId.value}` })
}
}
// 辅助函数
function shouldShowTime(msg: ChatMessage, index: number): boolean {
if (index === 0) return true
const prevMsg = messages.value[index - 1]
const currentTime = new Date(msg.created_at).getTime()
const prevTime = new Date(prevMsg.created_at).getTime()
return currentTime - prevTime > 5 * 60 * 1000 // 5分钟
}
function isSystemMessage(type: number): boolean {
return checkSystemMessage(type)
}
function getBubbleClass(msg: ChatMessage): string {
const classes = []
if (msg.isSelf) classes.push('bubble-self')
else classes.push('bubble-other')
return classes.join(' ')
}
function getMediaUrl(msg: ChatMessage): string {
if (typeof msg.extra === 'string') {
try {
const extra = JSON.parse(msg.extra)
return extra.url || msg.content
} catch {
return msg.content
}
}
return msg.extra?.url || msg.content
}
function getFileName(msg: ChatMessage): string {
if (typeof msg.extra === 'string') {
try {
const extra = JSON.parse(msg.extra)
return extra.name || '未知文件'
} catch {
return '未知文件'
}
}
return msg.extra?.name || '未知文件'
}
function getFileSize(msg: ChatMessage): number {
if (typeof msg.extra === 'string') {
try {
const extra = JSON.parse(msg.extra)
return extra.size || 0
} catch {
return 0
}
}
return msg.extra?.size || 0
}
</script>
<style lang="scss" scoped>
.chat-page {
min-height: 100vh;
background: var(--bg-page);
display: flex;
flex-direction: column;
}
// 消息列表
.message-list {
flex: 1;
padding: 20rpx;
}
.load-more {
text-align: center;
padding: 20rpx;
color: var(--text-tertiary);
font-size: 24rpx;
}
.time-divider {
text-align: center;
padding: 20rpx 0;
text {
padding: 8rpx 20rpx;
background: var(--bubble-system);
border-radius: 8rpx;
font-size: 24rpx;
color: var(--text-tertiary);
}
}
.system-message {
text-align: center;
padding: 16rpx 0;
text {
padding: 8rpx 24rpx;
background: var(--bubble-system);
border-radius: 8rpx;
font-size: 26rpx;
color: var(--text-tertiary);
}
}
.message-bubble-wrap {
display: flex;
align-items: flex-start;
margin-bottom: 24rpx;
&.is-self {
flex-direction: row-reverse;
}
}
.message-avatar {
margin: 0 16rpx;
.avatar-placeholder {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
background: var(--color-primary);
color: #fff;
font-size: 28rpx;
font-weight: 600;
&.avatar-self {
background: #10aeff;
}
}
}
.message-bubble {
max-width: 60%;
padding: 20rpx 24rpx;
border-radius: 16rpx;
word-break: break-all;
&.bubble-self {
background: var(--bubble-self);
border-top-right-radius: 4rpx;
}
&.bubble-other {
background: var(--bubble-other);
border-top-left-radius: 4rpx;
}
.text-content {
font-size: 30rpx;
color: var(--text-primary);
line-height: 1.6;
}
}
.audio-message {
display: flex;
align-items: center;
gap: 12rpx;
min-width: 120rpx;
.audio-duration {
font-size: 26rpx;
color: var(--text-secondary);
}
}
.video-message {
width: 400rpx;
height: 300rpx;
border-radius: 8rpx;
}
.file-message {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx;
background: var(--bg-hover);
border-radius: 8rpx;
min-width: 300rpx;
.file-info {
flex: 1;
min-width: 0;
.file-name {
display: block;
font-size: 28rpx;
color: var(--text-primary);
}
.file-size {
display: block;
font-size: 24rpx;
color: var(--text-tertiary);
margin-top: 4rpx;
}
}
}
.bottom-placeholder {
height: 200rpx;
}
// 输入区域
.input-area {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: flex-end;
padding: 16rpx 20rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
background: var(--bg-content);
border-top: 1rpx solid var(--divider-color);
gap: 16rpx;
}
.toolbar {
display: flex;
gap: 16rpx;
padding-bottom: 8rpx;
}
.tool-icon {
color: var(--text-secondary);
}
.input-wrap {
flex: 1;
background: var(--search-bg);
border-radius: 16rpx;
padding: 8rpx 16rpx;
}
// 更多面板
.more-panel {
display: flex;
flex-wrap: wrap;
padding: 40rpx;
gap: 40rpx;
}
.more-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
width: 120rpx;
.more-icon {
width: 100rpx;
height: 100rpx;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-hover);
border-radius: 16rpx;
}
text {
font-size: 24rpx;
color: var(--text-secondary);
}
}
</style>

221
src/pages/contact/add.vue Normal file
View File

@@ -0,0 +1,221 @@
<template>
<view class="add-page">
<!-- 搜索区域 -->
<view class="search-section">
<wd-search
v-model="keyword"
placeholder="输入用户ID或手机号搜索"
show-action
@search="doSearch"
@cancel="keyword = ''"
/>
</view>
<!-- 搜索结果 -->
<view v-if="searched" class="results-section">
<view v-if="loading" class="loading-state">
<wd-loading />
<text>搜索中...</text>
</view>
<wd-status-tip v-else-if="results.length === 0" tip="未找到相关用户" />
<template v-else>
<view
v-for="item in results"
:key="item.id"
class="result-item"
>
<wd-img
v-if="item.avatar"
:src="item.avatar"
width="96rpx"
height="96rpx"
radius="8rpx"
/>
<view v-else class="avatar-placeholder" :style="{ background: generateColor(item.name || '') }">
{{ item.name?.charAt(0) || '?' }}
</view>
<view class="user-info">
<text class="name">{{ item.name }}</text>
<text class="id">ID: {{ item.id }}</text>
</view>
<wd-button type="primary" size="small" @click="addFriend(item)">添加</wd-button>
</view>
</template>
</view>
<!-- 扫码添加 -->
<view class="scan-section" @click="scanQrCode">
<view class="scan-icon">
<wd-icon name="scan" size="64rpx" color="var(--color-primary)" />
</view>
<text class="scan-text">扫一扫添加好友</text>
</view>
<wd-toast />
<wd-message-box />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import * as contactApi from '@/api/modules/contact'
import { generateColor } from '@/utils/format'
import { useToast, useMessage } from 'wot-design-uni'
import type { User } from '@/types/api'
const toast = useToast()
const message = useMessage()
const keyword = ref('')
const loading = ref(false)
const searched = ref(false)
const results = ref<User[]>([])
async function doSearch() {
if (!keyword.value.trim()) {
toast.show('请输入搜索内容')
return
}
loading.value = true
searched.value = true
try {
results.value = await contactApi.searchUsers(keyword.value.trim())
} catch (error) {
console.error('搜索失败:', error)
toast.error('搜索失败')
} finally {
loading.value = false
}
}
async function addFriend(user: User) {
try {
const { value } = await message.prompt({
title: '添加好友',
inputPlaceholder: '请输入验证消息',
inputValue: '你好,我是...'
})
await contactApi.addFriend({
to_user_id: user.id,
message: value || ''
})
toast.success('申请已发送')
} catch (e: any) {
if (e !== 'cancel') {
toast.error('发送失败')
}
}
}
function scanQrCode() {
uni.scanCode({
onlyFromCamera: false,
success: (res) => {
console.log('扫码结果:', res.result)
// TODO: 处理扫码结果
toast.show('扫码功能开发中')
},
fail: () => {
toast.error('扫码失败')
}
})
}
</script>
<style lang="scss" scoped>
.add-page {
min-height: 100vh;
background: var(--bg-page);
}
.search-section {
background: var(--bg-content);
padding: 10rpx 0;
}
.results-section {
background: var(--bg-content);
margin-top: 20rpx;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
.result-item {
display: flex;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid var(--divider-color);
.avatar-placeholder {
width: 96rpx;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
color: #fff;
font-size: 36rpx;
font-weight: 600;
}
}
.user-info {
flex: 1;
margin-left: 24rpx;
min-width: 0;
.name {
display: block;
font-size: 32rpx;
color: var(--text-primary);
font-weight: 500;
}
.id {
display: block;
font-size: 26rpx;
color: var(--text-secondary);
margin-top: 8rpx;
}
}
.scan-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx;
background: var(--bg-content);
margin-top: 20rpx;
.scan-icon {
width: 120rpx;
height: 120rpx;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-hover);
border-radius: 50%;
margin-bottom: 24rpx;
}
.scan-text {
font-size: 30rpx;
color: var(--text-secondary);
}
}
</style>

View File

@@ -0,0 +1,242 @@
<template>
<view class="detail-page">
<view v-if="loading" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<template v-else-if="contact">
<!-- 用户信息 -->
<view class="header-section">
<wd-img
v-if="contact.user?.avatar"
:src="contact.user.avatar"
width="140rpx"
height="140rpx"
radius="12rpx"
/>
<view v-else class="avatar-placeholder" :style="{ background: generateColor(contact.user?.name || '') }">
{{ contact.user?.name?.charAt(0) || '?' }}
</view>
<view class="user-info">
<text class="name">{{ contact.remark_name || contact.user?.name || '未知' }}</text>
<text class="id">ID: {{ contact.user?.id || '-' }}</text>
</view>
</view>
<!-- 基本信息 -->
<wd-cell-group>
<wd-cell title="备注名" :value="contact.remark_name || '未设置'" is-link @click="editRemark" />
<wd-cell title="个性签名" :value="contact.user?.desc || '暂无'" />
<wd-cell title="地区" :value="contact.user?.region || '未知'" />
</wd-cell-group>
<!-- 设置 -->
<wd-cell-group title="聊天设置">
<wd-cell title="置顶聊天" center>
<template #value>
<wd-switch v-model="contact.is_top" @change="toggleTop" />
</template>
</wd-cell>
<wd-cell title="消息免打扰" center>
<template #value>
<wd-switch v-model="contact.is_muted" @change="toggleMute" />
</template>
</wd-cell>
<wd-cell title="加入黑名单" center>
<template #value>
<wd-switch v-model="contact.is_blocked" @change="toggleBlock" />
</template>
</wd-cell>
</wd-cell-group>
<!-- 操作按钮 -->
<view class="actions-section">
<wd-button type="primary" block @click="goChat">发消息</wd-button>
<wd-button type="error" block plain @click="deleteFriend">删除好友</wd-button>
</view>
</template>
<wd-toast />
<wd-message-box />
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import * as contactApi from '@/api/modules/contact'
import { generateColor } from '@/utils/format'
import { useToast, useMessage } from 'wot-design-uni'
import type { Contact } from '@/types/api'
const toast = useToast()
const messageBox = useMessage()
const loading = ref(true)
const contact = ref<Contact | null>(null)
const contactId = ref('')
onLoad((options: any) => {
contactId.value = options?.id || ''
})
onMounted(async () => {
if (contactId.value) {
await loadContact()
}
})
async function loadContact() {
loading.value = true
try {
contact.value = await contactApi.getContactDetail(contactId.value)
} catch (error) {
console.error('加载联系人失败:', error)
toast.error('加载失败')
} finally {
loading.value = false
}
}
function goChat() {
if (!contact.value) return
uni.navigateTo({
url: `/pages/chat/index?id=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}`
})
}
async function editRemark() {
if (!contact.value) return
try {
const { value } = await messageBox.prompt({
title: '修改备注名',
inputValue: contact.value.remark_name || '',
inputPlaceholder: '请输入备注名'
})
await contactApi.updateContact(contactId.value, { remark_name: value })
contact.value.remark_name = value
toast.success('修改成功')
} catch (e: any) {
if (e !== 'cancel') {
toast.error('修改失败')
}
}
}
async function toggleTop() {
if (!contact.value) return
try {
await contactApi.updateContact(contactId.value, { is_top: contact.value.is_top })
toast.success(contact.value.is_top ? '已置顶' : '已取消置顶')
} catch {
contact.value.is_top = !contact.value.is_top
toast.error('操作失败')
}
}
async function toggleMute() {
if (!contact.value) return
try {
await contactApi.updateContact(contactId.value, { is_muted: contact.value.is_muted })
toast.success(contact.value.is_muted ? '已开启免打扰' : '已关闭免打扰')
} catch {
contact.value.is_muted = !contact.value.is_muted
toast.error('操作失败')
}
}
async function toggleBlock() {
if (!contact.value) return
try {
await contactApi.updateContact(contactId.value, { is_blocked: contact.value.is_blocked })
toast.success(contact.value.is_blocked ? '已加入黑名单' : '已移出黑名单')
} catch {
contact.value.is_blocked = !contact.value.is_blocked
toast.error('操作失败')
}
}
async function deleteFriend() {
try {
await messageBox.confirm({
title: '提示',
msg: '确定要删除该好友吗?删除后将无法恢复'
})
await contactApi.deleteContact(contactId.value)
toast.success('删除成功')
setTimeout(() => {
uni.navigateBack()
}, 1000)
} catch (e: any) {
if (e !== 'cancel') {
toast.error('删除失败')
}
}
}
</script>
<style lang="scss" scoped>
.detail-page {
min-height: 100vh;
background: var(--bg-page);
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
.header-section {
display: flex;
align-items: center;
padding: 40rpx 30rpx;
background: var(--bg-content);
margin-bottom: 20rpx;
.avatar-placeholder {
width: 140rpx;
height: 140rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12rpx;
color: #fff;
font-size: 56rpx;
font-weight: 600;
}
}
.user-info {
margin-left: 30rpx;
.name {
display: block;
font-size: 40rpx;
color: var(--text-primary);
font-weight: 600;
margin-bottom: 8rpx;
}
.id {
display: block;
font-size: 26rpx;
color: var(--text-tertiary);
}
}
.actions-section {
padding: 60rpx 30rpx;
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 24rpx;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<view class="groups-page">
<view v-if="loading" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<wd-status-tip v-else-if="groups.length === 0" tip="暂无群聊" />
<template v-else>
<wd-cell
v-for="group in groups"
:key="group.room_id"
:title="group.room_name"
:label="getCategoryText(group.category)"
is-link
@click="goChat(group)"
>
<template #icon>
<wd-img
v-if="group.room_avatar"
:src="group.room_avatar"
width="80rpx"
height="80rpx"
radius="8rpx"
custom-style="margin-right: 24rpx;"
/>
<view v-else class="avatar-placeholder" :style="{ background: generateColor(group.room_name || '') }">
{{ group.room_name?.charAt(0) || '群' }}
</view>
</template>
</wd-cell>
</template>
<!-- 创建群聊按钮 -->
<view class="create-section" @click="goCreate">
<wd-icon name="add-circle" size="40rpx" color="var(--color-primary)" />
<text>创建群聊</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as roomApi from '@/api/modules/room'
import { generateColor } from '@/utils/format'
interface GroupItem {
room_id: string
room_name: string
room_avatar: string
category: 'joined' | 'created' | 'managed'
}
const loading = ref(true)
const groups = ref<GroupItem[]>([])
onMounted(async () => {
await loadGroups()
})
async function loadGroups() {
loading.value = true
try {
groups.value = await roomApi.getUserGroups()
} catch (error) {
console.error('加载群聊列表失败:', error)
} finally {
loading.value = false
}
}
function getCategoryText(category: string) {
switch (category) {
case 'created':
return '我创建的'
case 'managed':
return '我管理的'
default:
return '我加入的'
}
}
function goChat(group: GroupItem) {
uni.navigateTo({
url: `/pages/chat/index?roomId=${group.room_id}&name=${encodeURIComponent(group.room_name)}`
})
}
function goCreate() {
uni.navigateTo({ url: '/pages/group/create' })
}
</script>
<style lang="scss" scoped>
.groups-page {
min-height: 100vh;
background: var(--bg-page);
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
.avatar-placeholder {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
margin-right: 24rpx;
color: #fff;
font-size: 32rpx;
font-weight: 600;
}
.create-section {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 28rpx;
background: var(--bg-content);
margin-top: 20rpx;
color: var(--color-primary);
font-size: 32rpx;
}
</style>

395
src/pages/contact/index.vue Normal file
View File

@@ -0,0 +1,395 @@
<template>
<view class="page-container" :class="{ dark: isDark }">
<!-- 自定义导航栏 -->
<view class="custom-navbar">
<view class="navbar-content">
<view class="navbar-title">联系人</view>
<view class="navbar-right">
<wd-icon name="add" size="48rpx" @click="goAddFriend" />
</view>
</view>
</view>
<!-- 搜索栏 -->
<view class="search-bar">
<wd-search
v-model="searchKeyword"
placeholder="搜索联系人"
disabled
hide-cancel
@click="goSearch"
/>
</view>
<!-- 内容区域 -->
<scroll-view
class="content-scroll"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- 功能入口 -->
<view class="function-section">
<wd-cell
title="新朋友"
is-link
@click="goFriendRequests"
>
<template #icon>
<view class="function-icon icon-friend">
<wd-icon name="user-add" size="40rpx" color="#fff" />
</view>
</template>
<template #value>
<wd-badge v-if="friendRequestCount > 0" :value="friendRequestCount" />
</template>
</wd-cell>
<wd-cell
title="群聊"
is-link
@click="goGroups"
>
<template #icon>
<view class="function-icon icon-group">
<wd-icon name="people" size="40rpx" color="#fff" />
</view>
</template>
</wd-cell>
<wd-cell
title="群通知"
is-link
@click="goGroupNotify"
>
<template #icon>
<view class="function-icon icon-notify">
<wd-icon name="bell" size="40rpx" color="#fff" />
</view>
</template>
</wd-cell>
</view>
<!-- 联系人列表 -->
<view class="contact-section">
<view v-if="loading" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<wd-status-tip v-else-if="contacts.length === 0" tip="暂无联系人" />
<template v-else>
<!-- 按字母分组 -->
<view
v-for="group in groupedContacts"
:key="group.letter"
class="contact-group"
>
<view class="group-header">{{ group.letter }}</view>
<wd-cell
v-for="contact in group.contacts"
:key="contact.id"
:title="contact.remark_name || contact.user?.name || '未知'"
:label="contact.user?.desc"
is-link
@click="goContactDetail(contact)"
>
<template #icon>
<wd-img
v-if="contact.user?.avatar"
:src="contact.user.avatar"
width="80rpx"
height="80rpx"
radius="8rpx"
custom-style="margin-right: 24rpx;"
/>
<view v-else class="avatar-placeholder" :style="{ background: generateColor(contact.user?.name || '') }">
{{ contact.user?.name?.charAt(0) || '?' }}
</view>
</template>
</wd-cell>
</view>
</template>
</view>
</scroll-view>
<!-- 右侧字母索引 -->
<view class="letter-index">
<text
v-for="letter in letters"
:key="letter"
class="letter-item"
@click="scrollToLetter(letter)"
>
{{ letter }}
</text>
</view>
<wd-toast />
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useContactStore, useChatStore } from '@/stores'
import { useTheme } from '@/composables/useTheme'
import { generateColor } from '@/utils/format'
import * as contactApi from '@/api/modules/contact'
import type { Contact } from '@/types/api'
const contactStore = useContactStore()
const chatStore = useChatStore()
const { isDark } = useTheme()
// 状态
const searchKeyword = ref('')
const refreshing = ref(false)
const loading = ref(false)
// 计算属性
const contacts = computed(() => chatStore.contacts)
const friendRequestCount = computed(() => contactStore.friendRequests.filter(r => r.status === 'pending').length)
// 字母列表
const letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '#']
// 按字母分组的联系人
const groupedContacts = computed(() => {
const groups: Record<string, Contact[]> = {}
contacts.value.forEach(contact => {
const name = contact.remark_name || contact.user?.name || ''
const firstChar = name.charAt(0).toUpperCase()
const letter = /[A-Z]/.test(firstChar) ? firstChar : '#'
if (!groups[letter]) {
groups[letter] = []
}
groups[letter].push(contact)
})
return Object.keys(groups)
.sort((a, b) => {
if (a === '#') return 1
if (b === '#') return -1
return a.localeCompare(b)
})
.map(letter => ({
letter,
contacts: groups[letter]
}))
})
// 生命周期
onMounted(() => {
loadData()
})
onShow(() => {
loadFriendRequests()
})
// 方法
async function loadData() {
loading.value = true
try {
const list = await contactApi.getContacts()
chatStore.setContacts(list)
} catch (error) {
console.error('加载联系人失败:', error)
} finally {
loading.value = false
}
}
async function loadFriendRequests() {
try {
const requests = await contactApi.getFriendRequests()
contactStore.setFriendRequests(requests)
} catch {
// ignore
}
}
async function onRefresh() {
refreshing.value = true
await loadData()
await loadFriendRequests()
refreshing.value = false
}
function goSearch() {
uni.navigateTo({ url: '/pages/search/index' })
}
function goAddFriend() {
uni.navigateTo({ url: '/pages/contact/add' })
}
function goFriendRequests() {
uni.navigateTo({ url: '/pages/contact/requests' })
}
function goGroups() {
uni.navigateTo({ url: '/pages/contact/groups' })
}
function goGroupNotify() {
uni.navigateTo({ url: '/pages/group/notify' })
}
function goContactDetail(contact: Contact) {
uni.navigateTo({ url: `/pages/contact/detail?id=${contact.id}` })
}
function scrollToLetter(letter: string) {
// TODO: 实现滚动到指定字母
console.log('Scroll to:', letter)
}
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: var(--bg-page);
}
// 自定义导航栏
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: var(--nav-bg);
padding-top: var(--status-bar-height, 0);
.navbar-content {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 30rpx;
}
.navbar-title {
flex: 1;
font-size: 36rpx;
font-weight: 600;
color: var(--text-primary);
}
.navbar-right {
color: var(--text-primary);
}
}
// 搜索栏
.search-bar {
position: fixed;
top: calc(88rpx + var(--status-bar-height, 0));
left: 0;
right: 0;
z-index: 99;
padding: 10rpx 20rpx;
background: var(--nav-bg);
}
// 内容滚动区域
.content-scroll {
position: fixed;
top: calc(88rpx + 80rpx + var(--status-bar-height, 0));
left: 0;
right: 40rpx;
bottom: 0;
}
// 功能入口
.function-section {
background: var(--bg-content);
margin-bottom: 20rpx;
}
.function-icon {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
margin-right: 24rpx;
&.icon-friend {
background: #fa9d3b;
}
&.icon-group {
background: #07c160;
}
&.icon-notify {
background: #10aeff;
}
}
// 联系人列表
.contact-section {
background: var(--bg-content);
}
.contact-group {
.group-header {
padding: 12rpx 30rpx;
font-size: 26rpx;
color: var(--text-tertiary);
background: var(--bg-page);
}
}
.avatar-placeholder {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
margin-right: 24rpx;
color: #fff;
font-size: 32rpx;
font-weight: 600;
}
// 字母索引
.letter-index {
position: fixed;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
align-items: center;
padding: 10rpx 8rpx;
z-index: 100;
.letter-item {
font-size: 22rpx;
color: var(--text-tertiary);
padding: 4rpx 8rpx;
&:active {
color: var(--color-primary);
}
}
}
// 加载状态
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
</style>

View File

@@ -0,0 +1,174 @@
<template>
<view class="requests-page">
<view v-if="loading" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<wd-status-tip v-else-if="requests.length === 0" tip="暂无好友申请" />
<template v-else>
<view
v-for="item in requests"
:key="item.id"
class="request-item"
>
<wd-img
v-if="item.from_user?.avatar"
:src="item.from_user.avatar"
width="96rpx"
height="96rpx"
radius="8rpx"
/>
<view v-else class="avatar-placeholder" :style="{ background: generateColor(item.from_user?.name || '') }">
{{ item.from_user?.name?.charAt(0) || '?' }}
</view>
<view class="request-info">
<text class="name">{{ item.from_user?.name || '未知用户' }}</text>
<text class="message">{{ item.message || '请求添加您为好友' }}</text>
</view>
<view class="request-actions">
<template v-if="item.status === 'pending'">
<wd-button size="small" plain @click="reject(item)">拒绝</wd-button>
<wd-button size="small" type="primary" @click="accept(item)">同意</wd-button>
</template>
<text v-else class="status" :class="item.status">
{{ item.status === 'accepted' ? '已添加' : '已拒绝' }}
</text>
</view>
</view>
</template>
<wd-toast />
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useContactStore } from '@/stores'
import * as contactApi from '@/api/modules/contact'
import { generateColor } from '@/utils/format'
import { useToast } from 'wot-design-uni'
import type { FriendRequest } from '@/types/api'
const contactStore = useContactStore()
const toast = useToast()
const loading = ref(true)
const requests = ref<FriendRequest[]>([])
onMounted(async () => {
await loadRequests()
})
async function loadRequests() {
loading.value = true
try {
requests.value = await contactApi.getFriendRequests()
contactStore.setFriendRequests(requests.value)
} catch (error) {
console.error('加载好友申请失败:', error)
} finally {
loading.value = false
}
}
async function accept(item: FriendRequest) {
try {
await contactApi.acceptFriendRequest(item.id)
item.status = 'accepted'
contactStore.removeFriendRequest(item.id)
toast.success('已添加')
} catch {
toast.error('操作失败')
}
}
async function reject(item: FriendRequest) {
try {
await contactApi.rejectFriendRequest(item.id)
item.status = 'rejected'
contactStore.removeFriendRequest(item.id)
toast.success('已拒绝')
} catch {
toast.error('操作失败')
}
}
</script>
<style lang="scss" scoped>
.requests-page {
min-height: 100vh;
background: var(--bg-page);
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
.request-item {
display: flex;
align-items: center;
padding: 24rpx 30rpx;
background: var(--bg-content);
border-bottom: 1rpx solid var(--divider-color);
.avatar-placeholder {
width: 96rpx;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
color: #fff;
font-size: 36rpx;
font-weight: 600;
}
}
.request-info {
flex: 1;
margin-left: 24rpx;
min-width: 0;
.name {
display: block;
font-size: 32rpx;
color: var(--text-primary);
font-weight: 500;
}
.message {
display: block;
font-size: 26rpx;
color: var(--text-secondary);
margin-top: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.request-actions {
display: flex;
gap: 16rpx;
align-items: center;
.status {
font-size: 26rpx;
color: var(--text-tertiary);
&.accepted {
color: var(--color-primary);
}
}
}
</style>

215
src/pages/group/create.vue Normal file
View File

@@ -0,0 +1,215 @@
<template>
<view class="create-page">
<!-- 群名称 -->
<wd-cell-group>
<wd-input
v-model="form.name"
label="群名称"
placeholder="请输入群名称"
:maxlength="20"
clearable
/>
</wd-cell-group>
<!-- 选择成员 -->
<view class="members-section">
<view class="section-header">
<text class="title">选择成员</text>
<text class="count">已选 {{ selectedIds.length }} </text>
</view>
<view v-if="loadingContacts" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<template v-else>
<view
v-for="contact in contacts"
:key="contact.id"
class="contact-item"
@click="toggleSelect(contact)"
>
<wd-checkbox
:model-value="selectedIds.includes(contact.contact_user_id)"
@change="toggleSelect(contact)"
/>
<wd-img
v-if="contact.user?.avatar"
:src="contact.user.avatar"
width="72rpx"
height="72rpx"
radius="8rpx"
/>
<view v-else class="avatar-placeholder" :style="{ background: generateColor(contact.user?.name || '') }">
{{ contact.user?.name?.charAt(0) || '?' }}
</view>
<text class="name">{{ contact.remark_name || contact.user?.name || '未知' }}</text>
</view>
</template>
</view>
<!-- 底部按钮 -->
<view class="footer-section">
<wd-button
type="primary"
block
:loading="creating"
:disabled="!canCreate"
@click="createGroup"
>
创建群聊 ({{ selectedIds.length }})
</wd-button>
</view>
<wd-toast />
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import * as contactApi from '@/api/modules/contact'
import * as roomApi from '@/api/modules/room'
import { generateColor } from '@/utils/format'
import { useToast } from 'wot-design-uni'
import type { Contact } from '@/types/api'
const toast = useToast()
const loadingContacts = ref(true)
const creating = ref(false)
const contacts = ref<Contact[]>([])
const selectedIds = ref<string[]>([])
const form = ref({
name: ''
})
const canCreate = computed(() => form.value.name.trim() && selectedIds.value.length >= 2)
onMounted(async () => {
await loadContacts()
})
async function loadContacts() {
loadingContacts.value = true
try {
contacts.value = await contactApi.getContacts()
} catch (error) {
console.error('加载联系人失败:', error)
} finally {
loadingContacts.value = false
}
}
function toggleSelect(contact: Contact) {
const id = contact.contact_user_id
const index = selectedIds.value.indexOf(id)
if (index > -1) {
selectedIds.value.splice(index, 1)
} else {
selectedIds.value.push(id)
}
}
async function createGroup() {
if (!canCreate.value) return
creating.value = true
try {
const group = await roomApi.createGroup({
name: form.value.name.trim(),
member_ids: selectedIds.value
})
toast.success('创建成功')
// 跳转到群聊
setTimeout(() => {
uni.redirectTo({
url: `/pages/chat/index?roomId=${group.room_id}&name=${encodeURIComponent(group.name)}`
})
}, 1000)
} catch (error) {
toast.error('创建失败')
} finally {
creating.value = false
}
}
</script>
<style lang="scss" scoped>
.create-page {
min-height: 100vh;
background: var(--bg-page);
padding-bottom: 160rpx;
}
.members-section {
background: var(--bg-content);
margin-top: 20rpx;
.section-header {
display: flex;
justify-content: space-between;
padding: 20rpx 30rpx;
border-bottom: 1rpx solid var(--divider-color);
.title {
font-size: 28rpx;
color: var(--text-tertiary);
}
.count {
font-size: 28rpx;
color: var(--color-primary);
}
}
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
.contact-item {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
border-bottom: 1rpx solid var(--divider-color);
.avatar-placeholder {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
margin-left: 20rpx;
color: #fff;
font-size: 28rpx;
font-weight: 600;
}
.name {
flex: 1;
margin-left: 20rpx;
font-size: 32rpx;
color: var(--text-primary);
}
}
.footer-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: var(--bg-content);
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
}
</style>

331
src/pages/group/info.vue Normal file
View File

@@ -0,0 +1,331 @@
<template>
<view class="info-page">
<view v-if="loading" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<template v-else-if="groupInfo">
<!-- 群头像和名称 -->
<view class="header-section">
<wd-img
v-if="groupInfo.avatar"
:src="groupInfo.avatar"
width="120rpx"
height="120rpx"
radius="16rpx"
/>
<view v-else class="avatar-placeholder" :style="{ background: generateColor(groupInfo.name || '') }">
{{ groupInfo.name?.charAt(0) || '群' }}
</view>
<text class="group-name">{{ groupInfo.name }}</text>
<text class="member-count">{{ groupInfo.member_count || 0 }} </text>
</view>
<!-- 群成员 -->
<view class="members-section">
<view class="section-header" @click="goMembers">
<text class="title">群成员</text>
<text class="more">全部 {{ groupInfo.member_count }} </text>
</view>
<view class="members-grid">
<view
v-for="member in displayMembers"
:key="member.user_id"
class="member-item"
>
<wd-img
v-if="member.user?.avatar"
:src="member.user.avatar"
width="80rpx"
height="80rpx"
radius="8rpx"
/>
<view v-else class="member-avatar" :style="{ background: generateColor(member.user?.name || '') }">
{{ member.user?.name?.charAt(0) || '?' }}
</view>
<text class="member-name">{{ member.nickname || member.user?.name || '未知' }}</text>
</view>
<view class="member-item add-btn" @click="inviteMembers">
<view class="add-icon">
<wd-icon name="add" size="40rpx" color="var(--text-tertiary)" />
</view>
<text class="member-name">邀请</text>
</view>
</view>
</view>
<!-- 群设置 -->
<wd-cell-group title="群设置">
<wd-cell title="群名称" :value="groupInfo.name" is-link @click="editName" />
<wd-cell title="群公告" :value="announcement || '未设置'" is-link @click="editAnnouncement" />
<wd-cell title="消息免打扰" center>
<template #value>
<wd-switch v-model="isMuted" @change="toggleMute" />
</template>
</wd-cell>
<wd-cell title="置顶聊天" center>
<template #value>
<wd-switch v-model="isTop" @change="toggleTop" />
</template>
</wd-cell>
</wd-cell-group>
<!-- 操作按钮 -->
<view class="actions-section">
<wd-button type="error" block plain @click="quitGroup">退出群聊</wd-button>
</view>
</template>
<wd-toast />
<wd-message-box />
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import * as roomApi from '@/api/modules/room'
import { generateColor } from '@/utils/format'
import { useToast, useMessage } from 'wot-design-uni'
import type { GroupInfo, GroupMember } from '@/types/api'
const toast = useToast()
const messageBox = useMessage()
const loading = ref(true)
const groupInfo = ref<GroupInfo | null>(null)
const members = ref<GroupMember[]>([])
const announcement = ref('')
const isMuted = ref(false)
const isTop = ref(false)
const roomId = ref('')
const displayMembers = computed(() => members.value.slice(0, 8))
onLoad((options: any) => {
roomId.value = options?.roomId || ''
})
onMounted(async () => {
if (roomId.value) {
await loadGroupInfo()
}
})
async function loadGroupInfo() {
loading.value = true
try {
const [info, memberList, announcementRes] = await Promise.all([
roomApi.getGroup(roomId.value),
roomApi.getGroupMembers(roomId.value),
roomApi.getGroupAnnouncement(roomId.value).catch(() => ({ announcement: '' }))
])
groupInfo.value = info
members.value = memberList
announcement.value = announcementRes.announcement
} catch (error) {
console.error('加载群信息失败:', error)
toast.error('加载失败')
} finally {
loading.value = false
}
}
function goMembers() {
uni.navigateTo({ url: `/pages/group/members?roomId=${roomId.value}` })
}
function inviteMembers() {
toast.show('邀请功能开发中')
}
async function editName() {
if (!groupInfo.value) return
try {
const { value } = await messageBox.prompt({
title: '修改群名称',
inputValue: groupInfo.value.name,
inputPlaceholder: '请输入群名称'
})
await roomApi.updateGroup(roomId.value, { name: value })
groupInfo.value.name = value
toast.success('修改成功')
} catch (e: any) {
if (e !== 'cancel') {
toast.error('修改失败')
}
}
}
async function editAnnouncement() {
try {
const { value } = await messageBox.prompt({
title: '修改群公告',
inputValue: announcement.value,
inputPlaceholder: '请输入群公告'
})
await roomApi.updateGroupAnnouncement(roomId.value, { announcement: value })
announcement.value = value
toast.success('修改成功')
} catch (e: any) {
if (e !== 'cancel') {
toast.error('修改失败')
}
}
}
function toggleMute() {
toast.show(isMuted.value ? '已开启免打扰' : '已关闭免打扰')
}
function toggleTop() {
toast.show(isTop.value ? '已置顶' : '已取消置顶')
}
async function quitGroup() {
try {
await messageBox.confirm({
title: '提示',
msg: '确定要退出该群聊吗?'
})
await roomApi.quitGroup(roomId.value)
toast.success('已退出')
setTimeout(() => {
uni.navigateBack({ delta: 2 })
}, 1000)
} catch (e: any) {
if (e !== 'cancel') {
toast.error('操作失败')
}
}
}
</script>
<style lang="scss" scoped>
.info-page {
min-height: 100vh;
background: var(--bg-page);
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
.header-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx;
background: var(--bg-content);
margin-bottom: 20rpx;
.avatar-placeholder {
width: 120rpx;
height: 120rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16rpx;
color: #fff;
font-size: 48rpx;
font-weight: 600;
}
.group-name {
margin-top: 20rpx;
font-size: 36rpx;
font-weight: 600;
color: var(--text-primary);
}
.member-count {
margin-top: 8rpx;
font-size: 26rpx;
color: var(--text-tertiary);
}
}
.members-section {
background: var(--bg-content);
margin-bottom: 20rpx;
padding: 30rpx;
.section-header {
display: flex;
justify-content: space-between;
margin-bottom: 24rpx;
.title {
font-size: 28rpx;
color: var(--text-tertiary);
}
.more {
font-size: 28rpx;
color: var(--text-link);
}
}
.members-grid {
display: flex;
flex-wrap: wrap;
gap: 24rpx;
}
.member-item {
width: 120rpx;
display: flex;
flex-direction: column;
align-items: center;
.member-avatar {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
color: #fff;
font-size: 28rpx;
font-weight: 600;
}
.member-name {
margin-top: 8rpx;
font-size: 24rpx;
color: var(--text-secondary);
max-width: 120rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.add-btn {
.add-icon {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx dashed var(--border-color);
border-radius: 8rpx;
}
}
}
}
.actions-section {
padding: 60rpx 30rpx;
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
}
</style>

123
src/pages/group/members.vue Normal file
View File

@@ -0,0 +1,123 @@
<template>
<view class="members-page">
<!-- 搜索框 -->
<view class="search-section">
<wd-search
v-model="keyword"
placeholder="搜索成员"
hide-cancel
/>
</view>
<view v-if="loading" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<template v-else>
<wd-cell
v-for="member in filteredMembers"
:key="member.user_id"
:title="member.nickname || member.user?.name || '未知'"
is-link
>
<template #icon>
<wd-img
v-if="member.user?.avatar"
:src="member.user.avatar"
width="80rpx"
height="80rpx"
radius="8rpx"
custom-style="margin-right: 24rpx;"
/>
<view v-else class="avatar-placeholder" :style="{ background: generateColor(member.user?.name || '') }">
{{ member.user?.name?.charAt(0) || '?' }}
</view>
</template>
<template #value>
<wd-tag v-if="member.role === 2" type="warning" size="small">群主</wd-tag>
<wd-tag v-else-if="member.role === 1" type="info" size="small">管理员</wd-tag>
</template>
</wd-cell>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import * as roomApi from '@/api/modules/room'
import { generateColor } from '@/utils/format'
import type { GroupMember } from '@/types/api'
const loading = ref(true)
const keyword = ref('')
const members = ref<GroupMember[]>([])
const roomId = ref('')
const filteredMembers = computed(() => {
if (!keyword.value.trim()) return members.value
const kw = keyword.value.trim().toLowerCase()
return members.value.filter((m) => {
const name = (m.nickname || m.user?.name || '').toLowerCase()
return name.includes(kw)
})
})
onLoad((options: any) => {
roomId.value = options?.roomId || ''
})
onMounted(async () => {
if (roomId.value) {
await loadMembers()
}
})
async function loadMembers() {
loading.value = true
try {
members.value = await roomApi.getGroupMembers(roomId.value)
// 按角色排序:群主 > 管理员 > 普通成员
members.value.sort((a, b) => b.role - a.role)
} catch (error) {
console.error('加载群成员失败:', error)
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
.members-page {
min-height: 100vh;
background: var(--bg-page);
}
.search-section {
background: var(--bg-content);
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
.avatar-placeholder {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
margin-right: 24rpx;
color: #fff;
font-size: 32rpx;
font-weight: 600;
}
</style>

116
src/pages/group/notify.vue Normal file
View File

@@ -0,0 +1,116 @@
<template>
<view class="notify-page">
<view v-if="loading" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<wd-status-tip v-else-if="notifications.length === 0" tip="暂无群通知" />
<template v-else>
<view
v-for="item in notifications"
:key="item.id"
class="notify-item"
:class="{ unread: !item.is_read }"
>
<view class="notify-content">{{ item.content }}</view>
<view class="notify-time">{{ formatTime(item.created_at) }}</view>
</view>
<wd-loadmore :state="loadState" @loadmore="loadMore" />
</template>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as roomApi from '@/api/modules/room'
import { formatTime } from '@/utils/format'
interface GroupNotification {
id: number
room_id: string
content: string
is_read?: boolean
created_at: string
}
const loading = ref(true)
const notifications = ref<GroupNotification[]>([])
const loadState = ref<'loading' | 'finished' | 'error'>('loading')
const page = ref(1)
const hasMore = ref(true)
onMounted(async () => {
await loadNotifications()
})
async function loadNotifications() {
loading.value = true
try {
const res = await roomApi.getGroupNotifications({ page: 1, page_size: 20 })
notifications.value = res.data || []
hasMore.value = notifications.value.length < res.total
loadState.value = hasMore.value ? 'loading' : 'finished'
} catch (error) {
console.error('加载群通知失败:', error)
loadState.value = 'error'
} finally {
loading.value = false
}
}
async function loadMore() {
if (!hasMore.value || loading.value) return
page.value++
try {
const res = await roomApi.getGroupNotifications({ page: page.value, page_size: 20 })
notifications.value = [...notifications.value, ...(res.data || [])]
hasMore.value = notifications.value.length < res.total
loadState.value = hasMore.value ? 'loading' : 'finished'
} catch {
page.value--
loadState.value = 'error'
}
}
</script>
<style lang="scss" scoped>
.notify-page {
min-height: 100vh;
background: var(--bg-page);
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
.notify-item {
padding: 24rpx 30rpx;
background: var(--bg-content);
border-bottom: 1rpx solid var(--divider-color);
&.unread {
background: var(--bg-hover);
}
.notify-content {
font-size: 30rpx;
color: var(--text-primary);
margin-bottom: 12rpx;
}
.notify-time {
font-size: 24rpx;
color: var(--text-tertiary);
}
}
</style>

20
src/pages/index.vue Normal file
View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import AppFooter from '@/components/AppFooter.vue'
import AppLogos from '@/components/AppLogos.vue'
import InputEntry from '@/components/InputEntry.vue'
</script>
<template>
<view class="root-container">
<AppLogos />
<InputEntry />
<AppFooter />
</view>
</template>
<style scoped>
.root-container {
padding: 5rem 2.5rem;
text-align: center;
}
</style>

539
src/pages/index/index.vue Normal file
View File

@@ -0,0 +1,539 @@
<template>
<view class="page-container" :class="{ dark: isDark }">
<!-- 自定义导航栏 -->
<view class="custom-navbar">
<view class="navbar-content">
<!-- 左侧头像 -->
<view class="navbar-left" @click="openDrawer">
<wd-img
v-if="user?.avatar"
:src="user.avatar"
width="64rpx"
height="64rpx"
round
/>
<view v-else class="avatar-placeholder avatar-sm">
{{ user?.name?.charAt(0) || '?' }}
</view>
</view>
<!-- 标题 -->
<view class="navbar-title">消息</view>
<!-- 右侧加号 -->
<view class="navbar-right">
<wd-icon name="add" size="48rpx" @click="showPlusMenu = true" />
</view>
</view>
</view>
<!-- 搜索栏 -->
<view class="search-bar">
<wd-search
v-model="searchKeyword"
placeholder="搜索"
disabled
hide-cancel
@click="goSearch"
/>
</view>
<!-- 会话列表 -->
<scroll-view
class="conversation-list"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<view v-if="loading && conversations.length === 0" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<wd-status-tip v-else-if="conversations.length === 0" tip="暂无消息" />
<template v-else>
<wd-swipe-action
v-for="item in conversations"
:key="item.id"
>
<view class="conversation-item" @click="goChat(item)">
<!-- 头像 -->
<view class="avatar-wrap">
<wd-img
v-if="item.avatar"
:src="item.avatar"
width="96rpx"
height="96rpx"
radius="8rpx"
/>
<view v-else class="avatar-placeholder avatar-md" :style="{ background: generateColor(item.name || '') }">
{{ item.name?.charAt(0) || '?' }}
</view>
<!-- 未读数 -->
<wd-badge
v-if="item.unread_count > 0 && !item.is_muted"
:value="item.unread_count"
:max="99"
class="unread-badge"
/>
<view v-else-if="item.unread_count > 0 && item.is_muted" class="unread-dot" />
</view>
<!-- 内容 -->
<view class="conversation-content">
<view class="conversation-header">
<text class="conversation-name text-ellipsis">{{ item.name || '未知' }}</text>
<text class="conversation-time">{{ formatMessageTime(item.last_time) }}</text>
</view>
<view class="conversation-footer">
<text class="conversation-msg text-ellipsis">{{ item.last_message || '' }}</text>
<wd-icon v-if="item.is_muted" name="volume-mute" size="28rpx" class="mute-icon" />
</view>
</view>
</view>
<!-- 右滑操作 -->
<template #right>
<view class="swipe-actions">
<view class="swipe-action action-top" @click="toggleTop(item)">
{{ item.is_top ? '取消置顶' : '置顶' }}
</view>
<view class="swipe-action action-delete" @click="deleteConversation(item)">
删除
</view>
</view>
</template>
</wd-swipe-action>
</template>
</scroll-view>
<!-- 加号菜单 -->
<wd-action-sheet
v-model="showPlusMenu"
:actions="plusMenuActions"
@select="onPlusMenuSelect"
/>
<!-- 侧边抽屉 -->
<wd-popup
v-model="showDrawer"
position="left"
custom-style="width: 70%; height: 100%;"
>
<view class="drawer-content">
<!-- 用户信息 -->
<view class="drawer-header">
<wd-img
v-if="user?.avatar"
:src="user.avatar"
width="120rpx"
height="120rpx"
round
/>
<view v-else class="avatar-placeholder avatar-lg">
{{ user?.name?.charAt(0) || '?' }}
</view>
<text class="drawer-name">{{ user?.name || '未登录' }}</text>
<text class="drawer-desc">{{ user?.desc || '这个人很懒,什么都没写' }}</text>
</view>
<!-- 菜单列表 -->
<view class="drawer-menu">
<wd-cell title="个人资料" icon="user" is-link @click="goProfile" />
<wd-cell title="设置" icon="setting" is-link @click="goSettings" />
<wd-cell title="深色模式" icon="picture" center>
<template #value>
<wd-switch v-model="isDark" @change="toggleTheme" />
</template>
</wd-cell>
</view>
<!-- 退出登录 -->
<view class="drawer-footer">
<wd-button type="error" block plain @click="logout">退出登录</wd-button>
</view>
</view>
</wd-popup>
<!-- Toast 组件 -->
<wd-toast />
<wd-message-box />
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useAuthStore, useConversationStore } from '@/stores'
import { useTheme } from '@/composables/useTheme'
import { formatMessageTime, generateColor } from '@/utils/format'
import { useToast, useMessage } from 'wot-design-uni'
import type { Conversation } from '@/types/conversation'
const authStore = useAuthStore()
const conversationStore = useConversationStore()
const { isDark, toggleTheme } = useTheme()
const toast = useToast()
const messageBox = useMessage()
// 状态
const searchKeyword = ref('')
const showPlusMenu = ref(false)
const showDrawer = ref(false)
const refreshing = ref(false)
// 计算属性
const user = computed(() => authStore.user)
const conversations = computed(() => conversationStore.conversations)
const loading = computed(() => conversationStore.loading)
// 加号菜单选项
const plusMenuActions = [
{ name: '创建群聊', value: 'createGroup' },
{ name: '添加好友', value: 'addFriend' },
{ name: '扫一扫', value: 'scan' }
]
// 生命周期
onMounted(() => {
loadData()
})
onShow(() => {
loadData()
})
// 方法
async function loadData() {
if (!authStore.isAuthenticated) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
await conversationStore.loadConversations()
}
async function onRefresh() {
refreshing.value = true
await conversationStore.loadConversations()
refreshing.value = false
}
function goSearch() {
uni.navigateTo({ url: '/pages/search/index' })
}
function goChat(item: Conversation) {
conversationStore.clearUnread(item.target_id)
uni.navigateTo({
url: `/pages/chat/index?roomId=${item.room_id || ''}&targetId=${item.target_id}&name=${encodeURIComponent(item.name || '')}`
})
}
function toggleTop(item: Conversation) {
toast.show(item.is_top ? '已取消置顶' : '已置顶')
}
async function deleteConversation(item: Conversation) {
try {
await messageBox.confirm({
title: '提示',
msg: '确定删除该会话吗?'
})
toast.success('已删除')
} catch {
// 取消
}
}
function onPlusMenuSelect(item: { name: string; value: string }) {
showPlusMenu.value = false
switch (item.value) {
case 'createGroup':
uni.navigateTo({ url: '/pages/group/create' })
break
case 'addFriend':
uni.navigateTo({ url: '/pages/contact/add' })
break
case 'scan':
uni.scanCode({
success: (res) => {
console.log('扫码结果:', res.result)
toast.show('扫码成功')
},
fail: () => {
toast.error('扫码失败')
}
})
break
}
}
function openDrawer() {
showDrawer.value = true
}
function goProfile() {
showDrawer.value = false
uni.navigateTo({ url: '/pages/profile/index' })
}
function goSettings() {
showDrawer.value = false
uni.navigateTo({ url: '/pages/settings/index' })
}
async function logout() {
try {
await messageBox.confirm({
title: '提示',
msg: '确定要退出登录吗?'
})
showDrawer.value = false
authStore.logout()
} catch {
// 取消
}
}
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: var(--bg-page);
}
// 自定义导航栏
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: var(--nav-bg);
padding-top: var(--status-bar-height, 0);
.navbar-content {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 30rpx;
}
.navbar-left {
margin-right: 20rpx;
}
.navbar-title {
flex: 1;
font-size: 36rpx;
font-weight: 600;
color: var(--text-primary);
}
.navbar-right {
color: var(--text-primary);
}
}
// 搜索栏
.search-bar {
position: fixed;
top: calc(88rpx + var(--status-bar-height, 0));
left: 0;
right: 0;
z-index: 99;
padding: 10rpx 20rpx;
background: var(--nav-bg);
}
// 会话列表
.conversation-list {
position: fixed;
top: calc(88rpx + 80rpx + var(--status-bar-height, 0));
left: 0;
right: 0;
bottom: 0;
background: var(--bg-content);
}
.conversation-item {
display: flex;
align-items: center;
padding: 24rpx 30rpx;
background: var(--bg-content);
border-bottom: 1rpx solid var(--divider-color);
&:active {
background: var(--bg-hover);
}
}
.avatar-wrap {
position: relative;
margin-right: 24rpx;
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
font-weight: 600;
&.avatar-sm {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
font-size: 28rpx;
}
&.avatar-md {
width: 96rpx;
height: 96rpx;
border-radius: 8rpx;
font-size: 36rpx;
}
&.avatar-lg {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
font-size: 48rpx;
}
}
.unread-badge {
position: absolute;
top: -8rpx;
right: -8rpx;
}
.unread-dot {
position: absolute;
top: 0;
right: 0;
width: 16rpx;
height: 16rpx;
background: var(--color-primary);
border-radius: 50%;
}
.conversation-content {
flex: 1;
min-width: 0;
}
.conversation-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
}
.conversation-name {
flex: 1;
font-size: 32rpx;
color: var(--text-primary);
font-weight: 500;
}
.conversation-time {
font-size: 24rpx;
color: var(--text-tertiary);
margin-left: 16rpx;
}
.conversation-footer {
display: flex;
align-items: center;
}
.conversation-msg {
flex: 1;
font-size: 28rpx;
color: var(--text-secondary);
}
.mute-icon {
color: var(--text-tertiary);
margin-left: 8rpx;
}
// 滑动操作
.swipe-actions {
display: flex;
height: 100%;
}
.swipe-action {
display: flex;
align-items: center;
justify-content: center;
width: 150rpx;
height: 100%;
color: #fff;
font-size: 28rpx;
&.action-top {
background: #c8c8c8;
}
&.action-delete {
background: var(--color-danger);
}
}
// 抽屉
.drawer-content {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-page);
}
.drawer-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 30rpx 40rpx;
background: var(--bg-content);
}
.drawer-name {
margin-top: 20rpx;
font-size: 36rpx;
font-weight: 600;
color: var(--text-primary);
}
.drawer-desc {
margin-top: 8rpx;
font-size: 26rpx;
color: var(--text-secondary);
}
.drawer-menu {
flex: 1;
margin-top: 20rpx;
background: var(--bg-content);
}
.drawer-footer {
padding: 30rpx;
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
}
// 加载和空状态
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
</style>

264
src/pages/login/index.vue Normal file
View File

@@ -0,0 +1,264 @@
<template>
<view class="login-page">
<!-- Logo -->
<view class="logo-section">
<view class="logo">
<wd-icon name="chat" size="120rpx" color="#07c160" />
</view>
<text class="app-name">NL-IM</text>
<text class="app-desc">即时通讯</text>
</view>
<!-- 登录表单 -->
<view class="form-section">
<wd-cell-group border>
<wd-input
v-model="form.account"
label="账号"
placeholder="请输入手机号/邮箱"
clearable
:prefix-icon="'user'"
/>
<wd-input
v-model="form.password"
label="密码"
type="password"
placeholder="请输入密码"
clearable
show-password
:prefix-icon="'lock'"
/>
</wd-cell-group>
<view class="form-options">
<wd-checkbox v-model="form.remember">记住登录</wd-checkbox>
<text class="forgot-link" @click="forgotPassword">忘记密码</text>
</view>
<wd-button
type="primary"
block
:loading="loading"
:disabled="!canLogin"
@click="handleLogin"
>
登录
</wd-button>
<view class="register-link">
<text>还没有账号</text>
<text class="link" @click="goRegister">立即注册</text>
</view>
</view>
<!-- 其他登录方式 -->
<view class="other-login">
<view class="divider">
<view class="line" />
<text class="text">其他登录方式</text>
<view class="line" />
</view>
<view class="login-icons">
<view class="login-icon" @click="loginWithWeixin">
<wd-icon name="weixin" size="56rpx" color="#07c160" />
</view>
</view>
</view>
<!-- Toast MessageBox -->
<wd-toast />
<wd-message-box />
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useAuthStore } from '@/stores'
import { useToast, useMessage } from 'wot-design-uni'
const authStore = useAuthStore()
const toast = useToast()
const message = useMessage()
// 表单数据
const form = ref({
account: '',
password: '',
remember: true
})
const loading = ref(false)
// 是否可以登录
const canLogin = computed(() => {
return form.value.account.trim() && form.value.password.trim()
})
// 登录
async function handleLogin() {
if (!canLogin.value) return
loading.value = true
try {
await authStore.login({
account: form.value.account.trim(),
password: form.value.password,
remember: form.value.remember
})
toast.success('登录成功')
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 1000)
} catch (error: any) {
toast.error(error.message || '登录失败')
} finally {
loading.value = false
}
}
// 忘记密码
function forgotPassword() {
toast.show('忘记密码功能开发中')
}
// 去注册
function goRegister() {
toast.show('注册功能开发中')
}
// 微信登录
function loginWithWeixin() {
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
success: (res) => {
console.log('微信登录:', res.code)
toast.show('微信登录开发中')
},
fail: () => {
toast.error('微信登录失败')
}
})
// #endif
// #ifndef MP-WEIXIN
toast.show('请在微信小程序中使用此功能')
// #endif
}
</script>
<style lang="scss" scoped>
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-page);
padding: 0 40rpx;
}
// Logo 区域
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 150rpx;
margin-bottom: 80rpx;
.logo {
width: 160rpx;
height: 160rpx;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-content);
border-radius: 32rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
}
.app-name {
margin-top: 24rpx;
font-size: 48rpx;
font-weight: 700;
color: var(--text-primary);
}
.app-desc {
margin-top: 8rpx;
font-size: 28rpx;
color: var(--text-secondary);
}
}
// 表单区域
.form-section {
background: var(--bg-content);
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 40rpx;
.form-options {
display: flex;
align-items: center;
justify-content: space-between;
margin: 30rpx 0;
.forgot-link {
font-size: 28rpx;
color: var(--color-primary);
}
}
.register-link {
text-align: center;
margin-top: 30rpx;
font-size: 28rpx;
color: var(--text-secondary);
.link {
color: var(--color-primary);
margin-left: 8rpx;
}
}
}
// 其他登录方式
.other-login {
margin-top: auto;
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
.divider {
display: flex;
align-items: center;
margin-bottom: 40rpx;
.line {
flex: 1;
height: 1rpx;
background: var(--border-color);
}
.text {
padding: 0 24rpx;
font-size: 26rpx;
color: var(--text-tertiary);
}
}
.login-icons {
display: flex;
justify-content: center;
gap: 60rpx;
.login-icon {
width: 96rpx;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-content);
border-radius: 50%;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
}
}
}
</style>

396
src/pages/moment/detail.vue Normal file
View File

@@ -0,0 +1,396 @@
<template>
<view class="detail-page">
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
<template v-else-if="moment">
<view class="moment-content">
<!-- 用户信息 -->
<view class="header">
<image
v-if="moment.user?.avatar"
class="avatar"
:src="moment.user.avatar"
mode="aspectFill"
/>
<view v-else class="avatar avatar-placeholder">
{{ moment.user?.name?.charAt(0) || '?' }}
</view>
<view class="user-info">
<text class="name">{{ moment.user?.name || '未知' }}</text>
<text class="time">{{ formatTime(moment.created_at) }}</text>
</view>
</view>
<!-- 内容 -->
<view class="content">
<text>{{ moment.content }}</text>
</view>
<!-- 图片 -->
<view v-if="moment.media_type === 1" class="images">
<image
v-for="(url, index) in parseMediaUrls(moment.media_urls)"
:key="index"
class="image"
:src="url"
mode="aspectFill"
@click="previewImages(parseMediaUrls(moment.media_urls), index)"
/>
</view>
<!-- 视频 -->
<view v-if="moment.media_type === 2" class="video">
<video
:src="parseMediaUrls(moment.media_urls)[0]"
class="video-player"
object-fit="cover"
/>
</view>
<!-- 操作栏 -->
<view class="actions">
<view class="action-item" @click="toggleLike">
<text :class="{ liked: moment.is_liked }">{{ moment.is_liked ? '❤️' : '🤍' }}</text>
<text>{{ moment.like_count || 0 }}</text>
</view>
<view class="action-item">
<text>💬</text>
<text>{{ moment.comment_count || 0 }}</text>
</view>
</view>
</view>
<!-- 评论列表 -->
<view class="comments-section">
<view class="section-title">评论 ({{ moment.comment_count || 0 }})</view>
<view v-if="moment.comments?.length === 0" class="empty-comments">
<text>暂无评论</text>
</view>
<view v-else class="comments-list">
<view
v-for="comment in moment.comments"
:key="comment.id"
class="comment-item"
>
<image
v-if="comment.user?.avatar"
class="avatar"
:src="comment.user.avatar"
mode="aspectFill"
/>
<view v-else class="avatar avatar-placeholder">
{{ comment.user?.name?.charAt(0) || '?' }}
</view>
<view class="comment-content">
<view class="comment-header">
<text class="name">{{ comment.user?.name || '未知' }}</text>
<text class="time">{{ formatTime(comment.created_at) }}</text>
</view>
<view class="comment-text">
<text v-if="comment.reply_to_user" class="reply-to">
回复 {{ comment.reply_to_user.name }}
</text>
<text>{{ comment.content }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 评论输入框 -->
<view class="comment-input-bar">
<input
v-model="commentText"
class="input"
type="text"
placeholder="发表评论..."
confirm-type="send"
@confirm="sendComment"
/>
<button class="send-btn" @click="sendComment">发送</button>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMomentStore } from '@/stores'
import { formatTime } from '@/utils/format'
import { parseMediaUrls } from '@/types/moment'
import type { Moment } from '@/types/moment'
const momentStore = useMomentStore()
const loading = ref(true)
const moment = ref<Moment | null>(null)
const commentText = ref('')
const momentId = ref(0)
onMounted(async () => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = (currentPage as any).$page?.options || {}
momentId.value = parseInt(options.id || '0')
if (momentId.value) {
await loadDetail()
}
})
async function loadDetail() {
loading.value = true
try {
await momentStore.fetchMomentDetail(momentId.value)
moment.value = momentStore.currentMoment
} catch (error) {
console.error('加载动态详情失败:', error)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function previewImages(urls: string[], current: number) {
uni.previewImage({ urls, current })
}
async function toggleLike() {
if (!moment.value) return
try {
await momentStore.toggleLike(moment.value.id)
} catch {
// 忽略错误
}
}
async function sendComment() {
if (!commentText.value.trim() || !moment.value) return
try {
await momentStore.addComment(moment.value.id, { content: commentText.value.trim() })
commentText.value = ''
// 刷新评论
await momentStore.refreshComments(moment.value.id)
} catch {
uni.showToast({ title: '评论失败', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.detail-page {
min-height: 100vh;
background: #ededed;
padding-bottom: 120rpx;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 60rpx;
color: #999;
}
.moment-content {
background: #fff;
padding: 30rpx;
margin-bottom: 20rpx;
.header {
display: flex;
margin-bottom: 20rpx;
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 8rpx;
margin-right: 20rpx;
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: #07c160;
color: #fff;
font-size: 32rpx;
}
.user-info {
.name {
display: block;
font-size: 32rpx;
color: #576b95;
font-weight: 500;
}
.time {
display: block;
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
}
}
.content {
font-size: 32rpx;
color: #333;
line-height: 1.6;
margin-bottom: 20rpx;
}
.images {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
margin-bottom: 20rpx;
.image {
width: 200rpx;
height: 200rpx;
border-radius: 8rpx;
}
}
.video {
margin-bottom: 20rpx;
.video-player {
width: 100%;
height: 400rpx;
border-radius: 8rpx;
}
}
.actions {
display: flex;
gap: 48rpx;
.action-item {
display: flex;
align-items: center;
gap: 8rpx;
text {
font-size: 28rpx;
color: #666;
&.liked {
color: #fa5151;
}
}
}
}
}
.comments-section {
background: #fff;
padding: 30rpx;
.section-title {
font-size: 28rpx;
color: #999;
margin-bottom: 24rpx;
}
.empty-comments {
text-align: center;
padding: 40rpx;
color: #999;
}
}
.comment-item {
display: flex;
margin-bottom: 24rpx;
.avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
margin-right: 16rpx;
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: #07c160;
color: #fff;
font-size: 24rpx;
}
.comment-content {
flex: 1;
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 8rpx;
.name {
font-size: 28rpx;
color: #576b95;
}
.time {
font-size: 24rpx;
color: #999;
}
}
.comment-text {
font-size: 28rpx;
color: #333;
.reply-to {
color: #576b95;
}
}
}
}
.comment-input-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
padding: 16rpx 20rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
.input {
flex: 1;
height: 72rpx;
padding: 0 24rpx;
background: #f5f5f5;
border-radius: 36rpx;
font-size: 28rpx;
}
.send-btn {
margin-left: 16rpx;
padding: 0 32rpx;
height: 72rpx;
line-height: 72rpx;
background: #07c160;
color: #fff;
font-size: 28rpx;
border-radius: 36rpx;
&::after {
border: none;
}
}
}
</style>

552
src/pages/moment/index.vue Normal file
View File

@@ -0,0 +1,552 @@
<template>
<view class="page-container" :class="{ dark: isDark }">
<!-- 自定义导航栏 -->
<view class="custom-navbar">
<view class="navbar-content">
<view class="navbar-title">朋友圈</view>
<view class="navbar-right">
<wd-badge :value="unreadCount" :max="99" v-if="unreadCount > 0">
<wd-icon name="bell" size="44rpx" @click="goNotifications" />
</wd-badge>
<wd-icon v-else name="bell" size="44rpx" @click="goNotifications" />
</view>
</view>
</view>
<!-- 内容区域 -->
<scroll-view
class="content-scroll"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<!-- 封面区域 -->
<view class="cover-section">
<image class="cover-bg" src="/static/images/moment-cover.jpg" mode="aspectFill" />
<view class="user-info">
<wd-img
v-if="user?.avatar"
:src="user.avatar"
width="120rpx"
height="120rpx"
radius="8rpx"
/>
<view v-else class="avatar-placeholder avatar-lg">
{{ user?.name?.charAt(0) || '?' }}
</view>
<text class="user-name">{{ user?.name || '未知' }}</text>
</view>
</view>
<!-- 动态列表 -->
<view class="moments-list">
<view v-if="loading && moments.length === 0" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<wd-status-tip v-else-if="moments.length === 0" tip="暂无动态" />
<template v-else>
<view
v-for="moment in moments"
:key="moment.id"
class="moment-card"
>
<!-- 用户头像 -->
<view class="moment-avatar">
<wd-img
v-if="moment.user?.avatar"
:src="moment.user.avatar"
width="80rpx"
height="80rpx"
radius="8rpx"
/>
<view v-else class="avatar-placeholder" :style="{ background: generateColor(moment.user?.name || '') }">
{{ moment.user?.name?.charAt(0) || '?' }}
</view>
</view>
<!-- 动态内容 -->
<view class="moment-content">
<!-- 用户名 -->
<text class="moment-username">{{ moment.user?.name || '未知' }}</text>
<!-- 文本内容 -->
<view v-if="moment.content" class="moment-text">
<text>{{ moment.content }}</text>
</view>
<!-- 图片 -->
<view v-if="moment.media_type === 1" class="moment-images" :class="`images-${getImageCount(moment)}`">
<wd-img
v-for="(url, index) in parseMediaUrls(moment.media_urls)"
:key="index"
:src="url"
:width="getImageSize(moment)"
:height="getImageSize(moment)"
mode="aspectFill"
radius="8rpx"
enable-preview
:preview-src-list="parseMediaUrls(moment.media_urls)"
/>
</view>
<!-- 视频 -->
<view v-if="moment.media_type === 2" class="moment-video">
<video
:src="parseMediaUrls(moment.media_urls)[0]"
class="video-player"
object-fit="cover"
:show-fullscreen-btn="true"
:show-play-btn="true"
/>
</view>
<!-- 时间和操作 -->
<view class="moment-footer">
<text class="moment-time">{{ formatTime(moment.created_at) }}</text>
<view class="moment-actions">
<view class="action-item" @click="showActionMenu(moment)">
<wd-icon name="more" size="32rpx" />
</view>
</view>
</view>
<!-- 点赞和评论 -->
<view v-if="(moment.likes && moment.likes.length > 0) || (moment.comments && moment.comments.length > 0)" class="moment-interactions">
<!-- 点赞 -->
<view v-if="moment.likes && moment.likes.length > 0" class="likes-row">
<wd-icon name="heart-fill" size="28rpx" color="#fa5151" />
<text class="likes-text">
{{ moment.likes.map(l => l.user?.name || '').join('、') }}
</text>
</view>
<!-- 评论 -->
<view
v-for="comment in moment.comments"
:key="comment.id"
class="comment-row"
>
<text class="comment-user">{{ comment.user?.name || '未知' }}</text>
<text v-if="comment.reply_to_user">
回复 <text class="comment-user">{{ comment.reply_to_user.name }}</text>
</text>
<text>: {{ comment.content }}</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<wd-loadmore :state="loadState" @loadmore="loadMore" />
</template>
</view>
</scroll-view>
<!-- 发布按钮 -->
<view class="fab" @click="goPublish">
<wd-icon name="camera" size="48rpx" color="#fff" />
</view>
<!-- 操作菜单 -->
<wd-action-sheet
v-model="showActions"
:actions="actionItems"
@select="onActionSelect"
/>
<wd-toast />
<wd-message-box />
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useAuthStore, useMomentStore } from '@/stores'
import { useTheme } from '@/composables/useTheme'
import { formatTime, generateColor } from '@/utils/format'
import { parseMediaUrls } from '@/types/moment'
import { useToast, useMessage } from 'wot-design-uni'
import type { Moment } from '@/types/moment'
const authStore = useAuthStore()
const momentStore = useMomentStore()
const { isDark } = useTheme()
const toast = useToast()
const messageBox = useMessage()
// 状态
const refreshing = ref(false)
const showActions = ref(false)
const currentMoment = ref<Moment | null>(null)
const loadState = ref<'loading' | 'finished' | 'error'>('loading')
// 计算属性
const user = computed(() => authStore.user)
const moments = computed(() => momentStore.moments)
const loading = computed(() => momentStore.loading)
const unreadCount = computed(() => momentStore.unreadCount)
// 操作菜单
const actionItems = computed(() => {
const items = []
if (currentMoment.value) {
items.push({
name: currentMoment.value.is_liked ? '取消赞' : '赞',
value: 'like'
})
items.push({ name: '评论', value: 'comment' })
if (currentMoment.value.user_id === user.value?.id) {
items.push({ name: '删除', value: 'delete', color: '#fa5151' })
}
}
return items
})
// 生命周期
onMounted(() => {
loadData()
})
onShow(() => {
loadUnreadCount()
})
// 方法
async function loadData() {
try {
await momentStore.fetchMoments()
loadState.value = momentStore.pagination.hasMore ? 'loading' : 'finished'
} catch {
loadState.value = 'error'
}
}
async function loadUnreadCount() {
try {
await momentStore.fetchUnreadCount()
} catch {
// ignore
}
}
async function onRefresh() {
refreshing.value = true
momentStore.pagination.page = 1
await loadData()
refreshing.value = false
}
async function loadMore() {
if (!momentStore.pagination.hasMore || loading.value) return
momentStore.pagination.page++
await momentStore.fetchMoreMoments()
loadState.value = momentStore.pagination.hasMore ? 'loading' : 'finished'
}
function goNotifications() {
uni.navigateTo({ url: '/pages/moment/notify' })
}
function goPublish() {
uni.navigateTo({ url: '/pages/moment/publish' })
}
function showActionMenu(moment: Moment) {
currentMoment.value = moment
showActions.value = true
}
async function onActionSelect(item: { value: string }) {
if (!currentMoment.value) return
switch (item.value) {
case 'like':
await toggleLike()
break
case 'comment':
goComment()
break
case 'delete':
await deleteMoment()
break
}
showActions.value = false
}
async function toggleLike() {
if (!currentMoment.value) return
try {
await momentStore.toggleLike(currentMoment.value.id)
} catch {
toast.error('操作失败')
}
}
function goComment() {
if (!currentMoment.value) return
uni.navigateTo({ url: `/pages/moment/detail?id=${currentMoment.value.id}` })
}
async function deleteMoment() {
if (!currentMoment.value) return
try {
await messageBox.confirm({
title: '提示',
msg: '确定要删除这条动态吗?'
})
await momentStore.deleteMoment(currentMoment.value.id)
toast.success('已删除')
} catch {
// 取消或失败
}
}
// 辅助函数
function getImageCount(moment: Moment): number {
const urls = parseMediaUrls(moment.media_urls)
return Math.min(urls.length, 9)
}
function getImageSize(moment: Moment): string {
const count = getImageCount(moment)
if (count === 1) return '400rpx'
if (count <= 4) return '200rpx'
return '150rpx'
}
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: var(--bg-page);
}
// 自定义导航栏
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: transparent;
padding-top: var(--status-bar-height, 0);
.navbar-content {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 30rpx;
}
.navbar-title {
flex: 1;
font-size: 36rpx;
font-weight: 600;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
}
.navbar-right {
color: #fff;
}
}
// 内容滚动区域
.content-scroll {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
// 封面区域
.cover-section {
position: relative;
height: 500rpx;
.cover-bg {
width: 100%;
height: 100%;
object-fit: cover;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.user-info {
position: absolute;
right: 30rpx;
bottom: -40rpx;
display: flex;
align-items: flex-end;
gap: 16rpx;
.avatar-lg {
width: 120rpx;
height: 120rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
background: var(--color-primary);
color: #fff;
font-size: 48rpx;
font-weight: 600;
border: 4rpx solid #fff;
}
.user-name {
font-size: 32rpx;
font-weight: 600;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
margin-bottom: 20rpx;
}
}
}
// 动态列表
.moments-list {
padding-top: 60rpx;
background: var(--bg-content);
min-height: calc(100vh - 500rpx);
}
.moment-card {
display: flex;
padding: 30rpx;
border-bottom: 1rpx solid var(--divider-color);
}
.moment-avatar {
margin-right: 24rpx;
.avatar-placeholder {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
color: #fff;
font-size: 32rpx;
font-weight: 600;
}
}
.moment-content {
flex: 1;
min-width: 0;
}
.moment-username {
font-size: 30rpx;
font-weight: 500;
color: var(--text-link);
margin-bottom: 12rpx;
display: block;
}
.moment-text {
font-size: 30rpx;
color: var(--text-primary);
line-height: 1.6;
margin-bottom: 16rpx;
}
.moment-images {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
margin-bottom: 16rpx;
}
.moment-video {
margin-bottom: 16rpx;
.video-player {
width: 100%;
height: 400rpx;
border-radius: 8rpx;
}
}
.moment-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
.moment-time {
font-size: 24rpx;
color: var(--text-tertiary);
}
.moment-actions {
.action-item {
padding: 8rpx;
color: var(--text-tertiary);
}
}
}
.moment-interactions {
background: var(--bg-hover);
border-radius: 8rpx;
padding: 16rpx;
.likes-row {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 26rpx;
color: var(--text-link);
margin-bottom: 8rpx;
.likes-text {
flex: 1;
}
}
.comment-row {
font-size: 26rpx;
color: var(--text-primary);
line-height: 1.8;
.comment-user {
color: var(--text-link);
}
}
}
// 发布按钮
.fab {
position: fixed;
right: 30rpx;
bottom: calc(180rpx + env(safe-area-inset-bottom));
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 20rpx rgba(7, 193, 96, 0.4);
z-index: 99;
}
// 加载状态
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
</style>

217
src/pages/moment/notify.vue Normal file
View File

@@ -0,0 +1,217 @@
<template>
<view class="notify-page">
<view v-if="loading && notifications.length === 0" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<wd-status-tip v-else-if="notifications.length === 0" tip="暂无消息通知" />
<template v-else>
<!-- 标记全部已读 -->
<view v-if="hasUnread" class="mark-all" @click="markAllRead">
<text>全部标为已读</text>
</view>
<view
v-for="item in notifications"
:key="item.id"
class="notify-item"
:class="{ unread: !item.is_read }"
@click="goMomentDetail(item)"
>
<wd-img
v-if="item.from_user?.avatar"
:src="item.from_user.avatar"
width="80rpx"
height="80rpx"
radius="8rpx"
/>
<view v-else class="avatar-placeholder" :style="{ background: generateColor(item.from_user?.name || '') }">
{{ item.from_user?.name?.charAt(0) || '?' }}
</view>
<view class="notify-content">
<view class="notify-header">
<text class="name">{{ item.from_user?.name || '未知用户' }}</text>
<text class="action">{{ getActionText(item.type) }}</text>
</view>
<text class="time">{{ formatTime(item.created_at) }}</text>
</view>
<!-- 动态缩略图 -->
<view v-if="item.moment" class="moment-thumb">
<wd-img
v-if="item.moment.media_urls"
:src="parseMediaUrls(item.moment.media_urls)[0]"
width="80rpx"
height="80rpx"
mode="aspectFill"
radius="8rpx"
/>
<text v-else class="moment-text">{{ item.moment.content?.slice(0, 20) }}</text>
</view>
</view>
<!-- 加载更多 -->
<wd-loadmore :state="loadState" @loadmore="loadMore" />
</template>
<wd-toast />
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useMomentStore } from '@/stores'
import { formatTime, generateColor } from '@/utils/format'
import { parseMediaUrls } from '@/types/moment'
import { useToast } from 'wot-design-uni'
import type { MomentNotification } from '@/types/moment'
const momentStore = useMomentStore()
const toast = useToast()
const loadState = ref<'loading' | 'finished' | 'error'>('loading')
const loading = computed(() => momentStore.loading)
const notifications = computed(() => momentStore.notifications)
const hasUnread = computed(() => notifications.value.some(n => !n.is_read))
onMounted(async () => {
await loadNotifications()
})
async function loadNotifications() {
try {
await momentStore.fetchNotifications(true)
loadState.value = momentStore.notifPagination.hasMore ? 'loading' : 'finished'
} catch {
loadState.value = 'error'
}
}
async function loadMore() {
if (!momentStore.notifPagination.hasMore || loading.value) return
await momentStore.fetchMoreNotifications()
loadState.value = momentStore.notifPagination.hasMore ? 'loading' : 'finished'
}
async function markAllRead() {
await momentStore.markAllAsRead()
toast.success('已全部标为已读')
}
function getActionText(type: number): string {
switch (type) {
case 1: return '赞了你的动态'
case 2: return '评论了你的动态'
case 3: return '回复了你的评论'
case 4: return '在动态中@了你'
default: return '与你互动'
}
}
function goMomentDetail(item: MomentNotification) {
// 标记已读
if (!item.is_read) {
momentStore.markAsRead([item.id])
}
// 跳转详情
uni.navigateTo({ url: `/pages/moment/detail?id=${item.moment_id}` })
}
</script>
<style lang="scss" scoped>
.notify-page {
min-height: 100vh;
background: var(--bg-page);
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
.mark-all {
padding: 20rpx 30rpx;
text-align: right;
text {
font-size: 28rpx;
color: var(--color-primary);
}
}
.notify-item {
display: flex;
align-items: center;
padding: 24rpx 30rpx;
background: var(--bg-content);
border-bottom: 1rpx solid var(--divider-color);
&.unread {
background: var(--bg-hover);
}
.avatar-placeholder {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
color: #fff;
font-size: 32rpx;
font-weight: 600;
}
}
.notify-content {
flex: 1;
margin-left: 24rpx;
min-width: 0;
.notify-header {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 8rpx;
.name {
font-size: 30rpx;
color: var(--text-link);
font-weight: 500;
}
.action {
font-size: 28rpx;
color: var(--text-primary);
}
}
.time {
font-size: 24rpx;
color: var(--text-tertiary);
}
}
.moment-thumb {
margin-left: 20rpx;
.moment-text {
display: block;
width: 80rpx;
font-size: 24rpx;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@@ -0,0 +1,313 @@
<template>
<view class="publish-page">
<!-- 导航栏 -->
<wd-navbar
title="发布动态"
left-arrow
fixed
placeholder
@click-left="goBack"
>
<template #right>
<wd-button
type="primary"
size="small"
:loading="publishing"
:disabled="!canPublish"
@click="publish"
>
发布
</wd-button>
</template>
</wd-navbar>
<!-- 内容输入 -->
<view class="content-section">
<wd-textarea
v-model="content"
placeholder="分享新鲜事..."
:maxlength="500"
show-word-limit
:rows="6"
no-border
/>
</view>
<!-- 图片/视频选择 -->
<view class="media-section">
<view class="media-list">
<view
v-for="(item, index) in mediaList"
:key="index"
class="media-item"
>
<wd-img
v-if="item.type === 'image'"
:src="item.path"
width="200rpx"
height="200rpx"
mode="aspectFill"
radius="8rpx"
/>
<video
v-else
:src="item.path"
class="video-preview"
object-fit="cover"
/>
<view class="remove-btn" @click="removeMedia(index)">
<wd-icon name="close" size="24rpx" color="#fff" />
</view>
</view>
<!-- 添加按钮 -->
<view v-if="mediaList.length < 9" class="add-media" @click="chooseMedia">
<wd-icon name="add" size="48rpx" color="var(--text-tertiary)" />
</view>
</view>
</view>
<!-- 可见范围 -->
<view class="options-section">
<wd-cell title="谁可以看" :value="visibilityText" is-link @click="showVisibilityPicker = true" />
<wd-cell title="所在位置" :value="location || '不显示位置'" is-link @click="chooseLocation" />
</view>
<!-- 可见范围选择器 -->
<wd-action-sheet
v-model="showVisibilityPicker"
:actions="visibilityOptions"
@select="onVisibilitySelect"
/>
<wd-toast />
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useMomentStore } from '@/stores'
import { useToast } from 'wot-design-uni'
const momentStore = useMomentStore()
const toast = useToast()
// 表单数据
const content = ref('')
const mediaList = ref<{ type: 'image' | 'video'; path: string }[]>([])
const visibility = ref(0) // 0=公开 1=仅好友 2=仅自己
const location = ref('')
// 状态
const publishing = ref(false)
const showVisibilityPicker = ref(false)
// 计算属性
const canPublish = computed(() => {
return content.value.trim() || mediaList.value.length > 0
})
const visibilityText = computed(() => {
switch (visibility.value) {
case 0: return '公开'
case 1: return '仅好友可见'
case 2: return '仅自己可见'
default: return '公开'
}
})
const visibilityOptions = [
{ name: '公开', value: 0 },
{ name: '仅好友可见', value: 1 },
{ name: '仅自己可见', value: 2 }
]
// 方法
function goBack() {
uni.navigateBack()
}
function chooseMedia() {
uni.showActionSheet({
itemList: ['拍照', '从相册选择', '拍视频'],
success: (res) => {
switch (res.tapIndex) {
case 0:
takePhoto()
break
case 1:
chooseImage()
break
case 2:
takeVideo()
break
}
}
})
}
function takePhoto() {
uni.chooseImage({
count: 1,
sourceType: ['camera'],
success: (res) => {
mediaList.value.push({
type: 'image',
path: res.tempFilePaths[0]
})
}
})
}
function chooseImage() {
const remaining = 9 - mediaList.value.length
if (remaining <= 0) return
uni.chooseImage({
count: remaining,
sourceType: ['album'],
success: (res) => {
res.tempFilePaths.forEach(path => {
mediaList.value.push({ type: 'image', path })
})
}
})
}
function takeVideo() {
// 视频只能选一个,清空之前的
mediaList.value = []
uni.chooseVideo({
sourceType: ['camera', 'album'],
maxDuration: 60,
success: (res) => {
mediaList.value.push({
type: 'video',
path: res.tempFilePath
})
}
})
}
function removeMedia(index: number) {
mediaList.value.splice(index, 1)
}
function onVisibilitySelect(item: { value: number }) {
visibility.value = item.value
showVisibilityPicker.value = false
}
function chooseLocation() {
uni.chooseLocation({
success: (res) => {
location.value = res.name || res.address || ''
},
fail: () => {
// 用户取消或权限问题
}
})
}
async function publish() {
if (!canPublish.value) return
publishing.value = true
try {
// 确定媒体类型
let mediaType = 0 // 纯文字
if (mediaList.value.length > 0) {
mediaType = mediaList.value[0].type === 'image' ? 1 : 2
}
// TODO: 先上传媒体文件获取 URL
const mediaUrls: string[] = []
for (const item of mediaList.value) {
// 这里应该调用上传接口
mediaUrls.push(item.path) // 暂时使用本地路径
}
await momentStore.publishMoment({
content: content.value.trim(),
media_type: mediaType,
media_urls: mediaUrls,
visibility: visibility.value,
location: location.value || undefined
})
setTimeout(() => {
uni.navigateBack()
}, 1000)
} catch (error) {
console.error('发布失败:', error)
} finally {
publishing.value = false
}
}
</script>
<style lang="scss" scoped>
.publish-page {
min-height: 100vh;
background: var(--bg-page);
}
.content-section {
background: var(--bg-content);
padding: 20rpx;
}
.media-section {
background: var(--bg-content);
padding: 20rpx 30rpx;
margin-top: 20rpx;
}
.media-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.media-item {
position: relative;
width: 200rpx;
height: 200rpx;
.video-preview {
width: 100%;
height: 100%;
border-radius: 8rpx;
}
.remove-btn {
position: absolute;
top: -12rpx;
right: -12rpx;
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
}
}
.add-media {
width: 200rpx;
height: 200rpx;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-hover);
border-radius: 8rpx;
border: 2rpx dashed var(--border-color);
}
.options-section {
background: var(--bg-content);
margin-top: 20rpx;
}
</style>

103
src/pages/profile/edit.vue Normal file
View File

@@ -0,0 +1,103 @@
<template>
<view class="edit-page">
<view class="form-item">
<input
v-model="value"
class="input"
:placeholder="placeholder"
:maxlength="maxLength"
/>
<text v-if="maxLength" class="counter">{{ value.length }}/{{ maxLength }}</text>
</view>
<button class="save-btn" @click="save">保存</button>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const field = ref('')
const value = ref('')
const placeholder = ref('')
const maxLength = ref(0)
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = (currentPage as any).$page?.options || {}
field.value = options.field || ''
value.value = decodeURIComponent(options.value || '')
switch (field.value) {
case 'name':
placeholder.value = '请输入昵称'
maxLength.value = 20
uni.setNavigationBarTitle({ title: '修改昵称' })
break
case 'desc':
placeholder.value = '请输入个性签名'
maxLength.value = 100
uni.setNavigationBarTitle({ title: '修改签名' })
break
case 'region':
placeholder.value = '请输入地区'
maxLength.value = 50
uni.setNavigationBarTitle({ title: '修改地区' })
break
}
})
function save() {
if (!value.value.trim()) {
uni.showToast({ title: '内容不能为空', icon: 'none' })
return
}
// TODO: 调用接口保存
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
</script>
<style lang="scss" scoped>
.edit-page {
padding: 30rpx;
background: #fff;
min-height: 100vh;
}
.form-item {
position: relative;
.input {
width: 100%;
padding: 24rpx;
background: #f5f5f5;
border-radius: 12rpx;
font-size: 32rpx;
}
.counter {
position: absolute;
right: 24rpx;
bottom: 24rpx;
font-size: 24rpx;
color: #999;
}
}
.save-btn {
margin-top: 60rpx;
background: #07c160;
color: #fff;
font-size: 32rpx;
border-radius: 12rpx;
&::after {
border: none;
}
}
</style>

112
src/pages/profile/index.vue Normal file
View File

@@ -0,0 +1,112 @@
<template>
<view class="profile-page">
<!-- 头像区域 -->
<view class="header-section">
<view class="avatar-wrap" @click="changeAvatar">
<wd-img
v-if="user?.avatar"
:src="user.avatar"
width="160rpx"
height="160rpx"
round
/>
<view v-else class="avatar-placeholder">
{{ user?.name?.charAt(0) || '?' }}
</view>
<view class="change-tip">点击更换</view>
</view>
</view>
<!-- 基本信息 -->
<wd-cell-group title="基本信息">
<wd-cell title="昵称" :value="user?.name || '未设置'" is-link @click="editField('name')" />
<wd-cell title="ID" :value="user?.id || '-'" />
<wd-cell title="个性签名" :value="user?.desc || '未设置'" is-link @click="editField('desc')" />
<wd-cell title="地区" :value="user?.region || '未设置'" is-link @click="editField('region')" />
</wd-cell-group>
<!-- 联系方式 -->
<wd-cell-group title="联系方式">
<wd-cell title="手机号" :value="formatPhone(user?.phone)" />
<wd-cell title="邮箱" :value="user?.email || '未绑定'" />
</wd-cell-group>
<wd-toast />
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores'
import { useToast } from 'wot-design-uni'
const authStore = useAuthStore()
const toast = useToast()
const user = computed(() => authStore.user)
function formatPhone(phone?: string) {
if (!phone) return '未绑定'
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
function changeAvatar() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
// TODO: 上传头像
console.log('选择的图片:', res.tempFilePaths[0])
toast.show('头像上传功能开发中')
}
})
}
function editField(field: string) {
const value = field === 'name' ? user.value?.name : field === 'desc' ? user.value?.desc : user.value?.region
uni.navigateTo({
url: `/pages/profile/edit?field=${field}&value=${encodeURIComponent(value || '')}`
})
}
</script>
<style lang="scss" scoped>
.profile-page {
min-height: 100vh;
background: var(--bg-page);
}
.header-section {
display: flex;
justify-content: center;
padding: 60rpx 0;
background: var(--bg-content);
margin-bottom: 20rpx;
}
.avatar-wrap {
display: flex;
flex-direction: column;
align-items: center;
.avatar-placeholder {
width: 160rpx;
height: 160rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: 64rpx;
font-weight: 600;
}
.change-tip {
margin-top: 16rpx;
font-size: 26rpx;
color: var(--text-tertiary);
}
}
</style>

229
src/pages/search/index.vue Normal file
View File

@@ -0,0 +1,229 @@
<template>
<view class="search-page">
<!-- 搜索栏 -->
<view class="search-header safe-area-top">
<wd-search
v-model="keyword"
placeholder="搜索"
show-action
focus
@search="doSearch"
@cancel="goBack"
@clear="clearKeyword"
/>
</view>
<!-- 搜索历史 -->
<view v-if="!keyword && history.length > 0" class="history-section">
<view class="section-header">
<text class="title">搜索历史</text>
<text class="clear" @click="clearHistory">清空</text>
</view>
<view class="history-tags">
<wd-tag
v-for="(item, index) in history"
:key="index"
round
@click="searchHistory(item)"
>
{{ item }}
</wd-tag>
</view>
</view>
<!-- 搜索结果 -->
<view v-if="keyword" class="results-section">
<view v-if="loading" class="loading-state">
<wd-loading />
<text>搜索中...</text>
</view>
<wd-status-tip v-else-if="results.length === 0 && searched" tip="未找到相关结果" />
<template v-else-if="results.length > 0">
<wd-cell
v-for="item in results"
:key="item.id"
:title="item.name"
:label="item.desc"
is-link
@click="goDetail(item)"
>
<template #icon>
<wd-img
v-if="item.avatar"
:src="item.avatar"
width="80rpx"
height="80rpx"
radius="8rpx"
custom-style="margin-right: 24rpx;"
/>
<view v-else class="avatar-placeholder" :style="{ background: generateColor(item.name || '') }">
{{ item.name?.charAt(0) || '?' }}
</view>
</template>
</wd-cell>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import * as contactApi from '@/api/modules/contact'
import { generateColor } from '@/utils/format'
import type { User } from '@/types/api'
const keyword = ref('')
const loading = ref(false)
const searched = ref(false)
const results = ref<User[]>([])
const history = ref<string[]>([])
// 加载搜索历史
const savedHistory = uni.getStorageSync('search_history')
if (savedHistory) {
try {
history.value = JSON.parse(savedHistory)
} catch {
history.value = []
}
}
// 监听关键词变化
let searchTimer: number
watch(keyword, (val) => {
clearTimeout(searchTimer)
if (val.trim()) {
searchTimer = setTimeout(() => {
doSearch()
}, 500) as unknown as number
} else {
results.value = []
searched.value = false
}
})
async function doSearch() {
if (!keyword.value.trim()) return
loading.value = true
searched.value = true
try {
results.value = await contactApi.searchUsers(keyword.value.trim())
// 保存搜索历史
saveHistory(keyword.value.trim())
} catch (error) {
console.error('搜索失败:', error)
} finally {
loading.value = false
}
}
function saveHistory(word: string) {
const index = history.value.indexOf(word)
if (index > -1) {
history.value.splice(index, 1)
}
history.value.unshift(word)
if (history.value.length > 10) {
history.value = history.value.slice(0, 10)
}
uni.setStorageSync('search_history', JSON.stringify(history.value))
}
function searchHistory(word: string) {
keyword.value = word
doSearch()
}
function clearKeyword() {
keyword.value = ''
results.value = []
searched.value = false
}
function clearHistory() {
history.value = []
uni.removeStorageSync('search_history')
}
function goBack() {
uni.navigateBack()
}
function goDetail(item: User) {
uni.navigateTo({ url: `/pages/contact/detail?id=${item.id}` })
}
</script>
<style lang="scss" scoped>
.search-page {
min-height: 100vh;
background: var(--bg-page);
}
.search-header {
background: var(--bg-content);
}
.safe-area-top {
padding-top: var(--status-bar-height, 0);
}
.history-section {
background: var(--bg-content);
margin-top: 20rpx;
padding: 30rpx;
.section-header {
display: flex;
justify-content: space-between;
margin-bottom: 24rpx;
.title {
font-size: 28rpx;
color: var(--text-tertiary);
}
.clear {
font-size: 28rpx;
color: var(--text-tertiary);
}
}
.history-tags {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
}
.results-section {
background: var(--bg-content);
margin-top: 20rpx;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
.avatar-placeholder {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
margin-right: 24rpx;
color: #fff;
font-size: 32rpx;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<view class="settings-page">
<wd-cell-group title="账号设置">
<wd-cell title="个人资料" icon="user" is-link @click="goProfile" />
<wd-cell title="账号与安全" icon="shield" is-link @click="goSecurity" />
</wd-cell-group>
<wd-cell-group title="通用设置">
<wd-cell title="消息通知" icon="bell" is-link @click="goNotification" />
<wd-cell title="隐私设置" icon="eye-close" is-link @click="goPrivacy" />
<wd-cell title="深色模式" icon="picture" center>
<template #value>
<wd-switch v-model="isDark" @change="toggleTheme" />
</template>
</wd-cell>
<wd-cell title="清除缓存" icon="delete" :value="cacheSize" is-link @click="clearCache" />
</wd-cell-group>
<wd-cell-group title="关于">
<wd-cell title="当前版本" value="v1.0.0" />
<wd-cell title="用户协议" is-link @click="goAgreement" />
<wd-cell title="隐私政策" is-link @click="goPrivacyPolicy" />
<wd-cell title="检查更新" is-link @click="checkUpdate" />
</wd-cell-group>
<view class="logout-section">
<wd-button type="error" block plain @click="logout">退出登录</wd-button>
</view>
<wd-toast />
<wd-message-box />
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores'
import { useTheme } from '@/composables/useTheme'
import { useToast, useMessage } from 'wot-design-uni'
const authStore = useAuthStore()
const { isDark, toggleTheme } = useTheme()
const toast = useToast()
const messageBox = useMessage()
const cacheSize = ref('0 KB')
onMounted(() => {
calculateCacheSize()
})
function calculateCacheSize() {
try {
const info = uni.getStorageInfoSync()
const size = info.currentSize || 0
if (size < 1024) {
cacheSize.value = `${size} KB`
} else {
cacheSize.value = `${(size / 1024).toFixed(2)} MB`
}
} catch {
cacheSize.value = '0 KB'
}
}
function goProfile() {
uni.navigateTo({ url: '/pages/profile/index' })
}
function goSecurity() {
toast.show('账号安全功能开发中')
}
function goNotification() {
toast.show('消息通知设置开发中')
}
function goPrivacy() {
toast.show('隐私设置功能开发中')
}
async function clearCache() {
try {
await messageBox.confirm({
title: '提示',
msg: '确定要清除缓存吗?'
})
// 保留登录信息
const token = uni.getStorageSync('nl_im_token')
const userId = uni.getStorageSync('nl_im_user_id')
const userInfo = uni.getStorageSync('nl_im_user_info')
const theme = uni.getStorageSync('nl_im_theme')
uni.clearStorageSync()
// 恢复登录信息
if (token) uni.setStorageSync('nl_im_token', token)
if (userId) uni.setStorageSync('nl_im_user_id', userId)
if (userInfo) uni.setStorageSync('nl_im_user_info', userInfo)
if (theme) uni.setStorageSync('nl_im_theme', theme)
cacheSize.value = '0 KB'
toast.success('清除成功')
} catch {
// 取消
}
}
function goAgreement() {
toast.show('用户协议页面开发中')
}
function goPrivacyPolicy() {
toast.show('隐私政策页面开发中')
}
function checkUpdate() {
toast.show('当前已是最新版本')
}
async function logout() {
try {
await messageBox.confirm({
title: '提示',
msg: '确定要退出登录吗?'
})
authStore.logout()
} catch {
// 取消
}
}
</script>
<style lang="scss" scoped>
.settings-page {
min-height: 100vh;
background: var(--bg-page);
}
.logout-section {
padding: 60rpx 30rpx;
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
}
</style>

1
src/static/github.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" fill-rule="evenodd" d="M16 2a14 14 0 0 0-4.43 27.28c.7.13 1-.3 1-.67v-2.38c-3.89.84-4.71-1.88-4.71-1.88a3.71 3.71 0 0 0-1.62-2.05c-1.27-.86.1-.85.1-.85a2.94 2.94 0 0 1 2.14 1.45a3 3 0 0 0 4.08 1.16a2.93 2.93 0 0 1 .88-1.87c-3.1-.36-6.37-1.56-6.37-6.92a5.4 5.4 0 0 1 1.44-3.76a5 5 0 0 1 .14-3.7s1.17-.38 3.85 1.43a13.3 13.3 0 0 1 7 0c2.67-1.81 3.84-1.43 3.84-1.43a5 5 0 0 1 .14 3.7a5.4 5.4 0 0 1 1.44 3.76c0 5.38-3.27 6.56-6.39 6.91a3.33 3.33 0 0 1 .95 2.59v3.84c0 .46.25.81 1 .67A14 14 0 0 0 16 2"/></svg>

After

Width:  |  Height:  |  Size: 614 B

View File

@@ -0,0 +1 @@
placeholder

View File

@@ -0,0 +1 @@
placeholder

View File

@@ -0,0 +1 @@
placeholder

View File

@@ -0,0 +1 @@
placeholder

View File

@@ -0,0 +1 @@
placeholder

View File

@@ -0,0 +1 @@
placeholder

View File

@@ -0,0 +1 @@
placeholder

1
src/static/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="512" height="512" viewBox="0 0 512 512"><defs><clipPath id="master_svg0_25_97"><rect x="0" y="0" width="512" height="512" rx="0"/></clipPath><clipPath id="master_svg1_25_11"><rect x="11" y="39" width="490" height="435" rx="0"/></clipPath></defs><g style="mix-blend-mode:passthrough" clip-path="url(#master_svg0_25_97)"><g clip-path="url(#master_svg1_25_11)"><g><path d="M51.4931,294.222767578125L205.214,437.551767578125C211.594,443.498767578125,220.016,446.812767578125,228.778,446.812767578125C237.54,446.812767578125,245.962,443.498767578125,252.342,437.551767578125L254.554,435.512767578125C238.306,411.638767578125,228.778,382.751767578125,228.778,351.655767578125C228.778,269.073767578125,295.812,202.124767578125,378.5,202.124767578125C402.575,202.124767578125,425.288,207.817767578125,445.45,217.842767578125C446.215,212.320767578125,446.556,206.797767578125,446.556,201.19076757812502L446.556,196.262767578125C446.556,136.874967578125,403.595,86.238267578125,344.983,76.467747578125C306.191,70.010717578125,266.719,82.669897578125,238.986,110.36716757812499L228.778,120.562467578125L218.569,110.36716757812499C190.837,82.669897578125,151.365,70.010717578125,112.573,76.467747578125C53.9601,86.238267578125,11,136.874967578125,11,196.262767578125L11,201.19076757812502C11,236.448767578125,25.6319,270.17876757812496,51.4931,294.222767578125ZM378.5,473.999767578125C446.13,473.999767578125,501,419.199767578125,501,351.655767578125C501,284.112767578125,446.13,229.312767578125,378.5,229.312767578125C310.87,229.312767578125,256,284.112767578125,256,351.655767578125C256,419.199767578125,310.87,473.999767578125,378.5,473.999767578125Z" fill="#2B9939" fill-opacity="1"/></g><g style="mix-blend-mode:passthrough"><path d="M322,415L441,415L441,293.5L419,293.5L419,393L344.5,393L344.5,293.5L322,293.5L322,415Z" fill="#FFFFFF" fill-opacity="1"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src/static/vite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

140
src/stores/app.ts Normal file
View File

@@ -0,0 +1,140 @@
/**
* 应用全局状态管理
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type TabType = 'chat' | 'contact' | 'moment'
export const useAppStore = defineStore('app', () => {
// 当前激活的 Tab
const currentTab = ref<TabType>('chat')
// 是否暗色模式
const isDark = ref(false)
// 系统信息
const systemInfo = ref<UniApp.GetSystemInfoResult | null>(null)
// 左侧抽屉是否打开
const drawerVisible = ref(false)
/**
* 初始化应用
*/
function init() {
// 获取系统信息
systemInfo.value = uni.getSystemInfoSync()
// 初始化主题
initTheme()
// 读取缓存的 Tab
const cachedTab = uni.getStorageSync('current_tab')
if (cachedTab && ['chat', 'contact', 'moment'].includes(cachedTab)) {
currentTab.value = cachedTab as TabType
}
}
/**
* 初始化主题
*/
function initTheme() {
// 读取缓存的主题设置
const cachedTheme = uni.getStorageSync('theme')
if (cachedTheme) {
isDark.value = cachedTheme === 'dark'
} else {
// 跟随系统
const info = uni.getSystemInfoSync()
isDark.value = info.theme === 'dark'
}
applyTheme()
// 监听系统主题变化
// @ts-ignore
uni.onThemeChange?.((res: { theme: string }) => {
if (!uni.getStorageSync('theme')) {
isDark.value = res.theme === 'dark'
applyTheme()
}
})
}
/**
* 应用主题
*/
function applyTheme() {
// 设置导航栏颜色
uni.setNavigationBarColor({
frontColor: isDark.value ? '#ffffff' : '#000000',
backgroundColor: isDark.value ? '#1a1a1a' : '#ededed',
animation: {
duration: 300,
timingFunc: 'easeIn'
}
})
// 设置 TabBar 样式
uni.setTabBarStyle({
backgroundColor: isDark.value ? '#1a1a1a' : '#f7f7f7',
borderStyle: isDark.value ? 'black' : 'white',
color: isDark.value ? '#808080' : '#999999',
selectedColor: isDark.value ? '#ffffff' : '#07c160'
})
}
/**
* 切换主题
*/
function toggleTheme() {
isDark.value = !isDark.value
uni.setStorageSync('theme', isDark.value ? 'dark' : 'light')
applyTheme()
}
/**
* 设置当前 Tab
*/
function setCurrentTab(tab: TabType) {
currentTab.value = tab
uni.setStorageSync('current_tab', tab)
}
/**
* 打开抽屉
*/
function openDrawer() {
drawerVisible.value = true
}
/**
* 关闭抽屉
*/
function closeDrawer() {
drawerVisible.value = false
}
/**
* 切换抽屉
*/
function toggleDrawer() {
drawerVisible.value = !drawerVisible.value
}
return {
currentTab,
isDark,
systemInfo,
drawerVisible,
init,
initTheme,
applyTheme,
toggleTheme,
setCurrentTab,
openDrawer,
closeDrawer,
toggleDrawer
}
})

108
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* 认证状态管理
*/
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) {
try {
const response = await authApi.login(data)
token.value = response.token
user.value = response.user
isAuthenticated.value = true
// 存储到本地
storage.setToken(response.token)
storage.setUserId(response.user.id)
storage.setUserInfo(response.user)
if (data.remember) {
storage.setRemember(true)
}
return response
} catch (error) {
console.error('Login failed:', error)
throw error
}
}
/**
* 注册
*/
async function register(data: any) {
try {
await authApi.register(data)
} catch (error) {
console.error('Register failed:', error)
throw error
}
}
/**
* 登出
*/
function logout() {
token.value = null
user.value = null
isAuthenticated.value = false
storage.clear()
// 跳转到登录页
uni.reLaunch({
url: '/pages/login/index'
})
}
/**
* 检查Token并获取用户信息
*/
async function checkAuth() {
if (!token.value) {
return false
}
try {
await authApi.checkToken()
// Token有效获取最新用户信息
const userInfo = await userApi.getMyInfo()
user.value = userInfo
storage.setUserInfo(userInfo)
return true
} catch {
// Token无效清除
logout()
return false
}
}
/**
* 更新用户信息
*/
function updateUserInfo(userInfo: User) {
user.value = userInfo
storage.setUserInfo(userInfo)
}
return {
token,
user,
isAuthenticated,
login,
register,
logout,
checkAuth,
updateUserInfo
}
})

157
src/stores/chat.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* 聊天状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ChatMessage, Contact } from '@/types/api'
import { generateColor } from '@/utils/format'
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
// 如果是群聊,标记 is_group
if (contact.room_type === 'group' || contact.room_id?.startsWith('group_')) {
contact.is_group = true
}
}
}
/**
* 添加消息
*/
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.map((contact) => ({
...contact,
color: contact.color || generateColor(contact.user_id || contact.id)
}))
}
/**
* 更新联系人最后消息
*/
function updateContactLastMsg(contactIdOrRoomId: string, lastMsg: string, lastTime: number) {
// 优先按 room_id 匹配(群聊场景)
let contact = contacts.value.find((c) => c.room_id === contactIdOrRoomId)
// 如果没有找到,再按 id 或 user_id 匹配(单聊场景)
if (!contact) {
contact = contacts.value.find((c) => c.id === contactIdOrRoomId || c.user_id === contactIdOrRoomId)
}
if (contact) {
contact.last_msg = lastMsg
contact.last_time = lastTime
}
}
/**
* 增加联系人未读数
*/
function incrementUnread(contactIdOrRoomId: string) {
// 优先按 room_id 匹配(群聊场景)
let contact = contacts.value.find((c) => c.room_id === contactIdOrRoomId)
// 如果没有找到,再按 id 或 user_id 匹配(单聊场景)
if (!contact) {
contact = contacts.value.find((c) => c.id === contactIdOrRoomId || c.user_id === contactIdOrRoomId)
}
if (contact && contact.id !== currentTarget.value?.id && contact.room_id !== currentTarget.value?.room_id) {
contact.unread = (contact.unread || 0) + 1
}
}
/**
* 清空房间消息
*/
function clearRoomMessages(roomId: string) {
messages.value[roomId] = []
}
// 群通知相关状态
const lastGroupNotification = ref<{ room_id: string; type: string; data: any } | null>(null)
const myMuteStatus = ref<Record<string, string | null>>({}) // room_id -> muted_until
/**
* 设置最后的群通知(用于触发群面板刷新)
*/
function setLastGroupNotification(notification: { room_id: string; type: string; data: any }) {
lastGroupNotification.value = { ...notification, _timestamp: Date.now() } as any
}
/**
* 设置我在某个群的禁言状态
*/
function setMyMuteStatus(roomId: string, mutedUntil: string | null) {
myMuteStatus.value[roomId] = mutedUntil
}
/**
* 获取我在某个群是否被禁言
*/
function isMyMuted(roomId: string): boolean {
const mutedUntil = myMuteStatus.value[roomId]
if (!mutedUntil) return false
return new Date(mutedUntil) > new Date()
}
/**
* 获取我的禁言到期时间
*/
function getMyMutedUntil(roomId: string): string | null {
return myMuteStatus.value[roomId] || null
}
return {
currentTarget,
messages,
contacts,
totalUnread,
lastGroupNotification,
myMuteStatus,
setCurrentTarget,
addMessage,
getRoomMessages,
setRoomMessages,
setContacts,
updateContactLastMsg,
incrementUnread,
clearRoomMessages,
setLastGroupNotification,
setMyMuteStatus,
isMyMuted,
getMyMutedUntil
}
})

120
src/stores/contact.ts Normal file
View File

@@ -0,0 +1,120 @@
/**
* 联系人状态管理
*/
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { storage } from '@/utils/storage'
import type { FriendRequest, ContactGroup, Contact } from '@/types/api'
export type LeftPanelMode = 'default' | 'friend-manager' | 'friend-notify' | 'group-notify'
export const useContactStore = defineStore('contact', () => {
const friendRequests = ref<FriendRequest[]>([])
const groups = ref<ContactGroup[]>([])
const leftPanelMode = ref<LeftPanelMode>(storage.getContactLeftPanelMode() as LeftPanelMode)
const selectedContact = ref<Contact | null>(null)
const contactListTab = ref<'friends' | 'groups'>(storage.getContactListTab())
/**
* 设置好友申请列表
*/
function setFriendRequests(requests: FriendRequest[]) {
friendRequests.value = requests
}
/**
* 添加好友申请
*/
function addFriendRequest(request: FriendRequest) {
friendRequests.value.push(request)
}
/**
* 移除好友申请
*/
function removeFriendRequest(requestId: number) {
const index = friendRequests.value.findIndex((r) => r.id === requestId)
if (index > -1) {
friendRequests.value.splice(index, 1)
}
}
/**
* 设置分组列表
*/
function setGroups(newGroups: ContactGroup[]) {
groups.value = newGroups
}
/**
* 添加分组
*/
function addGroup(group: ContactGroup) {
groups.value.push(group)
}
/**
* 更新分组
*/
function updateGroup(group: ContactGroup) {
const index = groups.value.findIndex((g) => g.id === group.id)
if (index > -1) {
groups.value[index] = group
}
}
/**
* 删除分组
*/
function removeGroup(groupId: number) {
const index = groups.value.findIndex((g) => g.id === groupId)
if (index > -1) {
groups.value.splice(index, 1)
}
}
/**
* 设置左侧面板模式
*/
function setLeftPanelMode(mode: LeftPanelMode) {
leftPanelMode.value = mode
storage.setContactLeftPanelMode(mode)
// 切换到管理/通知模式时,清除选中联系人
if (mode !== 'default') {
selectedContact.value = null
}
}
/**
* 设置选中的联系人
*/
function setSelectedContact(contact: Contact | null) {
selectedContact.value = contact
// 选中联系人时,切换到默认模式
if (contact) {
leftPanelMode.value = 'default'
}
}
// 监听 contactListTab 变化并保存
watch(contactListTab, (newTab) => {
storage.setContactListTab(newTab)
})
return {
friendRequests,
groups,
leftPanelMode,
selectedContact,
contactListTab,
setFriendRequests,
addFriendRequest,
removeFriendRequest,
setGroups,
addGroup,
updateGroup,
removeGroup,
setLeftPanelMode,
setSelectedContact
}
})

219
src/stores/conversation.ts Normal file
View File

@@ -0,0 +1,219 @@
/**
* 会话状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Conversation } from '@/types/conversation'
import type { ChatMessage } from '@/types/api'
import * as conversationApi from '@/api/modules/conversation'
import * as groupApi from '@/api/modules/room'
import { getMessageSummary } from '@/utils/message'
import { useChatStore } from './chat'
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((c: any) => {
const isGroup = c.type === 2 // type: 1=私聊, 2=群聊
const roomType = isGroup ? 'group' : 'p2p'
// 将后端的 room 字段映射为前端的 target_group
const targetGroup = c.room || c.target_group
// 转换 last_time 为时间戳(确保排序正确)
let lastTime = c.last_time
if (typeof lastTime === 'string') {
lastTime = new Date(lastTime).getTime()
} else if (!lastTime) {
lastTime = 0
}
return {
...c,
room_type: roomType,
is_group: isGroup,
// 群聊显示群名称,单聊显示用户名称
name: isGroup
? targetGroup?.room_name || targetGroup?.name || c.name || c.room_id || '群聊'
: c.target_user?.name || c.name || '未知用户',
avatar: isGroup
? targetGroup?.room_avatar || targetGroup?.avatar || c.avatar || ''
: c.target_user?.avatar || c.avatar || '',
// 群聊相关字段
room_id: isGroup ? c.room_id || c.target_id : c.room_id,
member_count: targetGroup?.member_count,
owner_id: targetGroup?.owner_id,
// 确保 last_time 是时间戳
last_time: lastTime,
last_message_time: c.last_time || c.last_message_time,
// 映射 room 为 target_group前端期望的字段名
target_group: targetGroup
? {
id: targetGroup.room_id || targetGroup.id,
room_id: targetGroup.room_id,
room_type: 'group',
name: targetGroup.room_name || targetGroup.name,
avatar: targetGroup.room_avatar || targetGroup.avatar,
owner_id: targetGroup.owner_id,
member_count: targetGroup.member_count,
created_at: targetGroup.created_at
}
: undefined
}
})
.sort((a: any, b: any) => {
// 确保排序:置顶优先 > 时间倒序(最新在最上面)
if (a.is_top !== b.is_top) return a.is_top ? -1 : 1
return (b.last_time || 0) - (a.last_time || 0)
})
// 对于群聊信息缺失的会话,尝试异步获取群信息
conversations.value.forEach(async (conv) => {
if (conv.is_group && conv.room_id && !conv.target_group && !conv.name) {
try {
const groupInfo = await groupApi.getGroup(conv.room_id)
// 更新会话的群信息
const index = conversations.value.findIndex((c) => c.id === conv.id)
if (index !== -1) {
conversations.value[index].target_group = groupInfo as any
conversations.value[index].name = (groupInfo as any).room_name || groupInfo.name || conv.room_id
conversations.value[index].avatar = (groupInfo as any).room_avatar || groupInfo.avatar
conversations.value[index].member_count = groupInfo.member_count
conversations.value[index].owner_id = groupInfo.owner_id
}
} catch (error) {
console.error(`Failed to fetch group info for room ${conv.room_id}:`, error)
}
}
})
} catch (error) {
console.error('Fetch conversations failed:', error)
} finally {
loading.value = false
}
}
/**
* 处理新消息 (发送或接收)
*/
function handleMessageUpdate(message: ChatMessage, isSelf: boolean, isCurrentChat: boolean = false) {
// 群聊和单聊统一使用 room_id 定位会话
const roomId = message.room_id
if (!roomId) return
// 优先按 room_id 匹配(群聊和单聊都支持)
let conv = conversations.value.find((c) => c.room_id === roomId)
// 如果没有 room_id回退到按 target_id 匹配(兼容旧逻辑)
if (!conv) {
const targetId = isSelf ? message.receiver_user_id : message.sender_user_id
if (targetId) {
conv = conversations.value.find((c) => c.target_id === targetId)
}
}
// 判断是否为群聊
const isGroupChat = conv?.type === 2 || conv?.is_group
// 生成摘要:群聊时需要包含发送者信息
let summary = getMessageSummary(message)
if (isGroupChat && !isSelf && message.sender_user_id) {
// 从 chatStore 中查找发送者信息
const chatStore = useChatStore()
const senderContact = chatStore.contacts.find(
(c) => c.user_id === message.sender_user_id || c.id === message.sender_user_id
)
if (senderContact) {
const senderName = senderContact.remark_name || senderContact.user?.name || '未知用户'
summary = `${senderName}${summary}`
}
}
const now = new Date(message.created_at || Date.now()).getTime()
if (conv) {
conv.last_message = summary
conv.last_time = now
// 如果不是自己发送的,且不是当前选中的聊天,且未设置免打扰,则增加未读数
if (!isSelf && !isCurrentChat && !conv.is_muted) {
conv.unread_count = (conv.unread_count || 0) + 1
}
} else {
// 新会话直接重新拉取,保证与后端一致
loadConversations()
return
}
// 更新后重新排序,确保最新消息在最上面
sortConversations()
}
/**
* 增加未读数
*/
function incrementUnread(targetIdOrRoomId: string) {
// 优先按 room_id 匹配(群聊场景)
let conv = conversations.value.find((c) => c.room_id === targetIdOrRoomId)
// 如果没有找到,再按 target_id 匹配(单聊场景)
if (!conv) {
conv = conversations.value.find((c) => c.target_id === targetIdOrRoomId)
}
if (conv) {
conv.unread_count = (conv.unread_count || 0) + 1
}
}
/**
* 清除未读数 (同步后端)
*/
async function clearUnread(targetIdOrRoomId: string) {
// 优先按 room_id 匹配(群聊场景)
let conv = conversations.value.find((c) => c.room_id === targetIdOrRoomId)
// 如果没有找到,再按 target_id 匹配(单聊场景)
if (!conv) {
conv = conversations.value.find((c) => c.target_id === targetIdOrRoomId)
}
if (conv && conv.unread_count > 0) {
conv.unread_count = 0
// 调用后端接口
try {
await conversationApi.resetUnread(conv.target_id || targetIdOrRoomId)
} catch (e) {
console.error(e)
}
}
}
/**
* 排序
*/
function sortConversations() {
conversations.value.sort((a, b) => {
if (a.is_top !== b.is_top) return a.is_top ? -1 : 1
return b.last_time - a.last_time
})
}
return {
conversations,
loading,
totalUnread,
loadConversations,
handleMessageUpdate,
incrementUnread,
clearUnread,
sortConversations
}
})

10
src/stores/counter.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function increment() {
count.value++
}
return { count, increment }
})

9
src/stores/index.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Store 统一导出
*/
export { useAuthStore } from './auth'
export { useChatStore } from './chat'
export { useContactStore } from './contact'
export { useConversationStore } from './conversation'
export { useMomentStore } from './moment'
export { useAppStore } from './app'

452
src/stores/moment.ts Normal file
View File

@@ -0,0 +1,452 @@
/**
* 朋友圈状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as momentApi from '@/api/modules/moment'
import type { Moment, MomentNotification, CreateMomentRequest, CreateCommentRequest, MomentNotifPayload } 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 notifPagination = ref({
page: 1,
pageSize: 20,
total: 0,
hasMore: true
})
// ==========================================
// 计算属性
// ==========================================
const hasUnread = computed(() => unreadCount.value > 0)
// ==========================================
// 动态相关方法
// ==========================================
/**
* 获取动态列表(刷新)
*/
async function fetchMoments() {
loading.value = true
try {
pagination.value.page = 1
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
} catch (error) {
console.error('获取动态列表失败:', error)
} finally {
loading.value = false
}
}
/**
* 加载更多动态
*/
async function fetchMoreMoments() {
if (!pagination.value.hasMore || loading.value) return
loading.value = true
try {
const nextPage = pagination.value.page + 1
const res = await momentApi.getMoments(nextPage, pagination.value.pageSize)
const newMoments = res.data || []
moments.value = [...moments.value, ...newMoments]
pagination.value.page = nextPage
pagination.value.total = res.total
pagination.value.hasMore = moments.value.length < res.total
} catch (error) {
console.error('加载更多动态失败:', error)
} finally {
loading.value = false
}
}
/**
* 获取用户动态列表
*/
async function fetchUserMoments(userId: string, refresh = false) {
if (refresh) {
pagination.value.page = 1
}
loading.value = true
try {
const res = await momentApi.getUserMoments(userId, pagination.value.page, pagination.value.pageSize)
if (refresh) {
moments.value = res.data || []
} else {
moments.value = [...moments.value, ...(res.data || [])]
}
pagination.value.total = res.total
pagination.value.hasMore = moments.value.length < res.total
} catch (error) {
console.error('获取用户动态失败:', error)
} finally {
loading.value = false
}
}
/**
* 获取动态详情
*/
async function fetchMomentDetail(id: number) {
loading.value = true
try {
currentMoment.value = await momentApi.getMomentDetail(id)
} catch (error) {
console.error('获取动态详情失败:', error)
throw error
} finally {
loading.value = false
}
}
/**
* 发布动态
*/
async function publishMoment(data: CreateMomentRequest) {
try {
const newMoment = await momentApi.createMoment(data)
// 添加到列表顶部
moments.value.unshift(newMoment)
uni.showToast({ title: '发布成功', icon: 'success' })
return newMoment
} catch (error) {
console.error('发布动态失败:', error)
uni.showToast({ title: '发布失败', icon: 'none' })
throw error
}
}
/**
* 删除动态
*/
async function removeMoment(id: number) {
try {
await momentApi.deleteMoment(id)
// 从列表中移除
moments.value = moments.value.filter((m) => m.id !== id)
if (currentMoment.value?.id === id) {
currentMoment.value = null
}
uni.showToast({ title: '删除成功', icon: 'success' })
} catch (error) {
console.error('删除动态失败:', error)
uni.showToast({ title: '删除失败', icon: 'none' })
throw error
}
}
// ==========================================
// 点赞相关方法
// ==========================================
/**
* 切换点赞状态
*/
async function toggleLike(momentId: number) {
const moment = moments.value.find((m) => m.id === momentId) || currentMoment.value
if (!moment) return
const wasLiked = moment.is_liked
try {
if (wasLiked) {
await momentApi.unlikeMoment(momentId)
moment.is_liked = false
moment.like_count = Math.max(0, moment.like_count - 1)
} else {
await momentApi.likeMoment(momentId)
moment.is_liked = true
moment.like_count += 1
}
} catch (error) {
console.error('点赞操作失败:', error)
// 恢复状态
moment.is_liked = wasLiked
throw error
}
}
// ==========================================
// 评论相关方法
// ==========================================
/**
* 添加评论
*/
async function addComment(momentId: number, data: CreateCommentRequest) {
try {
const newComment = await momentApi.createComment(momentId, data)
// 更新动态的评论列表
const moment = moments.value.find((m) => m.id === momentId) || currentMoment.value
if (moment) {
if (!moment.comments) {
moment.comments = []
}
moment.comments.push(newComment)
moment.comment_count += 1
}
uni.showToast({ title: '评论成功', icon: 'success' })
return newComment
} catch (error) {
console.error('评论失败:', error)
uni.showToast({ title: '评论失败', icon: 'none' })
throw error
}
}
/**
* 删除评论
*/
async function removeComment(momentId: number, commentId: number) {
try {
await momentApi.deleteComment(commentId)
// 更新动态的评论列表
const moment = moments.value.find((m) => m.id === momentId) || currentMoment.value
if (moment && moment.comments) {
moment.comments = moment.comments.filter((c) => c.id !== commentId)
moment.comment_count = Math.max(0, moment.comment_count - 1)
}
uni.showToast({ title: '删除成功', icon: 'success' })
} catch (error) {
console.error('删除评论失败:', error)
uni.showToast({ title: '删除失败', icon: 'none' })
throw error
}
}
/**
* 刷新评论列表
*/
async function refreshComments(momentId: number) {
try {
const comments = await momentApi.getMomentComments(momentId)
const moment = moments.value.find((m) => m.id === momentId) || currentMoment.value
if (moment) {
moment.comments = comments
}
return comments
} catch (error) {
console.error('刷新评论失败:', error)
throw error
}
}
// ==========================================
// 通知相关方法
// ==========================================
/**
* 获取通知列表
*/
async function fetchNotifications(refresh = false) {
if (refresh) {
notifPagination.value.page = 1
}
loading.value = true
try {
const res = await momentApi.getNotifications(notifPagination.value.page, notifPagination.value.pageSize)
if (refresh) {
notifications.value = res.data || []
} else {
notifications.value = [...notifications.value, ...(res.data || [])]
}
notifPagination.value.total = res.total
notifPagination.value.hasMore = notifications.value.length < res.total
} catch (error) {
console.error('获取通知列表失败:', error)
} finally {
loading.value = false
}
}
/**
* 加载更多通知
*/
async function fetchMoreNotifications() {
if (!notifPagination.value.hasMore || loading.value) return
notifPagination.value.page += 1
await fetchNotifications(false)
}
/**
* 标记通知已读
*/
async function markAsRead(ids: number[]) {
try {
await momentApi.markNotificationsRead({ ids })
// 更新本地状态
notifications.value.forEach((n) => {
if (ids.includes(n.id)) {
n.is_read = true
}
})
// 更新未读数
await fetchUnreadCount()
} catch (error) {
console.error('标记已读失败:', error)
}
}
/**
* 标记全部已读
*/
async function markAllAsRead() {
try {
await momentApi.markNotificationsRead({ all: true })
// 更新本地状态
notifications.value.forEach((n) => {
n.is_read = true
})
unreadCount.value = 0
} catch (error) {
console.error('标记全部已读失败:', error)
}
}
/**
* 获取未读通知数量
*/
async function fetchUnreadCount() {
try {
const res = await momentApi.getUnreadCount()
unreadCount.value = res.count
} catch (error) {
console.error('获取未读数失败:', error)
}
}
// ==========================================
// WebSocket 通知处理
// ==========================================
/**
* 处理 WebSocket 推送的朋友圈通知
*/
function handleWsNotification(payload: MomentNotifPayload) {
// 增加未读数
unreadCount.value += 1
// 显示通知
let message = ''
const userName = payload.from_user?.name || '有人'
switch (payload.type) {
case 'like':
message = `${userName} 赞了你的动态`
break
case 'comment':
message = `${userName} 评论了你的动态`
break
case 'reply':
message = `${userName} 回复了你的评论`
break
case 'mention':
message = `${userName} 在动态中@了你`
break
}
uni.showToast({
title: message,
icon: 'none',
duration: 3000
})
}
// ==========================================
// 重置状态
// ==========================================
function reset() {
moments.value = []
currentMoment.value = null
notifications.value = []
unreadCount.value = 0
pagination.value = {
page: 1,
pageSize: 20,
total: 0,
hasMore: true
}
notifPagination.value = {
page: 1,
pageSize: 20,
total: 0,
hasMore: true
}
}
return {
// 状态
moments,
currentMoment,
notifications,
unreadCount,
loading,
pagination,
notifPagination,
// 计算属性
hasUnread,
// 动态方法
fetchMoments,
fetchMoreMoments,
fetchUserMoments,
fetchMomentDetail,
publishMoment,
removeMoment,
deleteMoment: removeMoment, // 别名
// 点赞方法
toggleLike,
// 评论方法
addComment,
removeComment,
refreshComments,
// 通知方法
fetchNotifications,
fetchMoreNotifications,
markAsRead,
markAllAsRead,
fetchUnreadCount,
// WebSocket
handleWsNotification,
// 重置
reset
}
})

181
src/styles/common.scss Normal file
View File

@@ -0,0 +1,181 @@
/**
* 公共样式
*/
// 安全区域
.safe-area-top {
padding-top: var(--status-bar-height, 0);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
// Flex 布局
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column {
display: flex;
flex-direction: column;
}
.flex-1 {
flex: 1;
}
// 文本
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-center {
text-align: center;
}
.text-primary {
color: var(--color-primary);
}
.text-danger {
color: var(--color-danger);
}
.text-secondary {
color: var(--text-secondary);
}
.text-tertiary {
color: var(--text-tertiary);
}
// 边距
.m-0 { margin: 0; }
.mt-10 { margin-top: 10rpx; }
.mt-20 { margin-top: 20rpx; }
.mt-30 { margin-top: 30rpx; }
.mb-10 { margin-bottom: 10rpx; }
.mb-20 { margin-bottom: 20rpx; }
.mb-30 { margin-bottom: 30rpx; }
.p-0 { padding: 0; }
.p-20 { padding: 20rpx; }
.p-30 { padding: 30rpx; }
// 页面容器
.page-container {
min-height: 100vh;
background: var(--bg-page);
}
// 卡片
.card {
background: var(--bg-content);
border-radius: 16rpx;
margin: 20rpx;
overflow: hidden;
}
// 分割线
.divider {
height: 1rpx;
background: var(--divider-color);
margin: 0 30rpx;
}
// 头像占位
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--avatar-bg);
color: var(--avatar-text);
font-weight: 600;
}
// 空状态
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: var(--text-tertiary);
}
// 加载状态
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 60rpx;
color: var(--text-tertiary);
}
// 自定义导航栏
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: var(--nav-bg);
padding-top: var(--status-bar-height, 0);
.navbar-content {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 30rpx;
}
.navbar-title {
flex: 1;
font-size: 36rpx;
font-weight: 600;
color: var(--text-primary);
}
.navbar-left,
.navbar-right {
display: flex;
align-items: center;
}
}
// 底部安全区
.bottom-safe-area {
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
// 浮动按钮
.fab {
position: fixed;
right: 30rpx;
bottom: calc(180rpx + env(safe-area-inset-bottom));
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 20rpx rgba(7, 193, 96, 0.4);
z-index: 99;
}

101
src/styles/variables.scss Normal file
View File

@@ -0,0 +1,101 @@
/**
* 主题变量定义
* 浅色/暗色两套主题
*/
// 浅色主题(默认)
page {
// 主色调
--color-primary: #07c160;
--color-primary-dark: #06ad56;
--color-danger: #fa5151;
--color-warning: #ffc300;
--color-success: #07c160;
--color-info: #10aeff;
// 背景色
--bg-page: #ededed;
--bg-content: #ffffff;
--bg-hover: #f5f5f5;
--bg-mask: rgba(0, 0, 0, 0.5);
--bg-card: #ffffff;
// 文字颜色
--text-primary: #333333;
--text-secondary: #666666;
--text-tertiary: #999999;
--text-placeholder: #cccccc;
--text-link: #576b95;
// 边框颜色
--border-color: #e5e5e5;
--divider-color: #f0f0f0;
// 导航栏
--nav-bg: #ededed;
--nav-text: #000000;
// 标签栏
--tabbar-bg: #f7f7f7;
--tabbar-text: #999999;
--tabbar-text-active: #07c160;
// 消息气泡
--bubble-self: #95ec69;
--bubble-other: #ffffff;
--bubble-system: rgba(0, 0, 0, 0.1);
// 搜索框
--search-bg: #f5f5f5;
// 头像
--avatar-bg: #07c160;
--avatar-text: #ffffff;
// 未读徽标
--badge-bg: #fa5151;
--badge-text: #ffffff;
// 开关
--switch-on: #07c160;
--switch-off: #e5e5e5;
}
// 暗色主题
page.dark {
// 背景色
--bg-page: #111111;
--bg-content: #1e1e1e;
--bg-hover: #2a2a2a;
--bg-mask: rgba(0, 0, 0, 0.7);
--bg-card: #1e1e1e;
// 文字颜色
--text-primary: #e5e5e5;
--text-secondary: #a0a0a0;
--text-tertiary: #666666;
--text-placeholder: #444444;
--text-link: #7b9ad0;
// 边框颜色
--border-color: #333333;
--divider-color: #2a2a2a;
// 导航栏
--nav-bg: #1a1a1a;
--nav-text: #ffffff;
// 标签栏
--tabbar-bg: #1a1a1a;
// 消息气泡
--bubble-self: #2d5c2e;
--bubble-other: #2a2a2a;
--bubble-system: rgba(255, 255, 255, 0.1);
// 搜索框
--search-bg: #2a2a2a;
// 开关
--switch-off: #444444;
}

26
src/theme.json Normal file
View File

@@ -0,0 +1,26 @@
{
"light": {
"bgColor": "#fcfcfc",
"bgColorBottom": "#fcfcfc",
"bgColorTop": "#ff6b00",
"bgTxtStyle": "dark",
"navBgColor": "#ff6b00",
"navTxtStyle": "white",
"tabBgColor": "#fcfcfc",
"tabBorderStyle": "black",
"tabFontColor": "#1f2937",
"tabSelectedColor": "#ff6b00"
},
"dark": {
"bgColor": "#181818",
"bgColorBottom": "#181818",
"bgColorTop": "#ff6b00",
"bgTxtStyle": "light",
"navBgColor": "#ff6b00",
"navTxtStyle": "white",
"tabBgColor": "#181818",
"tabBorderStyle": "white",
"tabFontColor": "#f3f4f6",
"tabSelectedColor": "#ff6b00"
}
}

183
src/types/api.ts Normal file
View File

@@ -0,0 +1,183 @@
/**
* API 请求和响应的类型定义
*/
// 登录请求
export interface LoginRequest {
account: string // 邮箱或手机号
password: string
remember?: boolean
}
// 登录响应
export interface LoginResponse {
token: string
user: User
}
// 注册请求
export interface RegisterRequest {
email: string
phone: string
password: string
confirm_password: string
code: string
agree_terms: boolean
}
// 发送验证码请求
export interface SendCodeRequest {
target: string // 邮箱或手机号
type: 'email' | 'sms'
}
// 用户信息
export interface User {
id: string
email: string
phone: string
name: string
avatar: string
desc: string
region: string
created_at: string
updated_at: string
}
// 联系人信息
export interface Contact {
id: string
user_id: string
contact_user_id: string
room_id?: string // 房间ID雪花ID
room_type?: 'p2p' | 'group' // 房间类型
is_group?: boolean // 是否为群聊
member_count?: number // 群成员数量(群聊时使用)
owner_id?: string // 群主ID群聊时使用
remark_name?: string
group_id?: number
is_top: boolean
is_muted: boolean
is_special_care?: boolean
is_blocked?: boolean
user?: User // 关联的用户信息(单聊时使用)
last_msg?: string
last_time?: number
unread?: number
color?: string
}
// 好友申请
export 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
}
// 分组
export interface ContactGroup {
id: number
user_id: string
group_name: string
sort_order: number
created_at: string
}
// 房间
export interface Room {
id: string
room_id?: string // 房间ID雪花ID用于群聊
room_type: 'p2p' | 'group'
name?: string
avatar?: string
members: string[]
owner_id?: string // 群主ID
member_count?: number // 成员数量
created_at: string
}
// 聊天消息
export interface ChatMessage {
id: number
room_id: string
sender_user_id: string
receiver_user_id?: string
message_type: number // 0:文本 1:图片 2:语音 3:视频 4:系统 5:好友通知 6:信令 7:群通知 8:文件 9:朋友圈通知
content: string
duration: number
extra?: string | Record<string, any>
created_at: string
isSelf?: boolean // 前端标记
}
// 发送消息请求
export interface SendMessageRequest {
sender_client_id?: string
receiver_user_id?: string
room_id: string
message_type: number
content: string
duration?: number
extra?: string
call_id?: string
call_status?: string
}
// 附件
export interface Attachment {
id: number
uploader_id: string
file_name: string
file_type: string
file_size: number
file_path: string
file_url: string
mime_type: string
width?: number
height?: number
duration?: number
created_at: string
}
// ICE服务器配置
export interface ICEServerConfig {
urls: string[]
username?: string
credential?: string
}
// 群成员信息
export interface GroupMember {
user_id: string
room_id: string
role: number // 0:成员 1:管理员 2:群主
nickname?: string // 群名片
joined_at?: string
muted_until?: string // 禁言到期时间
user?: User // 关联的用户信息
}
// 群信息扩展Room
export interface GroupInfo extends Room {
room_id: string // 群IDgroup_xxx格式
room_type: 'group'
name: string // 群名称
avatar?: string
owner_id: string // 群主ID
member_count: number // 成员数量
members?: GroupMember[] // 成员列表
created_at: string
}
// 分页响应
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
size: number
}

29
src/types/conversation.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* 聊天会话类型定义
*/
import type { User, Room } from './api'
export interface Conversation {
id: number // 数据库主键ID
user_id: string // 所属用户ID
target_id: string // 目标ID (好友ID 或 群ID)
type: number // 1:私聊 2:群聊
room_type?: 'p2p' | 'group' // 房间类型(前端映射)
room_id?: string // 房间ID群聊时为 group_xxx单聊时可为拼接ID
is_group?: boolean // 是否为群聊(前端辅助字段)
member_count?: number // 群成员数量(群聊时使用)
owner_id?: string // 群主ID群聊时使用
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 // 单聊时的目标用户
target_group?: Room // 群聊时的群信息(可选)
}

9
src/types/index.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* 类型定义统一导出
*/
export * from './api'
export * from './conversation'
export * from './message'
export * from './moment'
export * from './user'

43
src/types/message.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* 消息相关类型定义
*/
export 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 // 朋友圈通知(预留)
}
export enum CallStatus {
INVITE = 'invite',
ACCEPTED = 'accepted',
OFFER = 'offer',
ANSWER = 'answer',
CANDIDATE = 'candidate',
HANGUP = 'hangup',
ENDED = 'ended',
ANSWERED_ELSEWHERE = 'answered_elsewhere'
}
export interface MessageExtra {
url?: string
name?: string
size?: number
duration?: string
title?: string
description?: string
image?: string
type?: 'video' | 'audio'
// 系统/通知消息相关字段
event?: string // 事件类型,如 'member_join', 'member_leave' 等
operator_id?: string // 操作者ID
target_id?: string // 目标用户ID
attachment_id?: number // 附件ID
}

178
src/types/moment.ts Normal file
View File

@@ -0,0 +1,178 @@
/**
* 朋友圈相关类型定义
*/
import type { User } from './api'
// 动态
export interface Moment {
id: number
user_id: string
content: string
media_type: 0 | 1 | 2 // 0=纯文字 1=图片 2=视频
media_urls: string | string[] // 后端返回JSON字符串前端解析后为数组
location?: string
visibility: 0 | 1 | 2 | 3 // 0=公开 1=仅好友 2=部分好友可见 3=部分好友不可见
visible_user_ids?: string | string[]
mention_user_ids?: string | string[]
topic_tags?: string | string[]
like_count: number
comment_count: number
is_deleted?: boolean
created_at: string
updated_at?: string
// 关联字段
user?: User
is_liked: boolean
likes?: MomentLike[]
comments?: MomentComment[]
}
// 点赞
export interface MomentLike {
id: number
moment_id: number
user_id: string
created_at: string
user?: User
}
// 评论
export interface MomentComment {
id: number
moment_id: number
user_id: string
reply_to_comment_id?: number | null
reply_to_user_id?: string
content: string
created_at: string
user?: User
reply_to_user?: User
}
// 通知
export 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 | null
is_read: boolean
created_at: string
// 关联字段
from_user?: User
moment?: Moment
comment?: MomentComment
}
// 发布动态请求
export interface CreateMomentRequest {
content: string
media_type: 0 | 1 | 2
media_urls?: string[]
location?: string
visibility: 0 | 1 | 2 | 3
visible_user_ids?: string[]
mention_user_ids?: string[]
topic_tags?: string[]
}
// 发表评论请求
export interface CreateCommentRequest {
content: string
reply_to_comment_id?: number
}
// 标记已读请求
export interface MarkReadRequest {
ids?: number[]
all?: boolean
}
// 分页响应
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
size: number
}
// WebSocket 朋友圈通知推送数据
export interface MomentNotifPayload {
type: 'like' | 'comment' | 'reply' | 'mention'
moment_id: number
from_user?: User
content?: string
comment_id?: number
}
// 动态可见性选项
export const VisibilityOptions = [
{ value: 0, label: '公开', icon: 'globe' },
{ value: 1, label: '仅好友可见', icon: 'users' },
{ value: 2, label: '部分好友可见', icon: 'user-check' },
{ value: 3, label: '部分好友不可见', icon: 'user-x' }
] as const
// 媒体类型
export const MediaTypes = {
TEXT: 0,
IMAGE: 1,
VIDEO: 2
} as const
// 通知类型
export const NotificationTypes = {
LIKE: 1,
COMMENT: 2,
REPLY: 3,
MENTION: 4
} as const
// 通知类型文本映射
export const NotificationTypeText: Record<number, string> = {
1: '赞了你的动态',
2: '评论了你的动态',
3: '回复了你的评论',
4: '在动态中@了你'
}
/**
* 解析媒体URL后端返回JSON字符串
*/
export function parseMediaUrls(urls: string | string[] | undefined): string[] {
if (!urls) return []
if (Array.isArray(urls)) return urls
try {
return JSON.parse(urls)
} catch {
return []
}
}
/**
* 解析话题标签
*/
export function parseTopicTags(tags: string | string[] | undefined): string[] {
if (!tags) return []
if (Array.isArray(tags)) return tags
try {
return JSON.parse(tags)
} catch {
return []
}
}
/**
* 解析@用户ID
*/
export function parseMentionUserIds(ids: string | string[] | undefined): string[] {
if (!ids) return []
if (Array.isArray(ids)) return ids
try {
return JSON.parse(ids)
} catch {
return []
}
}

19
src/types/user.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* 用户相关类型定义
*/
export interface UserInfo {
id: string
email: string
phone: string
name: string
avatar: string
desc: string
region: string
created_at: string
updated_at: string
}
export interface UserListItem extends UserInfo {
color?: string // 前端生成的颜色
}

79
src/uni.scss Normal file
View File

@@ -0,0 +1,79 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场https://ext.dcloud.net.cn上很多三方插件均使用了这些样式变量
* 如果你是插件开发者建议你使用scss预处理并在插件代码中直接使用这些变量无需 import 这个文件方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者插件使用者你可以通过修改这些变量来定制自己的插件主题实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
// wot-design-uni 主题色通过 CSS 变量覆盖 (在 App.vue 中设置)
// 不直接 import variable.scss 以避免循环导入
/* 颜色变量 */
/* 行为相关颜色 - 使用 QQ 风格主色 */
$uni-color-primary: #07c160;
$uni-color-success: #07c160;
$uni-color-warning: #ffc300;
$uni-color-error: #fa5151;
/* 文字基本颜色 */
$uni-text-color: #333; // 基本色
$uni-text-color-inverse: #fff; // 反色
$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable: #c0c0c0;
/* 背景颜色 */
$uni-bg-color: #fff;
$uni-bg-color-grey: #f8f8f8;
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
$uni-bg-color-mask: rgba(0, 0, 0, 0.4); // 遮罩颜色
/* 边框颜色 */
$uni-border-color: #c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm: 12px;
$uni-font-size-base: 14px;
$uni-font-size-lg: 16px;
/* 图片尺寸 */
$uni-img-size-sm: 20px;
$uni-img-size-base: 26px;
$uni-img-size-lg: 40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2c405a; // 文章标题颜色
$uni-font-size-title: 20px;
$uni-color-subtitle: #555; // 二级标题颜色
$uni-font-size-subtitle: 18px;
$uni-color-paragraph: #3f536e; // 文章段落颜色
$uni-font-size-paragraph: 15px;

172
src/utils/format.ts Normal file
View File

@@ -0,0 +1,172 @@
/**
* 格式化工具函数
*/
/**
* 格式化时间(相对时间)
*/
export function formatTime(timestamp: number | string): string {
const date = new Date(typeof timestamp === 'string' ? timestamp : 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)
// 刚刚1分钟内
if (seconds < 60) {
return '刚刚'
}
// X分钟前1小时内
if (minutes < 60) {
return `${minutes}分钟前`
}
// X小时前24小时内
if (hours < 24) {
return `${hours}小时前`
}
// 昨天
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) {
return '昨天'
}
// X天前7天内
if (days < 7) {
return `${days}天前`
}
// 超过7天显示具体日期
const currentYear = now.getFullYear()
const messageYear = date.getFullYear()
if (currentYear === messageYear) {
// 今年:显示月-日
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${month}-${day}`
} else {
// 去年或更早:显示年-月-日
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${year}-${month}-${day}`
}
}
/**
* 格式化消息时间(用于聊天记录)
*/
export function formatMessageTime(timestamp: number | string): string {
const date = new Date(typeof timestamp === 'string' ? timestamp : 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 formatTimeStr = (d: Date) => {
const h = d.getHours().toString().padStart(2, '0')
const m = d.getMinutes().toString().padStart(2, '0')
return `${h}:${m}`
}
// 刚刚1分钟内
if (seconds < 60) {
return '刚刚'
}
// X分钟前1小时内
if (minutes < 60) {
return `${minutes}分钟前`
}
// X小时前24小时内
if (hours < 24) {
return `${hours}小时前`
}
// 昨天 HH:mm
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) {
return `昨天 ${formatTimeStr(date)}`
}
// 本周内:显示星期几 HH:mm
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 7)
if (date > weekAgo) {
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const weekday = weekdays[date.getDay()]
return `${weekday} ${formatTimeStr(date)}`
}
// 今年MM-DD HH:mm
const currentYear = now.getFullYear()
const messageYear = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
if (currentYear === messageYear) {
return `${month}-${day} ${formatTimeStr(date)}`
} else {
// 去年或更早YYYY-MM-DD HH:mm
const year = date.getFullYear()
return `${year}-${month}-${day} ${formatTimeStr(date)}`
}
}
/**
* 格式化文件大小
*/
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 mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
/**
* 格式化相对时间(别名)
*/
export function formatRelativeTime(timestamp: number | string): string {
return formatTime(timestamp)
}
/**
* 生成用户头像颜色
*/
export function generateColor(str: string): string {
const colors = [
'#3b82f6', // blue
'#ec4899', // pink
'#10b981', // green
'#f59e0b', // amber
'#6366f1', // indigo
'#ef4444', // red
'#8b5cf6', // purple
'#06b6d4' // cyan
]
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
return colors[Math.abs(hash) % colors.length]
}

7
src/utils/index.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* 工具函数统一导出
*/
export * from './storage'
export * from './format'
export * from './message'
export * from './platform'

144
src/utils/message.ts Normal file
View File

@@ -0,0 +1,144 @@
/**
* 消息处理工具函数
*/
import type { ChatMessage } from '@/types/api'
import { MessageType } from '@/types/message'
export interface MessageTypeConfig {
label: string
icon: string
summaryFn: (msg: ChatMessage) => string
}
/**
* 消息类型配置映射表
*/
export const messageTypeConfigs: Record<number, MessageTypeConfig> = {
[MessageType.TEXT]: {
label: '文本消息',
icon: 'chat',
summaryFn: (msg) => msg.content || ''
},
[MessageType.IMAGE]: {
label: '图片',
icon: 'picture',
summaryFn: () => '[图片]'
},
[MessageType.AUDIO]: {
label: '语音',
icon: 'volume',
summaryFn: () => '[语音]'
},
[MessageType.VIDEO]: {
label: '视频',
icon: 'video',
summaryFn: () => '[视频]'
},
[MessageType.SYSTEM]: {
label: '系统消息',
icon: 'warning',
summaryFn: (msg) => {
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra || {}
return extra.title || msg.content || '[系统消息]'
}
},
[MessageType.FRIEND_NOTIFY]: {
label: '好友通知',
icon: 'add-circle',
summaryFn: (msg) => {
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra || {}
return extra.title || msg.content || '[好友通知]'
}
},
[MessageType.SIGNAL]: {
label: '通话',
icon: 'phone',
summaryFn: () => '[通话]'
},
[MessageType.GROUP_NOTIFY]: {
label: '群通知',
icon: 'user',
summaryFn: (msg) => {
// 优先使用后端发送的 content
if (msg.content) {
return msg.content
}
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra || {}
// 根据事件类型生成更具体的文案
if (extra.type === 'member_invite' || extra.event === 'member_invite') {
const operatorName = extra.operator_name || '未知用户'
const targetNames = extra.target_names || []
if (targetNames.length > 0) {
return `${operatorName} 邀请了 ${targetNames.join('、')} 加入群聊`
}
return `${operatorName} 邀请了成员加入群聊`
} else if (extra.type === 'member_remove' || extra.event === 'member_remove') {
const operatorName = extra.operator_name || '未知用户'
const targetName = extra.target_name || '用户'
return `${operatorName}${targetName} 移出了群聊`
} else if (extra.type === 'member_leave' || extra.event === 'member_leave') {
const targetName = extra.target_name || '用户'
return `${targetName} 退出了群聊`
} else if (extra.type === 'member_join' || extra.event === 'member_join') {
const targetName = extra.target_name || '用户'
return `${targetName} 加入了群聊`
} else if (extra.event === 'group_update') {
return `群信息已更新`
} else if (extra.event === 'role_change') {
return `成员角色已变更`
}
return extra.title || '[群通知]'
}
},
[MessageType.FILE]: {
label: '文件',
icon: 'folder',
summaryFn: (msg) => {
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra || {}
return extra.name ? `[文件] ${extra.name}` : '[文件]'
}
},
[MessageType.MOMENTS_NOTIFY]: {
label: '朋友圈通知',
icon: 'heart',
summaryFn: (msg) => {
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra || {}
return extra.title || msg.content || '[朋友圈通知]'
}
}
}
/**
* 获取消息摘要
*/
export function getMessageSummary(msg: ChatMessage): string {
const config = messageTypeConfigs[msg.message_type]
if (config) {
return config.summaryFn(msg)
}
return msg.content || '[未知消息]'
}
/**
* 获取消息类型标签
*/
export function getMessageTypeLabel(messageType: number): string {
return messageTypeConfigs[messageType]?.label || '未知消息'
}
/**
* 获取消息类型图标
*/
export function getMessageTypeIcon(messageType: number): string {
return messageTypeConfigs[messageType]?.icon || 'warning'
}
/**
* 判断是否为系统/通知类消息(需要特殊样式)
*/
export function isSystemOrNotifyMessage(messageType: number): boolean {
return [MessageType.SYSTEM, MessageType.FRIEND_NOTIFY, MessageType.GROUP_NOTIFY, MessageType.MOMENTS_NOTIFY].includes(
messageType
)
}

179
src/utils/platform.ts Normal file
View File

@@ -0,0 +1,179 @@
/**
* 平台相关工具函数
*/
/**
* 是否 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
}
/**
* 是否 H5
*/
export function isH5(): boolean {
// #ifdef H5
return true
// #endif
// #ifndef H5
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 chooseVideo(): Promise<UniApp.ChooseVideoSuccess> {
return new Promise((resolve, reject) => {
uni.chooseVideo({
sourceType: ['album', 'camera'],
compressed: true,
success: resolve,
fail: reject
})
})
}
/**
* 选择文件
*/
export function chooseFile(count = 1): Promise<any> {
return new Promise((resolve, reject) => {
// #ifdef APP-PLUS
uni.chooseFile({
count,
success: resolve,
fail: reject
})
// #endif
// #ifdef MP-WEIXIN
// @ts-ignore
wx.chooseMessageFile({
count,
type: 'file',
success: resolve,
fail: reject
})
// #endif
// #ifdef H5
// H5 使用 input[type=file]
const input = document.createElement('input')
input.type = 'file'
input.multiple = count > 1
input.onchange = (e: any) => {
const files = e.target.files
resolve({ tempFiles: Array.from(files) })
}
input.click()
// #endif
})
}
/**
* 扫码
*/
export function scanCode(): Promise<string> {
return new Promise((resolve, reject) => {
uni.scanCode({
onlyFromCamera: false,
success: (res) => resolve(res.result),
fail: reject
})
})
}
/**
* 预览图片
*/
export function previewImage(urls: string[], current = 0) {
uni.previewImage({
urls,
current
})
}
/**
* 保存图片到相册
*/
export function saveImageToAlbum(filePath: string): Promise<void> {
return new Promise((resolve, reject) => {
uni.saveImageToPhotosAlbum({
filePath,
success: () => {
uni.showToast({ title: '保存成功', icon: 'success' })
resolve()
},
fail: (err) => {
if (err.errMsg.includes('auth deny')) {
uni.showToast({ title: '请授权保存图片', icon: 'none' })
}
reject(err)
}
})
})
}
/**
* 复制到剪贴板
*/
export function copyToClipboard(text: string): Promise<void> {
return new Promise((resolve, reject) => {
uni.setClipboardData({
data: text,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
resolve()
},
fail: reject
})
})
}
/**
* 拨打电话
*/
export function makePhoneCall(phoneNumber: string) {
uni.makePhoneCall({
phoneNumber,
fail: (err) => {
if (!err.errMsg.includes('cancel')) {
uni.showToast({ title: '拨号失败', icon: 'none' })
}
}
})
}

142
src/utils/storage.ts Normal file
View File

@@ -0,0 +1,142 @@
/**
* 本地存储工具函数
*/
const KEYS = {
TOKEN: 'nl_im_token',
USER_ID: 'nl_im_user_id',
USER_INFO: 'nl_im_user_info',
REMEMBER: 'nl_im_remember',
CURRENT_TAB: 'nl_im_current_tab',
CONTACT_LEFT_PANEL_MODE: 'nl_im_contact_left_panel_mode',
CONTACT_LIST_TAB: 'nl_im_contact_list_tab',
SELECTED_CONVERSATION: 'nl_im_selected_conversation',
SELECTED_ROOM_ID: 'nl_im_selected_room_id',
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(userId: string) {
uni.setStorageSync(KEYS.USER_ID, userId)
},
getUserId(): string | null {
return uni.getStorageSync(KEYS.USER_ID) || null
},
removeUserId() {
uni.removeStorageSync(KEYS.USER_ID)
},
// User Info
setUserInfo(userInfo: any) {
uni.setStorageSync(KEYS.USER_INFO, JSON.stringify(userInfo))
},
getUserInfo(): any | null {
const info = uni.getStorageSync(KEYS.USER_INFO)
if (!info) return null
try {
return JSON.parse(info)
} catch {
return null
}
},
removeUserInfo() {
uni.removeStorageSync(KEYS.USER_INFO)
},
// Remember
setRemember(remember: boolean) {
uni.setStorageSync(KEYS.REMEMBER, String(remember))
},
getRemember(): boolean {
return uni.getStorageSync(KEYS.REMEMBER) === 'true'
},
// Current Tab (chat/contact/moment)
setCurrentTab(tab: 'chat' | 'contact' | 'moment') {
uni.setStorageSync(KEYS.CURRENT_TAB, tab)
},
getCurrentTab(): 'chat' | 'contact' | 'moment' {
const tab = uni.getStorageSync(KEYS.CURRENT_TAB)
return tab === 'chat' || tab === 'contact' || tab === 'moment' ? tab : 'chat'
},
// Contact Left Panel Mode
setContactLeftPanelMode(mode: string) {
uni.setStorageSync(KEYS.CONTACT_LEFT_PANEL_MODE, mode)
},
getContactLeftPanelMode(): string {
return uni.getStorageSync(KEYS.CONTACT_LEFT_PANEL_MODE) || 'default'
},
// Contact List Tab
setContactListTab(tab: 'friends' | 'groups') {
uni.setStorageSync(KEYS.CONTACT_LIST_TAB, tab)
},
getContactListTab(): 'friends' | 'groups' {
const tab = uni.getStorageSync(KEYS.CONTACT_LIST_TAB)
return tab === 'friends' || tab === 'groups' ? tab : 'friends'
},
// Selected Conversation
setSelectedConversation(targetId: string | null) {
if (targetId) {
uni.setStorageSync(KEYS.SELECTED_CONVERSATION, targetId)
} else {
uni.removeStorageSync(KEYS.SELECTED_CONVERSATION)
}
},
getSelectedConversation(): string | null {
return uni.getStorageSync(KEYS.SELECTED_CONVERSATION) || null
},
// Selected Room ID
setSelectedRoomId(roomId: string | null) {
if (roomId) {
uni.setStorageSync(KEYS.SELECTED_ROOM_ID, roomId)
} else {
uni.removeStorageSync(KEYS.SELECTED_ROOM_ID)
}
},
getSelectedRoomId(): string | null {
return uni.getStorageSync(KEYS.SELECTED_ROOM_ID) || null
},
// Theme
setTheme(theme: 'light' | 'dark') {
uni.setStorageSync(KEYS.THEME, theme)
},
getTheme(): 'light' | 'dark' | null {
const theme = uni.getStorageSync(KEYS.THEME)
return theme === 'light' || theme === 'dark' ? theme : null
},
// Clear all
clear() {
Object.values(KEYS).forEach((key) => uni.removeStorageSync(key))
}
}

40
tsconfig.json Normal file
View File

@@ -0,0 +1,40 @@
{
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"jsx": "preserve",
"jsxImportSource": "vue",
"noImplicitThis": true,
"strict": true,
"verbatimModuleSyntax": true,
"target": "ESNext",
"useDefineForClassFields": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": [
"vite/client",
"@dcloudio/types",
"@mini-types/alipay",
"miniprogram-api-typings",
"wot-design-uni/global.d.ts",
"@uni-helper/uni-types"
]
},
"include": [
"**/*.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.jsx",
"src/**/*.vue"
],
"vueCompilerOptions": {
"plugins": ["@uni-helper/uni-types/volar-plugin"]
}
}

17
unh.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from '@uni-helper/unh'
/**
* unh 配置文件
* 更多配置请参考https://uni-helper.js.org/unh/
*/
export default defineConfig({
platform: {
// 默认平台
default: 'h5',
// 平台别名
alias: {
'h5': ['w', 'h'],
'mp-weixin': 'wx',
},
},
})

33
vite.config.ts Normal file
View File

@@ -0,0 +1,33 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import Components from '@uni-helper/vite-plugin-uni-components'
import { WotResolver } from '@uni-helper/vite-plugin-uni-components/resolvers'
import Uni from '@uni-helper/plugin-uni'
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
plugins: [
// https://uni-helper.js.org/vite-plugin-uni-components
Components({
dts: true,
resolvers: [WotResolver()]
}),
// https://uni-helper.js.org/plugin-uni
Uni(),
],
css: {
preprocessorOptions: {
scss: {
// 使用 @use 替代 @import 避免循环导入
additionalData: `@use "wot-design-uni/components/common/abstracts/variable.scss" as *;`,
},
},
},
})