初始化
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
shamefully-hoist=true
|
||||
16
.vscode/extensions.json
vendored
Normal file
16
.vscode/extensions.json
vendored
Normal 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
37
.vscode/settings.json
vendored
Normal 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
1617
DEVELOPMENT.md
Normal file
File diff suppressed because it is too large
Load Diff
1458
WOT-COMPONENTS.md
Normal file
1458
WOT-COMPONENTS.md
Normal file
File diff suppressed because it is too large
Load Diff
35
components.d.ts
vendored
Normal file
35
components.d.ts
vendored
Normal 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
3
eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import uniHelper from '@uni-helper/eslint-config'
|
||||
|
||||
export default uniHelper()
|
||||
21
index.html
Normal file
21
index.html
Normal 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
59
package.json
Normal 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
12862
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
79
src/App.vue
Normal file
79
src/App.vue
Normal 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
17
src/api/index.ts
Normal 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'
|
||||
25
src/api/modules/attachment.ts
Normal file
25
src/api/modules/attachment.ts
Normal 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
30
src/api/modules/auth.ts
Normal 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')
|
||||
}
|
||||
85
src/api/modules/contact.ts
Normal file
85
src/api/modules/contact.ts
Normal 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}`)
|
||||
}
|
||||
39
src/api/modules/conversation.ts
Normal file
39
src/api/modules/conversation.ts
Normal 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}`)
|
||||
}
|
||||
28
src/api/modules/message.ts
Normal file
28
src/api/modules/message.ts
Normal 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
99
src/api/modules/moment.ts
Normal 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
151
src/api/modules/room.ts
Normal 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
25
src/api/modules/system.ts
Normal 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
33
src/api/modules/user.ts
Normal 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
264
src/api/request/index.ts
Normal 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
30
src/api/request/types.ts
Normal 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
243
src/api/websocket/index.ts
Normal 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()
|
||||
34
src/components/AppFooter.vue
Normal file
34
src/components/AppFooter.vue
Normal 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>
|
||||
59
src/components/AppLogos.vue
Normal file
59
src/components/AppLogos.vue
Normal 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>
|
||||
36
src/components/InputEntry.vue
Normal file
36
src/components/InputEntry.vue
Normal 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>
|
||||
107
src/components/chat/MessageBubble.vue
Normal file
107
src/components/chat/MessageBubble.vue
Normal 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>
|
||||
106
src/components/chat/MessageInput.vue
Normal file
106
src/components/chat/MessageInput.vue
Normal 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>
|
||||
115
src/components/common/AppAvatar.vue
Normal file
115
src/components/common/AppAvatar.vue
Normal 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>
|
||||
24
src/components/common/AppEmpty.vue
Normal file
24
src/components/common/AppEmpty.vue
Normal 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>
|
||||
50
src/components/common/AppLoading.vue
Normal file
50
src/components/common/AppLoading.vue
Normal 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>
|
||||
136
src/components/common/AppNavBar.vue
Normal file
136
src/components/common/AppNavBar.vue
Normal 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
12
src/components/index.ts
Normal 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'
|
||||
219
src/components/moment/MomentCard.vue
Normal file
219
src/components/moment/MomentCard.vue
Normal 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
1
src/composables/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useTheme } from './useTheme'
|
||||
113
src/composables/useTheme.ts
Normal file
113
src/composables/useTheme.ts
Normal 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
14
src/main.ts
Normal 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
70
src/manifest.json
Normal 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
169
src/pages.json
Normal 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
690
src/pages/chat/index.vue
Normal 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
221
src/pages/contact/add.vue
Normal 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>
|
||||
242
src/pages/contact/detail.vue
Normal file
242
src/pages/contact/detail.vue
Normal 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>
|
||||
135
src/pages/contact/groups.vue
Normal file
135
src/pages/contact/groups.vue
Normal 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
395
src/pages/contact/index.vue
Normal 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>
|
||||
174
src/pages/contact/requests.vue
Normal file
174
src/pages/contact/requests.vue
Normal 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
215
src/pages/group/create.vue
Normal 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
331
src/pages/group/info.vue
Normal 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
123
src/pages/group/members.vue
Normal 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
116
src/pages/group/notify.vue
Normal 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
20
src/pages/index.vue
Normal 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
539
src/pages/index/index.vue
Normal 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
264
src/pages/login/index.vue
Normal 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
396
src/pages/moment/detail.vue
Normal 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
552
src/pages/moment/index.vue
Normal 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
217
src/pages/moment/notify.vue
Normal 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>
|
||||
313
src/pages/moment/publish.vue
Normal file
313
src/pages/moment/publish.vue
Normal 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
103
src/pages/profile/edit.vue
Normal 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
112
src/pages/profile/index.vue
Normal 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
229
src/pages/search/index.vue
Normal 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>
|
||||
145
src/pages/settings/index.vue
Normal file
145
src/pages/settings/index.vue
Normal 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
1
src/static/github.svg
Normal 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 |
1
src/static/icons/chat-active.png
Normal file
1
src/static/icons/chat-active.png
Normal file
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
1
src/static/icons/chat.png
Normal file
1
src/static/icons/chat.png
Normal file
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
1
src/static/icons/contact-active.png
Normal file
1
src/static/icons/contact-active.png
Normal file
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
1
src/static/icons/contact.png
Normal file
1
src/static/icons/contact.png
Normal file
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
1
src/static/icons/moment-active.png
Normal file
1
src/static/icons/moment-active.png
Normal file
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
1
src/static/icons/moment.png
Normal file
1
src/static/icons/moment.png
Normal file
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
1
src/static/images/moment-cover.jpg
Normal file
1
src/static/images/moment-cover.jpg
Normal file
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
1
src/static/logo.svg
Normal file
1
src/static/logo.svg
Normal 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
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
140
src/stores/app.ts
Normal 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
108
src/stores/auth.ts
Normal 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
157
src/stores/chat.ts
Normal 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
120
src/stores/contact.ts
Normal 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
219
src/stores/conversation.ts
Normal 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
10
src/stores/counter.ts
Normal 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
9
src/stores/index.ts
Normal 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
452
src/stores/moment.ts
Normal 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
181
src/styles/common.scss
Normal 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
101
src/styles/variables.scss
Normal 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
26
src/theme.json
Normal 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
183
src/types/api.ts
Normal 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 // 群ID(group_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
29
src/types/conversation.ts
Normal 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
9
src/types/index.ts
Normal 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
43
src/types/message.ts
Normal 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
178
src/types/moment.ts
Normal 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
19
src/types/user.ts
Normal 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
79
src/uni.scss
Normal 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
172
src/utils/format.ts
Normal 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
7
src/utils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 工具函数统一导出
|
||||
*/
|
||||
export * from './storage'
|
||||
export * from './format'
|
||||
export * from './message'
|
||||
export * from './platform'
|
||||
144
src/utils/message.ts
Normal file
144
src/utils/message.ts
Normal 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
179
src/utils/platform.ts
Normal 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
142
src/utils/storage.ts
Normal 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
40
tsconfig.json
Normal 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
17
unh.config.ts
Normal 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
33
vite.config.ts
Normal 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 *;`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user