修复一些问题
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,3 +19,5 @@ dist
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/.mimocode/
|
||||
/.vscode/
|
||||
|
||||
26
components.d.ts
vendored
26
components.d.ts
vendored
@@ -8,12 +8,15 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ActionMenu: typeof import('./src/components/moment/ActionMenu.vue')['default']
|
||||
AppActionSheet: typeof import('./src/components/common/AppActionSheet.vue')['default']
|
||||
AppAvatar: typeof import('./src/components/common/AppAvatar.vue')['default']
|
||||
AppConfirmHost: typeof import('./src/components/common/AppConfirmHost.vue')['default']
|
||||
AppDrawer: typeof import('./src/components/common/AppDrawer.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']
|
||||
AppModal: typeof import('./src/components/common/AppModal.vue')['default']
|
||||
AppNavBar: typeof import('./src/components/common/AppNavBar.vue')['default']
|
||||
AppTabBar: typeof import('./src/components/common/AppTabBar.vue')['default']
|
||||
CallWindow: typeof import('./src/components/call/CallWindow.vue')['default']
|
||||
@@ -41,28 +44,5 @@ declare module 'vue' {
|
||||
UserMomentsModal: typeof import('./src/components/moment/UserMomentsModal.vue')['default']
|
||||
VideoPlayer: typeof import('./src/components/common/VideoPlayer.vue')['default']
|
||||
VisibilitySelector: typeof import('./src/components/moment/VisibilitySelector.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']
|
||||
WdCellGroup: typeof import('wot-design-uni/components/wd-cell-group/wd-cell-group.vue')['default']
|
||||
WdCheckbox: typeof import('wot-design-uni/components/wd-checkbox/wd-checkbox.vue')['default']
|
||||
WdConfigProvider: typeof import('wot-design-uni/components/wd-config-provider/wd-config-provider.vue')['default']
|
||||
WdDivider: typeof import('wot-design-uni/components/wd-divider/wd-divider.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']
|
||||
WdInput: typeof import('wot-design-uni/components/wd-input/wd-input.vue')['default']
|
||||
WdLoading: typeof import('wot-design-uni/components/wd-loading/wd-loading.vue')['default']
|
||||
WdLoadmore: typeof import('wot-design-uni/components/wd-loadmore/wd-loadmore.vue')['default']
|
||||
WdMessageBox: typeof import('wot-design-uni/components/wd-message-box/wd-message-box.vue')['default']
|
||||
WdNavbar: typeof import('wot-design-uni/components/wd-navbar/wd-navbar.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']
|
||||
WdStatusTip: typeof import('wot-design-uni/components/wd-status-tip/wd-status-tip.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']
|
||||
WdTag: typeof import('wot-design-uni/components/wd-tag/wd-tag.vue')['default']
|
||||
WdTextarea: typeof import('wot-design-uni/components/wd-textarea/wd-textarea.vue')['default']
|
||||
WdToast: typeof import('wot-design-uni/components/wd-toast/wd-toast.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,9 @@ async function initWebSocket() {
|
||||
// 注册全局消息处理器
|
||||
wsManager.onMessage(handleGlobalMessage)
|
||||
// 群通话信令监听器已移至 App.ku.vue 初始化
|
||||
console.log('✅ WebSocket 全局初始化成功')
|
||||
console.log('[OK] WebSocket initialized')
|
||||
} catch (error) {
|
||||
console.error('WebSocket 初始化失败:', error)
|
||||
console.error('WebSocket init failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 联系人管理相关 API
|
||||
*/
|
||||
import request from '../request'
|
||||
import type { Contact, User, FriendRequest, ContactGroup } from '@/types/api'
|
||||
import type { Contact, User, FriendRequest, ContactGroup, CommonGroup } from '@/types/api'
|
||||
|
||||
// 获取联系人列表
|
||||
export function getContacts() {
|
||||
@@ -54,16 +54,22 @@ export function deleteGroup(id: number) {
|
||||
return request.post<void>(`/contacts/groups/delete/${id}`)
|
||||
}
|
||||
|
||||
// 获取好友详情
|
||||
// 获取好友详情(合并 common_groups 等扩展字段)
|
||||
export async function getContactDetail(id: string): Promise<Contact> {
|
||||
const response = await request.get<{ contact: any; user: User }>(`/contacts/${id}`)
|
||||
const response = await request.get<{
|
||||
contact: any
|
||||
user: User
|
||||
common_groups?: CommonGroup[]
|
||||
is_friend?: boolean
|
||||
}>(`/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
|
||||
user: response.user,
|
||||
common_groups: response.common_groups ?? [],
|
||||
} as Contact
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ChatMessage, SendMessageRequest, PaginatedResponse } from '@/types
|
||||
|
||||
// 发送消息
|
||||
export function sendMessage(data: SendMessageRequest) {
|
||||
return request.post<void>('/send', data)
|
||||
return request.post<ChatMessage>('/send', data)
|
||||
}
|
||||
|
||||
// 获取历史消息
|
||||
@@ -29,5 +29,10 @@ export function syncMessages(roomId: string, page = 1, pageSize = 50) {
|
||||
|
||||
// 撤回消息
|
||||
export function recallMessage(messageId: number) {
|
||||
return request.post<void>('/messages/recall', { message_id: messageId })
|
||||
return request.post<ChatMessage>('/messages/recall', { message_id: messageId })
|
||||
}
|
||||
|
||||
/** 标记消息已读 */
|
||||
export function markMessagesRead(messageIds: number[]) {
|
||||
return request.post<void>('/messages/read-receipts', { message_ids: messageIds })
|
||||
}
|
||||
12
src/api/modules/settings.ts
Normal file
12
src/api/modules/settings.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 用户设置 API
|
||||
*/
|
||||
import request from '../request'
|
||||
|
||||
export function getUserSettings() {
|
||||
return request.get<Record<string, string>>('/user/settings')
|
||||
}
|
||||
|
||||
export function updateUserSettings(settings: Record<string, string>) {
|
||||
return request.post<Record<string, string>>('/user/settings', { settings })
|
||||
}
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
import type { ApiResponse, RequestConfig } from './types'
|
||||
import { storage } from '@/utils/storage'
|
||||
import { API_BASE_URL } from '@/config/app'
|
||||
|
||||
// API 基础路径
|
||||
// const BASE_URL = 'http://127.0.0.1:12080/api'
|
||||
const BASE_URL = 'https://g-ws.nailaoyun.cn/api'
|
||||
const BASE_URL = API_BASE_URL
|
||||
|
||||
// 默认超时时间
|
||||
const TIMEOUT = 30000
|
||||
|
||||
@@ -39,8 +39,8 @@ class WebSocketManager {
|
||||
|
||||
this.userId = userId
|
||||
// TODO: 替换为实际的 WebSocket 地址
|
||||
// const wsUrl = `ws://g-ws.nailaoyun.cn/ws?user_id=${userId}`
|
||||
const wsUrl = `wss://g-ws.nailaoyun.cn/ws?user_id=${userId}`
|
||||
const wsUrl = `ws://127.0.0.1:12080/ws?user_id=${userId}`
|
||||
// const wsUrl = `wss://g-ws.nailaoyun.cn/ws?user_id=${userId}`
|
||||
|
||||
this.socketTask = uni.connectSocket({
|
||||
url: wsUrl,
|
||||
|
||||
@@ -254,7 +254,7 @@ watch(() => props.remoteStream, (stream) => {
|
||||
/* #endif */
|
||||
|
||||
/* #ifndef MP-WEIXIN */
|
||||
top: calc(var(--status-bar-height, 0) + 240rpx);
|
||||
top: calc(var(--status-bar-height, 20px) + 240rpx);
|
||||
/* #endif */
|
||||
}
|
||||
}
|
||||
@@ -367,7 +367,7 @@ watch(() => props.remoteStream, (stream) => {
|
||||
/* #endif */
|
||||
|
||||
/* #ifndef MP-WEIXIN */
|
||||
top: calc(28rpx + var(--status-bar-height, 0));
|
||||
top: calc(28rpx + var(--status-bar-height, 20px));
|
||||
/* #endif */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<!-- ========== H5/App 单聊通话组件 ========== -->
|
||||
<!-- H5/App single call -->
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
<call-window
|
||||
v-if="h5CallActive"
|
||||
@@ -16,19 +16,14 @@
|
||||
/>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- ========== 微信小程序单聊通话组件 ========== -->
|
||||
<!-- WeChat mini program single call -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<mini-program-call-window />
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- ========== 群通话组件(所有平台共用) ========== -->
|
||||
<!-- 全局群通话来电覆盖层 -->
|
||||
<!-- Group call (all platforms) -->
|
||||
<group-call-incoming />
|
||||
|
||||
<!-- 全局群通话窗口 -->
|
||||
<group-call-window />
|
||||
|
||||
<!-- 群聊通话横幅 (有正在进行的通话但用户未加入) -->
|
||||
<group-call-banner />
|
||||
</template>
|
||||
|
||||
@@ -64,26 +59,20 @@ import MiniProgramCallWindow from './MiniProgramCallWindow.vue'
|
||||
const mpCall = useMiniProgramCall()
|
||||
// #endif
|
||||
|
||||
// 群通话
|
||||
const { initListener: initGroupListener } = useGroupWebRTC()
|
||||
|
||||
onMounted(() => {
|
||||
// #ifdef H5 || APP-PLUS
|
||||
// 初始化单聊通话信令监听器 (H5/App)
|
||||
webrtc.initListener()
|
||||
console.log('✅ [GlobalCallProvider] H5/App WebRTC 信令监听器已初始化')
|
||||
console.log('[GlobalCallProvider] H5/App WebRTC listener initialized')
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 初始化小程序通话信令监听器
|
||||
// 注意:initPusherContext 不在这里调用,因为此时 live-pusher 组件还未渲染
|
||||
// live-pusher 的 Context 初始化由 MiniProgramCallWindow.vue 在 pushUrl 设置后处理
|
||||
mpCall.initListener()
|
||||
console.log('✅ [GlobalCallProvider] 微信小程序通话监听器已初始化')
|
||||
console.log('[GlobalCallProvider] MP call listener initialized')
|
||||
// #endif
|
||||
|
||||
// 初始化群通话信令监听器(所有平台)
|
||||
initGroupListener()
|
||||
console.log('✅ [GlobalCallProvider] 群通话信令监听器已初始化')
|
||||
console.log('[GlobalCallProvider] Group call listener initialized')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -179,7 +179,7 @@ function handleClose() {
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
z-index: 2100;
|
||||
|
||||
--bg-surface: #fff;
|
||||
--bg-hover: #f5f5f5;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<view v-if="show" class="emoji-picker" :class="{ dark: isDark }">
|
||||
<!-- 分类标签 -->
|
||||
<view class="emoji-tabs">
|
||||
<view
|
||||
v-for="(cat, idx) in categories"
|
||||
@@ -13,7 +12,6 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 表情列表 -->
|
||||
<scroll-view scroll-y class="emoji-grid-container">
|
||||
<view class="emoji-grid">
|
||||
<view
|
||||
@@ -33,7 +31,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
@@ -49,87 +47,67 @@ const activeTab = ref(0)
|
||||
const categories = [
|
||||
{
|
||||
key: 'face',
|
||||
icon: '😀',
|
||||
icon: '\u{1F600}',
|
||||
emojis: [
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
|
||||
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
|
||||
'😘', '😗', '☺️', '😚', '😙', '🥲', '😋', '😛',
|
||||
'😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔',
|
||||
'🤐', '🤨', '😐', '😑', '😶', '😏', '😒', '🙄',
|
||||
'😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷',
|
||||
'🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴',
|
||||
'😵', '🤯', '🤠', '🥳', '🥸', '😎', '🤓', '🧐'
|
||||
'\u{1F600}', '\u{1F603}', '\u{1F604}', '\u{1F601}', '\u{1F606}', '\u{1F605}', '\u{1F923}', '\u{1F602}',
|
||||
'\u{1F642}', '\u{1F643}', '\u{1F609}', '\u{1F60A}', '\u{1F607}', '\u{1F970}', '\u{1F60D}', '\u{1F929}',
|
||||
'\u{1F618}', '\u{1F617}', '\u{263A}\u{FE0F}', '\u{1F61A}', '\u{1F619}', '\u{1F972}', '\u{1F60B}', '\u{1F61B}',
|
||||
'\u{1F61C}', '\u{1F92A}', '\u{1F61D}', '\u{1F911}', '\u{1F917}', '\u{1F92D}', '\u{1F92B}', '\u{1F914}',
|
||||
'\u{1F910}', '\u{1F928}', '\u{1F610}', '\u{1F611}', '\u{1F636}', '\u{1F60F}', '\u{1F612}', '\u{1F644}',
|
||||
'\u{1F62C}', '\u{1F925}', '\u{1F60C}', '\u{1F614}', '\u{1F62A}', '\u{1F924}', '\u{1F634}', '\u{1F637}',
|
||||
'\u{1F912}', '\u{1F915}', '\u{1F922}', '\u{1F92E}', '\u{1F927}', '\u{1F975}', '\u{1F976}', '\u{1F974}',
|
||||
'\u{1F635}', '\u{1F92F}', '\u{1F920}', '\u{1F973}', '\u{1F978}', '\u{1F60E}', '\u{1F913}', '\u{1F9D0}'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'gesture',
|
||||
icon: '👋',
|
||||
icon: '\u{1F44B}',
|
||||
emojis: [
|
||||
'👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏',
|
||||
'✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆',
|
||||
'🖕', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛',
|
||||
'🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️',
|
||||
'💪', '🦾', '🦿', '🦵', '🦶', '👂', '🦻', '👃'
|
||||
'\u{1F44B}', '\u{1F91A}', '\u{1F590}\u{FE0F}', '\u{270B}', '\u{1F596}', '\u{1F44C}', '\u{1F90C}', '\u{1F90F}',
|
||||
'\u{270C}\u{FE0F}', '\u{1F91E}', '\u{1F91F}', '\u{1F918}', '\u{1F919}', '\u{1F448}', '\u{1F449}', '\u{1F446}',
|
||||
'\u{1F595}', '\u{1F447}', '\u{261D}\u{FE0F}', '\u{1F44D}', '\u{1F44E}', '\u{270A}', '\u{1F44A}', '\u{1F91B}',
|
||||
'\u{1F91C}', '\u{1F44F}', '\u{1F64C}', '\u{1F450}', '\u{1F932}', '\u{1F91D}', '\u{1F64F}', '\u{270D}\u{FE0F}',
|
||||
'\u{1F4AA}', '\u{1F9BE}', '\u{1F9BF}', '\u{1F9B5}', '\u{1F9B6}', '\u{1F442}', '\u{1F9BB}', '\u{1F443}'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'heart',
|
||||
icon: '❤️',
|
||||
icon: '\u{2764}\u{FE0F}',
|
||||
emojis: [
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
|
||||
'🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖',
|
||||
'💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉️', '☸️',
|
||||
'✡️', '🔯', '🕎', '☯️', '☦️', '🛐', '⛎', '♈'
|
||||
'\u{2764}\u{FE0F}', '\u{1F9E1}', '\u{1F49B}', '\u{1F49A}', '\u{1F499}', '\u{1F49C}', '\u{1F5A4}', '\u{1F90D}',
|
||||
'\u{1F90E}', '\u{1F494}', '\u{2763}\u{FE0F}', '\u{1F495}', '\u{1F49E}', '\u{1F493}', '\u{1F497}', '\u{1F496}',
|
||||
'\u{1F498}', '\u{1F49D}', '\u{1F49F}', '\u{262E}\u{FE0F}', '\u{271D}\u{FE0F}', '\u{262A}\u{FE0F}', '\u{1F549}\u{FE0F}', '\u{2638}\u{FE0F}',
|
||||
'\u{2721}\u{FE0F}', '\u{1F52F}', '\u{1F54E}', '\u{262F}\u{FE0F}', '\u{2626}\u{FE0F}', '\u{1F6D0}', '\u{26CE}', '\u{2648}'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'animal',
|
||||
icon: '🐶',
|
||||
icon: '\u{1F436}',
|
||||
emojis: [
|
||||
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼',
|
||||
'🐻❄️', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵',
|
||||
'🙈', '🙉', '🙊', '🐒', '🐔', '🐧', '🐦', '🐤',
|
||||
'🐣', '🐥', '🦆', '🦅', '🦉', '🦇', '🐺', '🐗'
|
||||
'\u{1F436}', '\u{1F431}', '\u{1F42D}', '\u{1F439}', '\u{1F430}', '\u{1F98A}', '\u{1F43B}', '\u{1F43C}',
|
||||
'\u{1F428}', '\u{1F42F}', '\u{1F981}', '\u{1F42E}', '\u{1F437}', '\u{1F438}', '\u{1F435}', '\u{1F648}',
|
||||
'\u{1F649}', '\u{1F64A}', '\u{1F412}', '\u{1F414}', '\u{1F427}', '\u{1F426}', '\u{1F424}', '\u{1F423}',
|
||||
'\u{1F425}', '\u{1F986}', '\u{1F985}', '\u{1F989}', '\u{1F987}', '\u{1F41D}', '\u{1F41B}', '\u{1F98B}'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'food',
|
||||
icon: '🍔',
|
||||
icon: '\u{1F354}',
|
||||
emojis: [
|
||||
'🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓',
|
||||
'🫐', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝',
|
||||
'🍔', '🍟', '🍕', '🌭', '🥪', '🌮', '🌯', '🥗',
|
||||
'🍜', '🍝', '🍲', '🍱', '🍣', '🍤', '🍿', '🧁'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'activity',
|
||||
icon: '⚽',
|
||||
emojis: [
|
||||
'⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉',
|
||||
'🥏', '🎱', '🪀', '🏓', '🏸', '🏒', '🏑', '🥍',
|
||||
'🏏', '🪃', '🥅', '⛳', '🪁', '🏹', '🎣', '🤿',
|
||||
'🥊', '🥋', '🎽', '🛹', '🛼', '🛷', '⛸️', '🥌'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'object',
|
||||
icon: '💡',
|
||||
emojis: [
|
||||
'⌚', '📱', '💻', '⌨️', '🖥️', '🖨️', '🖱️', '🖲️',
|
||||
'💽', '💾', '💿', '📀', '📼', '📷', '📸', '📹',
|
||||
'🎥', '📽️', '🎞️', '📞', '☎️', '📟', '📠', '📺',
|
||||
'📻', '🎙️', '🎚️', '🎛️', '🧭', '⏱️', '⏲️', '⏰'
|
||||
'\u{1F34E}', '\u{1F34A}', '\u{1F34C}', '\u{1F347}', '\u{1F353}', '\u{1F349}', '\u{1F345}', '\u{1F346}',
|
||||
'\u{1F955}', '\u{1F33D}', '\u{1F336}', '\u{1F952}', '\u{1F96C}', '\u{1F966}', '\u{1F9C4}', '\u{1F9C5}',
|
||||
'\u{1F354}', '\u{1F35F}', '\u{1F355}', '\u{1F32D}', '\u{1F96A}', '\u{1F32E}', '\u{1F32F}', '\u{1F957}',
|
||||
'\u{1F35D}', '\u{1F35C}', '\u{1F372}', '\u{1F35B}', '\u{1F363}', '\u{1F371}', '\u{1F35A}', '\u{1F359}'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'symbol',
|
||||
icon: '🔥',
|
||||
icon: '\u{1F525}',
|
||||
emojis: [
|
||||
'🔥', '💥', '✨', '🌟', '⭐', '🌈', '☀️', '🌤️',
|
||||
'⛅', '🌥️', '☁️', '🌦️', '🌧️', '⛈️', '🌩️', '🌨️',
|
||||
'❄️', '☃️', '⛄', '🌬️', '💨', '💧', '💦', '☔',
|
||||
'🎵', '🎶', '🎼', '🎹', '🥁', '🎷', '🎺', '🎸'
|
||||
'\u{1F525}', '\u{1F4A5}', '\u{2728}', '\u{1F31F}', '\u{2B50}', '\u{1F308}', '\u{2600}\u{FE0F}', '\u{1F324}\u{FE0F}',
|
||||
'\u{26C5}', '\u{1F325}', '\u{2601}\u{FE0F}', '\u{1F326}', '\u{1F327}', '\u{26C8}', '\u{1F329}', '\u{1F328}',
|
||||
'\u{2744}\u{FE0F}', '\u{2603}\u{FE0F}', '\u{26C4}', '\u{1F32C}', '\u{1F4A8}', '\u{1F4A7}', '\u{1F4A6}', '\u{2614}',
|
||||
'\u{1F3B5}', '\u{1F3B6}', '\u{1F3BC}', '\u{1F3B9}', '\u{1F941}', '\u{1F3A7}', '\u{1F3BA}', '\u{1F3B8}'
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -173,38 +151,22 @@ function selectEmoji(emoji: string) {
|
||||
.emoji-tabs {
|
||||
display: flex;
|
||||
padding: 16rpx 24rpx;
|
||||
border-bottom: 1rpx solid var(--border-color, #e5e7eb);
|
||||
gap: 8rpx;
|
||||
border-bottom: 1rpx solid rgba(0, 0, 0, 0.05);
|
||||
overflow-x: auto;
|
||||
|
||||
.dark & {
|
||||
border-bottom-color: #44403c;
|
||||
}
|
||||
.tab-item {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12rpx;
|
||||
font-size: 36rpx;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
min-width: 72rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16rpx;
|
||||
font-size: 40rpx;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(79, 70, 229, 0.1);
|
||||
|
||||
.dark & {
|
||||
background: rgba(249, 115, 22, 0.15);
|
||||
&.active {
|
||||
background: var(--bg-hover, #f3f4f6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,34 +176,22 @@ function selectEmoji(emoji: string) {
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 8rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
aspect-ratio: 1;
|
||||
width: 12.5%;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48rpx;
|
||||
border-radius: 16rpx;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
font-size: 44rpx;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark &:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 内容列 -->
|
||||
<!-- 消息内容列 -->
|
||||
<view class="content-col" :class="{ 'items-end': isSelf }">
|
||||
<!-- 群聊昵称 -->
|
||||
<!-- 发送者昵称(群聊) -->
|
||||
<text v-if="senderName" class="sender-name">{{ senderName }}</text>
|
||||
|
||||
<!-- 气泡实体 -->
|
||||
<!-- 消息气泡 -->
|
||||
<view
|
||||
class="bubble"
|
||||
:class="[
|
||||
@@ -24,7 +24,7 @@
|
||||
]"
|
||||
@longpress="$emit('long-press', msg)"
|
||||
>
|
||||
<!-- 0. 纯文本 -->
|
||||
<!-- 0. 文本消息 -->
|
||||
<text v-if="msg.message_type === 0" class="msg-text" selectable>{{ msg.content }}</text>
|
||||
|
||||
<!-- 1. 图片 -->
|
||||
@@ -73,8 +73,14 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text v-else class="msg-text">[不支持的消息类型]</text>
|
||||
<text v-else class="msg-text">[未知消息类型]</text>
|
||||
</view>
|
||||
|
||||
<!-- 已读回执(仅自己发送的消息) -->
|
||||
<text v-if="isSelf && msg.is_read" class="read-receipt">已读</text>
|
||||
|
||||
<!-- IP 归属地 -->
|
||||
<text v-if="showIpLocation" class="ip-location">{{ msg.ip_location }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -105,6 +111,12 @@ defineEmits<{
|
||||
|
||||
const avatarName = computed(() => props.senderName || 'U')
|
||||
|
||||
/** 是否展示 IP 归属地(过滤本地/未知) */
|
||||
const showIpLocation = computed(() => {
|
||||
const loc = props.msg.ip_location
|
||||
return !!loc && loc !== '本地' && loc !== '未知'
|
||||
})
|
||||
|
||||
const mediaTypeClass = computed(() => {
|
||||
const type = props.msg.message_type
|
||||
if (type === 1) return 'media-bubble image'
|
||||
@@ -114,7 +126,7 @@ const mediaTypeClass = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
// Helpers (复用原 index.vue 逻辑)
|
||||
/** 解析媒体 URL(与 chat/index.vue 保持一致) */
|
||||
function getMediaUrl(msg: ChatMessage): string {
|
||||
let url = ''
|
||||
if (typeof msg.extra === 'string') {
|
||||
@@ -141,11 +153,8 @@ function getVideoThumb(msg: ChatMessage) {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* * 气泡组件样式
|
||||
* 完全复用原 index.vue 的 .msg-row, .bubble 及其子元素样式
|
||||
*/
|
||||
/* 消息行布局,样式与 chat/index.vue 的 .msg-row、.bubble 保持一致 */
|
||||
|
||||
/* 布局 */
|
||||
.msg-row {
|
||||
display: flex; align-items: flex-start; gap: 20rpx;
|
||||
&.is-self { flex-direction: row-reverse; }
|
||||
@@ -157,35 +166,28 @@ function getVideoThumb(msg: ChatMessage) {
|
||||
.sender-name { font-size: 20rpx; color: #9ca3af; margin-bottom: 8rpx; margin-left: 8rpx; .theme-dark & { color: #78716c; } }
|
||||
}
|
||||
|
||||
/* 核心气泡 */
|
||||
.bubble {
|
||||
padding: 24rpx; border-radius: 32rpx; font-size: 30rpx; line-height: 1.6; position: relative; word-break: break-all; transition: all 0.3s ease;
|
||||
|
||||
/* 对方消息 */
|
||||
&.bubble-other {
|
||||
background: #ffffff; color: #1f2937; border-bottom-left-radius: 8rpx; box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.05);
|
||||
.theme-dark & { background: #262626; color: #e7e5e4; border: 2rpx solid #292524; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* 自己消息 */
|
||||
&.bubble-self {
|
||||
background: linear-gradient(135deg, #6366F1, #4F46E5); color: #ffffff; border-bottom-right-radius: 8rpx; box-shadow: 0 4rpx 16rpx rgba(79, 70, 229, 0.2);
|
||||
.theme-dark & { background: linear-gradient(135deg, #fb923c, #ea580c); box-shadow: 0 4rpx 16rpx rgba(234, 88, 12, 0.3); }
|
||||
}
|
||||
|
||||
/* 媒体容器 */
|
||||
&.media-bubble { padding: 0; background: transparent !important; box-shadow: none !important; border: none !important; }
|
||||
}
|
||||
|
||||
/* 媒体类型 */
|
||||
.image-bubble .msg-image { width: 360rpx; border-radius: 32rpx; background: #f3f4f6; border: 2rpx solid #f3f4f6; .theme-dark & { background: #292524; border-color: #292524; } }
|
||||
|
||||
/* 语音胶囊 */
|
||||
.audio-capsule {
|
||||
display: flex; align-items: center; gap: 20rpx; min-width: 180rpx; padding: 24rpx; background: #ffffff; border-radius: 32rpx;
|
||||
.theme-dark & { background: #262626; border: 2rpx solid #292524; }
|
||||
|
||||
/* 自己的语音 */
|
||||
.is-self & { background: linear-gradient(135deg, #6366F1, #4F46E5) !important; border: none !important; .theme-dark & { background: linear-gradient(135deg, #fb923c, #ea580c) !important; } }
|
||||
|
||||
.play-icon { width: 32rpx; height: 32rpx; color: #6b7280; .theme-dark & { color: #a8a29e; } .is-self & { color: rgba(255,255,255,0.9); } }
|
||||
@@ -202,7 +204,6 @@ function getVideoThumb(msg: ChatMessage) {
|
||||
}
|
||||
@keyframes wave { 0%, 100% { height: 8rpx; } 50% { height: 24rpx; } }
|
||||
|
||||
/* 视频预览 */
|
||||
.video-preview {
|
||||
position: relative; width: 360rpx; height: 200rpx; border-radius: 32rpx; overflow: hidden; background: #000;
|
||||
.video-thumb { width: 100%; height: 100%; opacity: 0.9; }
|
||||
@@ -210,7 +211,6 @@ function getVideoThumb(msg: ChatMessage) {
|
||||
.play-btn-circle { width: 80rpx; height: 80rpx; background: rgba(0,0,0,0.3); backdrop-filter: blur(4px); border-radius: 50%; display: flex; align-items: center; justify-content: center; border: 2rpx solid rgba(255,255,255,0.3); color: #fff; svg { width: 32rpx; height: 32rpx; } }
|
||||
}
|
||||
|
||||
/* 文件卡片 */
|
||||
.file-card {
|
||||
background: #ffffff; padding: 24rpx; border-radius: 32rpx; display: flex; align-items: center; gap: 24rpx; min-width: 400rpx; border: 2rpx solid #f3f4f6;
|
||||
.theme-dark & { background: #262626; border-color: #292524; }
|
||||
@@ -225,4 +225,18 @@ function getVideoThumb(msg: ChatMessage) {
|
||||
}
|
||||
.file-meta { flex: 1; display: flex; flex-direction: column; overflow: hidden; .f-name { font-size: 28rpx; font-weight: 500; color: #1f2937; .theme-dark & { color: #e7e5e4; } } .f-size { font-size: 22rpx; color: #9ca3af; .theme-dark & { color: #78716c; } } }
|
||||
}
|
||||
|
||||
.read-receipt {
|
||||
font-size: 20rpx;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
margin-top: 4rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.ip-location {
|
||||
font-size: 20rpx;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
margin-top: 4rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -322,7 +322,7 @@ function formatRecordDuration(s: number) { return `${Math.floor(s/60).toString()
|
||||
.input-floater {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
z-index: 200;
|
||||
z-index: 1;
|
||||
background: var(--bg-floater);
|
||||
border-top: 2rpx solid var(--border-color);
|
||||
|
||||
|
||||
58
src/components/common/AppActionSheet.vue
Normal file
58
src/components/common/AppActionSheet.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<wd-action-sheet
|
||||
:model-value="modelValue"
|
||||
:actions="actions"
|
||||
:title="title"
|
||||
:cancel-text="cancelText"
|
||||
:z-index="Z.actionSheet"
|
||||
v-bind="$attrs"
|
||||
@update:model-value="onModelUpdate"
|
||||
@select="emit('select', $event)"
|
||||
@open="onOpen"
|
||||
@close="emit('close')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, watch } from 'vue'
|
||||
import { Z } from '@/constants/zIndex'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
actions?: Array<{ name: string; value?: string; color?: string; disabled?: boolean }>
|
||||
title?: string
|
||||
cancelText?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
select: [action: { item: { name: string; value?: string }; index: number }]
|
||||
open: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const tabPageLayout = inject<{ closeDrawer?: () => void } | null>('tabPageLayout', null)
|
||||
|
||||
function onModelUpdate(value: boolean) {
|
||||
if (value && tabPageLayout?.closeDrawer) {
|
||||
tabPageLayout.closeDrawer()
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
if (tabPageLayout?.closeDrawer) {
|
||||
tabPageLayout.closeDrawer()
|
||||
}
|
||||
emit('open')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open && tabPageLayout?.closeDrawer) {
|
||||
tabPageLayout.closeDrawer()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<view class="app-avatar" :style="avatarStyle" @click="onClick">
|
||||
<wd-img
|
||||
v-if="resolvedSrc"
|
||||
v-if="showImage"
|
||||
:src="resolvedSrc"
|
||||
:width="sizeRpx"
|
||||
:height="sizeRpx"
|
||||
:round="round"
|
||||
:radius="round ? '50%' : radius"
|
||||
mode="aspectFill"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<view
|
||||
v-else
|
||||
@@ -16,8 +17,6 @@
|
||||
>
|
||||
{{ displayText }}
|
||||
</view>
|
||||
|
||||
<!-- 徽标 -->
|
||||
<wd-badge
|
||||
v-if="badge && badge > 0"
|
||||
:value="badge"
|
||||
@@ -29,7 +28,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
|
||||
@@ -55,26 +54,24 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
bgColor: ''
|
||||
})
|
||||
|
||||
// 判断是否为有效图片 URL
|
||||
function isValidImageUrl(url?: string): boolean {
|
||||
if (!url) return false
|
||||
// 有效 URL: http/https/data URI 或以 /uploads/ 开头
|
||||
return url.startsWith('http://') ||
|
||||
url.startsWith('https://') ||
|
||||
url.startsWith('data:') ||
|
||||
url.startsWith('/uploads/')
|
||||
}
|
||||
const imageError = ref(false)
|
||||
|
||||
// 处理图片 URL,添加域名前缀,并验证是否为有效图片 URL
|
||||
const resolvedSrc = computed(() => {
|
||||
// 先检查原始 src 是否为有效图片路径
|
||||
if (!isValidImageUrl(props.src)) return ''
|
||||
return resolveImageUrl(props.src)
|
||||
watch(() => props.src, () => {
|
||||
imageError.value = false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
const resolvedSrc = computed(() => {
|
||||
if (!props.src?.trim()) return ''
|
||||
return resolveImageUrl(props.src.trim())
|
||||
})
|
||||
|
||||
const showImage = computed(() => !!resolvedSrc.value && !imageError.value)
|
||||
|
||||
function onImageError() {
|
||||
imageError.value = true
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ (e: 'click'): void }>()
|
||||
|
||||
const sizeRpx = computed(() => `${props.size}rpx`)
|
||||
|
||||
@@ -91,9 +88,7 @@ const placeholderStyle = computed(() => ({
|
||||
fontSize: `${props.size * 0.4}rpx`
|
||||
}))
|
||||
|
||||
const displayText = computed(() => {
|
||||
return props.name?.charAt(0) || '?'
|
||||
})
|
||||
const displayText = computed(() => props.name?.charAt(0) || '?')
|
||||
|
||||
function onClick() {
|
||||
emit('click')
|
||||
@@ -131,10 +126,3 @@ function onClick() {
|
||||
border: 2rpx solid var(--bg-content);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
8
src/components/common/AppConfirmHost.vue
Normal file
8
src/components/common/AppConfirmHost.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<wd-toast :z-index="Z.toast" />
|
||||
<wd-message-box :z-index="Z.messageBox" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Z } from '@/constants/zIndex'
|
||||
</script>
|
||||
@@ -23,7 +23,7 @@
|
||||
/>
|
||||
</view>
|
||||
<text class="username">{{ user?.name || '请登录' }}</text>
|
||||
<text class="bio">{{ user?.desc || '编辑个签,展示你的独特个性' }}</text>
|
||||
<text class="bio">{{ user?.desc || '编辑个性签名,展示你的独特个性' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 菜单列表 -->
|
||||
|
||||
65
src/components/common/AppModal.vue
Normal file
65
src/components/common/AppModal.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<wd-popup
|
||||
:model-value="modelValue"
|
||||
:position="position"
|
||||
:z-index="Z.popup"
|
||||
:safe-area-inset-bottom="safeAreaInsetBottom"
|
||||
:custom-style="customStyle"
|
||||
v-bind="$attrs"
|
||||
@update:model-value="onModelUpdate"
|
||||
@open="onOpen"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<slot />
|
||||
</wd-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, watch } from 'vue'
|
||||
import { Z } from '@/constants/zIndex'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
position?: 'top' | 'right' | 'bottom' | 'left' | 'center'
|
||||
safeAreaInsetBottom?: boolean
|
||||
customStyle?: string
|
||||
}>(),
|
||||
{
|
||||
position: 'center',
|
||||
safeAreaInsetBottom: true,
|
||||
customStyle: '',
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
open: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const tabPageLayout = inject<{ closeDrawer?: () => void } | null>('tabPageLayout', null)
|
||||
|
||||
function onModelUpdate(value: boolean) {
|
||||
if (value && tabPageLayout?.closeDrawer) {
|
||||
tabPageLayout.closeDrawer()
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
if (tabPageLayout?.closeDrawer) {
|
||||
tabPageLayout.closeDrawer()
|
||||
}
|
||||
emit('open')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open && tabPageLayout?.closeDrawer) {
|
||||
tabPageLayout.closeDrawer()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -261,8 +261,8 @@ function handleClose() {
|
||||
/* #endif */
|
||||
|
||||
/* #ifndef MP-WEIXIN */
|
||||
height: calc(88rpx + var(--status-bar-height, 0));
|
||||
padding-top: var(--status-bar-height, 0);
|
||||
height: calc(88rpx + var(--status-bar-height, 20px));
|
||||
padding-top: var(--status-bar-height, 20px);
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,39 @@ interface MenuButtonInfo {
|
||||
}
|
||||
|
||||
// 全局单例状态(避免重复计算)
|
||||
const systemBarHeight = ref(0)
|
||||
const systemInfo = ref<UniApp.GetSystemInfoResult | null>(null)
|
||||
const menuButtonInfo = ref<MenuButtonInfo | null>(null)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
/**
|
||||
* 将状态栏高度注入全局 CSS 变量 --status-bar-height
|
||||
* APP/H5 端页面依赖此变量计算导航栏 padding
|
||||
*/
|
||||
function injectStatusBarCSS(height: number) {
|
||||
const px = `${height}px`
|
||||
systemBarHeight.value = height
|
||||
|
||||
// #ifdef H5
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.style.setProperty('--status-bar-height', px)
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.style.setProperty('--status-bar-height', px)
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序框架会注入,此处同步写入以便自定义导航栏一致
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.style.setProperty('--status-bar-height', px)
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化安全区域数据
|
||||
*/
|
||||
@@ -26,8 +55,11 @@ function initSafeArea() {
|
||||
if (isInitialized.value) return
|
||||
|
||||
try {
|
||||
// 获取系统信息
|
||||
systemInfo.value = uni.getSystemInfoSync()
|
||||
const barHeight = systemInfo.value.statusBarHeight || 0
|
||||
// 非 MP 端若获取失败,使用 20px 兜底(iPhone 通常 44-47px)
|
||||
const effectiveHeight = barHeight > 0 ? barHeight : 20
|
||||
injectStatusBarCSS(effectiveHeight)
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 获取微信小程序胶囊按钮位置信息
|
||||
@@ -56,9 +88,12 @@ function initSafeArea() {
|
||||
export function useSafeArea() {
|
||||
// 状态栏高度
|
||||
const statusBarHeight = computed(() => {
|
||||
return systemInfo.value?.statusBarHeight || 0
|
||||
return systemInfo.value?.statusBarHeight || systemBarHeight.value || 20
|
||||
})
|
||||
|
||||
/** 状态栏高度 CSS 值,供页面 :style 绑定 */
|
||||
const statusBarHeightPx = computed(() => `${statusBarHeight.value}px`)
|
||||
|
||||
// 导航栏内容高度(不含状态栏)
|
||||
const navBarContentHeight = computed(() => {
|
||||
// #ifdef MP-WEIXIN
|
||||
@@ -124,6 +159,7 @@ export function useSafeArea() {
|
||||
systemInfo,
|
||||
menuButtonInfo,
|
||||
statusBarHeight,
|
||||
statusBarHeightPx,
|
||||
navBarContentHeight,
|
||||
navBarTotalHeight,
|
||||
menuButtonRight,
|
||||
|
||||
5
src/config/app.ts
Normal file
5
src/config/app.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** API 与服务端资源地址配置 */
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:12080/api'
|
||||
|
||||
/** 去掉 /api 后缀,用于图片等静态资源 */
|
||||
export const API_ORIGIN = API_BASE_URL.replace(/\/api\/?$/, '')
|
||||
36
src/constants/zIndex.ts
Normal file
36
src/constants/zIndex.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 全局 z-index 层级常量
|
||||
* 数值越大越靠上,避免 TabBar / Drawer / Popup 互相遮挡
|
||||
*/
|
||||
export const Z = {
|
||||
/** 底部 TabBar */
|
||||
tabBar: 100,
|
||||
/** 页面固定头部、A-Z 索引条 */
|
||||
pageHeader: 200,
|
||||
/** 悬浮按钮 FAB */
|
||||
fab: 250,
|
||||
/** 聊天输入栏 */
|
||||
inputBar: 300,
|
||||
/** 加号菜单遮罩 */
|
||||
plusMenuBackdrop: 900,
|
||||
/** 加号菜单面板 */
|
||||
plusMenu: 950,
|
||||
/** 侧边抽屉遮罩 */
|
||||
drawerMask: 1000,
|
||||
/** 侧边抽屉内容 */
|
||||
drawerContent: 1001,
|
||||
/** 底部 ActionSheet */
|
||||
actionSheet: 2000,
|
||||
/** 居中/底部 Popup 弹窗 */
|
||||
popup: 2100,
|
||||
/** Toast 提示 */
|
||||
toast: 2200,
|
||||
/** MessageBox 确认框 */
|
||||
messageBox: 2300,
|
||||
/** 音视频通话窗口 */
|
||||
call: 9000,
|
||||
/** 全屏预览等 */
|
||||
fullscreen: 9500,
|
||||
} as const
|
||||
|
||||
export type ZIndexKey = keyof typeof Z
|
||||
26
src/layouts/AppChatPageLayout.vue
Normal file
26
src/layouts/AppChatPageLayout.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<wd-config-provider :theme="isDark ? 'dark' : 'light'">
|
||||
<view class="chat-page-layout" :class="{ dark: isDark }">
|
||||
<slot />
|
||||
|
||||
<!-- 页面级弹层 -->
|
||||
<slot name="overlays" />
|
||||
|
||||
<app-confirm-host />
|
||||
</view>
|
||||
</wd-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import AppConfirmHost from '@/components/common/AppConfirmHost.vue'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-page-layout {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
104
src/layouts/AppTabPageLayout.vue
Normal file
104
src/layouts/AppTabPageLayout.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<wd-config-provider :theme="isDark ? 'dark' : 'light'">
|
||||
<view class="tab-page-layout" :class="{ dark: isDark }">
|
||||
<!-- 页面主体内容 -->
|
||||
<slot />
|
||||
|
||||
<!-- 底部 TabBar -->
|
||||
<app-tab-bar :current="tab" />
|
||||
|
||||
<!-- 加号菜单(仅消息页) -->
|
||||
<plus-menu
|
||||
v-if="showPlusMenu"
|
||||
v-model="plusMenuOpen"
|
||||
@select="(key) => emit('plusSelect', key)"
|
||||
/>
|
||||
|
||||
<!-- 侧边抽屉 -->
|
||||
<app-drawer
|
||||
v-if="drawerEnabled"
|
||||
v-model="drawerOpen"
|
||||
@logout="emit('logout')"
|
||||
/>
|
||||
|
||||
<!-- 页面级弹层:ActionSheet / Modal 等 -->
|
||||
<slot name="overlays" />
|
||||
|
||||
<!-- Toast + MessageBox 置于最上层常规层 -->
|
||||
<app-confirm-host />
|
||||
</view>
|
||||
</wd-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide, watch } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import AppTabBar from '@/components/common/AppTabBar.vue'
|
||||
import AppDrawer from '@/components/common/AppDrawer.vue'
|
||||
import PlusMenu from '@/components/common/PlusMenu.vue'
|
||||
import AppConfirmHost from '@/components/common/AppConfirmHost.vue'
|
||||
|
||||
export type TabPageKey = 'messages' | 'contacts' | 'moments'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tab: TabPageKey
|
||||
/** 是否启用侧边抽屉 */
|
||||
drawer?: boolean
|
||||
/** 抽屉 v-model */
|
||||
showDrawer?: boolean
|
||||
/** 是否显示加号菜单(消息页) */
|
||||
showPlusMenu?: boolean
|
||||
/** 加号菜单 v-model */
|
||||
showPlusMenuPanel?: boolean
|
||||
}>(),
|
||||
{
|
||||
drawer: true,
|
||||
showDrawer: false,
|
||||
showPlusMenu: false,
|
||||
showPlusMenuPanel: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:showDrawer': [value: boolean]
|
||||
'update:showPlusMenuPanel': [value: boolean]
|
||||
logout: []
|
||||
plusSelect: [key: string]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const drawerEnabled = computed(() => props.drawer !== false)
|
||||
|
||||
const drawerOpen = computed({
|
||||
get: () => props.showDrawer,
|
||||
set: (v) => emit('update:showDrawer', v),
|
||||
})
|
||||
|
||||
const plusMenuOpen = computed({
|
||||
get: () => props.showPlusMenuPanel,
|
||||
set: (v) => emit('update:showPlusMenuPanel', v),
|
||||
})
|
||||
|
||||
/** 打开弹层时关闭抽屉,避免 MessageBox 被遮挡 */
|
||||
function closeDrawer() {
|
||||
if (drawerOpen.value) {
|
||||
emit('update:showDrawer', false)
|
||||
}
|
||||
}
|
||||
|
||||
provide('tabPageLayout', { closeDrawer })
|
||||
|
||||
/** 加号菜单打开时也关闭抽屉 */
|
||||
watch(plusMenuOpen, (open) => {
|
||||
if (open) closeDrawer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tab-page-layout {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -19,7 +19,7 @@
|
||||
<text class="nav-name">{{ displayName }}</text>
|
||||
<view class="status-row">
|
||||
<view v-if="!isGroupChat" class="status-dot"></view>
|
||||
<text class="status-text">{{ isGroupChat ? `${groupMembers.length}人` : '手机在线' }}</text>
|
||||
<text class="status-text">{{ isGroupChat ? `${groupMembers.length}人` : '在线' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -513,7 +513,7 @@ function goUserDetail(user: User) {
|
||||
|
||||
/* #ifndef MP-WEIXIN */
|
||||
/* H5/App:仅状态栏 */
|
||||
padding-top: var(--status-bar-height, 0);
|
||||
padding-top: var(--status-bar-height, 20px);
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<view class="detail-container" :class="{ 'theme-dark': isDark }">
|
||||
<!-- 视差背景 (与设计稿一致: blur-xl scale-110 opacity-70) -->
|
||||
<!-- 背景图(模糊放大效果 blur-xl scale-110 opacity-70) -->
|
||||
<view class="parallax-bg">
|
||||
<image
|
||||
v-if="contact?.user?.avatar"
|
||||
:src="contact.user.avatar"
|
||||
<image
|
||||
v-if="contact?.user?.avatar"
|
||||
:src="contact.user.avatar"
|
||||
class="blur-image"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="gradient-overlay" />
|
||||
</view>
|
||||
|
||||
<!-- 导航栏 (与设计稿一致) -->
|
||||
<!-- 顶部导航栏 -->
|
||||
<view class="nav-header">
|
||||
<view class="nav-back" @click="goBack">
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
@@ -37,11 +37,10 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主体内容 - 好友状态 -->
|
||||
<!-- 好友资料 - 主内容区 -->
|
||||
<scroll-view scroll-y class="main-scroll custom-scrollbar" v-if="!loading && isFriend && contact">
|
||||
<!-- 身份卡片区 (与设计稿一致) -->
|
||||
<!-- 身份信息区 -->
|
||||
<view class="identity-section">
|
||||
<!-- 头像: w-32 h-32 rounded-full border-[6px] border-white shadow-xl -->
|
||||
<view class="avatar-wrapper">
|
||||
<app-avatar
|
||||
:src="contact.user?.avatar"
|
||||
@@ -50,25 +49,18 @@
|
||||
round
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 名字: text-2xl font-bold -->
|
||||
<text class="user-name">{{ contact.remark_name || contact.user?.name || '未知' }}</text>
|
||||
|
||||
<!-- 标签: text-xs bg-gray-100 px-2 py-0.5 rounded -->
|
||||
<view class="tag-row">
|
||||
<view class="info-tag">ID: {{ contact.user?.id?.slice(-6) || '-' }}</view>
|
||||
<view class="info-tag">{{ contact.user?.region || '未知' }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 签名: text-sm text-gray-500 text-center max-w-[200px] -->
|
||||
<text class="bio-text">{{ contact.user?.desc || '这个人很懒,什么都没写' }}</text>
|
||||
<text class="bio-text">{{ contact.user?.desc || '这个人很懒,什么都没有写' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 好友设置卡片 (与设计稿一致) -->
|
||||
<!-- 设置卡片 -->
|
||||
<view class="settings-card animate-fade-in-up">
|
||||
<!-- 设置备注 -->
|
||||
<view class="setting-row" @click="editRemark">
|
||||
<text class="row-label">设置备注</text>
|
||||
<text class="row-label">备注名</text>
|
||||
<view class="row-right">
|
||||
<text class="row-value">{{ contact.remark_name || '未设置' }}</text>
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
@@ -81,94 +73,135 @@
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 置顶聊天 -->
|
||||
<view class="setting-row">
|
||||
<text class="row-label">置顶聊天</text>
|
||||
<view class="toggle-wrap">
|
||||
<view
|
||||
class="custom-toggle"
|
||||
:class="{ active: contact.is_top }"
|
||||
@click="toggleTopManual"
|
||||
>
|
||||
<view class="custom-toggle" :class="{ active: contact.is_top }" @click="toggleTopManual">
|
||||
<view class="toggle-thumb" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息免打扰 -->
|
||||
<view class="setting-row no-border">
|
||||
<view class="setting-row">
|
||||
<text class="row-label">消息免打扰</text>
|
||||
<view class="toggle-wrap">
|
||||
<view
|
||||
class="custom-toggle"
|
||||
:class="{ active: contact.is_muted }"
|
||||
@click="toggleMuteManual"
|
||||
>
|
||||
<view class="custom-toggle" :class="{ active: contact.is_muted }" @click="toggleMuteManual">
|
||||
<view class="toggle-thumb" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="setting-row no-border">
|
||||
<text class="row-label">加入黑名单</text>
|
||||
<view class="toggle-wrap">
|
||||
<view class="custom-toggle" :class="{ active: contact.is_blocked }" @click="toggleBlockManual">
|
||||
<view class="toggle-thumb" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 共同群聊 -->
|
||||
<view v-if="commonGroups.length > 0" class="settings-card animate-fade-in-up">
|
||||
<view class="common-groups-header">
|
||||
<text class="section-header-title">共同群聊</text>
|
||||
<text class="section-header-count">{{ commonGroups.length }}个</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(group, index) in commonGroups"
|
||||
:key="group.room_id"
|
||||
class="setting-row"
|
||||
:class="{ 'no-border': index === commonGroups.length - 1 }"
|
||||
@click="openCommonGroup(group)"
|
||||
>
|
||||
<text class="row-label group-name">{{ group.room_name || '未命名群聊' }}</text>
|
||||
<view class="row-right">
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
<svg class="chevron-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-icon-mp" />
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 查看朋友圈 -->
|
||||
<view class="settings-card animate-fade-in-up">
|
||||
<view class="setting-row no-border" @click="viewMoments">
|
||||
<text class="row-label">查看朋友圈</text>
|
||||
<view class="row-right">
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
<svg class="chevron-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-icon-mp" />
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-spacer" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 主体内容 - 陌生人状态 -->
|
||||
<!-- 陌生人资料 - 主内容区 -->
|
||||
<scroll-view scroll-y class="main-scroll custom-scrollbar" v-else-if="!loading && !isFriend && strangerUser">
|
||||
<!-- 身份卡片区 -->
|
||||
<view class="identity-section">
|
||||
<view class="avatar-wrapper">
|
||||
<app-avatar
|
||||
:src="strangerUser.avatar"
|
||||
:name="strangerUser.name"
|
||||
:size="256"
|
||||
round
|
||||
/>
|
||||
<app-avatar :src="strangerUser.avatar" :name="strangerUser.name" :size="256" round />
|
||||
</view>
|
||||
|
||||
<text class="user-name">{{ strangerUser.name || '未知' }}</text>
|
||||
|
||||
<view class="tag-row">
|
||||
<view class="info-tag">ID: {{ strangerUser.id?.slice(-6) || '-' }}</view>
|
||||
<view class="info-tag">{{ strangerUser.region || '未知' }}</view>
|
||||
</view>
|
||||
|
||||
<text class="bio-text">{{ strangerUser.desc || '这个人很懒,什么都没写' }}</text>
|
||||
<text class="bio-text">{{ strangerUser.desc || '这个人很懒,什么都没有写' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 陌生人信息卡片 -->
|
||||
<view class="stranger-card animate-fade-in-up">
|
||||
<text class="section-title">个人简介</text>
|
||||
|
||||
<text class="section-title">好友信息</text>
|
||||
<view class="info-row" v-if="sourceText">
|
||||
<text class="info-label">来源</text>
|
||||
<text class="info-value">{{ sourceText }}</text>
|
||||
</view>
|
||||
|
||||
<view class="info-row">
|
||||
<text class="info-label">签名</text>
|
||||
<text class="info-value">{{ strangerUser.desc || '暂无签名' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="info-row" v-if="strangerUser.region">
|
||||
<text class="info-label">地区</text>
|
||||
<text class="info-value">{{ strangerUser.region }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="settings-card animate-fade-in-up">
|
||||
<view class="setting-row no-border" @click="viewMoments">
|
||||
<text class="row-label">查看朋友圈</text>
|
||||
<view class="row-right">
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
<svg class="chevron-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<wd-icon name="arrow-right" size="32rpx" custom-class="chevron-icon-mp" />
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-spacer" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- Loading -->
|
||||
<view v-else-if="loading" class="center-loading">
|
||||
<wd-loading size="60rpx" :color="isDark ? '#f97316' : '#4F46E5'"/>
|
||||
</view>
|
||||
|
||||
<!-- 底部悬浮操作栏 - 好友状态 -->
|
||||
<!-- 底部操作栏 - 好友 -->
|
||||
<view class="floating-dock" v-if="isFriend && contact">
|
||||
<view class="dock-buttons">
|
||||
<!-- 电话: flex-1 -->
|
||||
<view class="dock-btn secondary" @click="startAudioCall">
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -179,7 +212,6 @@
|
||||
<wd-icon name="phone" size="44rpx" />
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
<!-- 发消息: flex-[2] -->
|
||||
<view class="dock-btn primary" @click="goChat">
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -191,7 +223,6 @@
|
||||
<!-- #endif -->
|
||||
<text>发消息</text>
|
||||
</view>
|
||||
<!-- 视频: flex-1 -->
|
||||
<view class="dock-btn secondary" @click="startVideoCall">
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -204,14 +235,12 @@
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 删除好友: text-xs text-gray-400 underline -->
|
||||
<view class="delete-link" @click="deleteFriend">
|
||||
<text>删除好友</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部悬浮操作栏 - 陌生人状态 -->
|
||||
<!-- 底部操作栏 - 陌生人 -->
|
||||
<view class="floating-dock" v-else-if="!isFriend && strangerUser">
|
||||
<view class="add-friend-btn" @click="addFriend">
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
@@ -241,18 +270,16 @@ import * as contactApi from '@/api/modules/contact'
|
||||
import { useToast, useMessage } from 'wot-design-uni'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { Contact } from '@/types/api'
|
||||
|
||||
import * as userApi from '@/api/modules/user'
|
||||
import type { User } from '@/types/api'
|
||||
import type { Contact, CommonGroup, User } from '@/types/api'
|
||||
|
||||
// --- 状态定义 ---
|
||||
const toast = useToast()
|
||||
const messageBox = useMessage()
|
||||
const { isDark } = useTheme()
|
||||
const loading = ref(true)
|
||||
const contact = ref<Contact | null>(null)
|
||||
const strangerUser = ref<User | null>(null)
|
||||
const commonGroups = ref<CommonGroup[]>([])
|
||||
const contactId = ref('')
|
||||
const userId = ref('')
|
||||
const isFriend = ref(false)
|
||||
@@ -261,7 +288,6 @@ const sourceText = ref('')
|
||||
onLoad((options: any) => {
|
||||
contactId.value = options?.id || ''
|
||||
userId.value = options?.userId || ''
|
||||
// 来源信息(如果有)
|
||||
sourceText.value = options?.source ? decodeURIComponent(options.source) : ''
|
||||
})
|
||||
|
||||
@@ -273,41 +299,45 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// 加载好友联系人
|
||||
/** 从 API 响应中提取共同群聊列表 */
|
||||
function applyCommonGroups(detail: Contact) {
|
||||
commonGroups.value = detail.common_groups ?? []
|
||||
}
|
||||
|
||||
async function loadContact() {
|
||||
loading.value = true
|
||||
try {
|
||||
contact.value = await contactApi.getContactDetail(contactId.value)
|
||||
const detail = await contactApi.getContactDetail(contactId.value)
|
||||
contact.value = detail
|
||||
applyCommonGroups(detail)
|
||||
isFriend.value = true
|
||||
} catch (error) {
|
||||
console.error('加载联系人失败:', error)
|
||||
console.error('加载好友详情失败', error)
|
||||
toast.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 通过用户ID加载 - 可能是好友也可能是陌生人
|
||||
async function loadContactByUserId() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 先尝试从好友列表查找
|
||||
const contacts = await contactApi.getContacts()
|
||||
const found = contacts.find((c: Contact) =>
|
||||
const found = contacts.find((c: Contact) =>
|
||||
c.contact_user_id === userId.value || c.user_id === userId.value
|
||||
)
|
||||
|
||||
if (found) {
|
||||
// 是好友
|
||||
contact.value = await contactApi.getContactDetail(found.id?.toString() || '')
|
||||
const targetId = found.contact_user_id || found.id?.toString() || userId.value
|
||||
contactId.value = targetId
|
||||
const detail = await contactApi.getContactDetail(targetId)
|
||||
contact.value = detail
|
||||
applyCommonGroups(detail)
|
||||
isFriend.value = true
|
||||
} else {
|
||||
// 不是好友,加载用户基本信息
|
||||
try {
|
||||
strangerUser.value = await userApi.getUserInfo(userId.value)
|
||||
isFriend.value = false
|
||||
} catch {
|
||||
// 用户不存在
|
||||
toast.warning('用户不存在')
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
}
|
||||
@@ -320,76 +350,141 @@ async function loadContactByUserId() {
|
||||
}
|
||||
}
|
||||
|
||||
// 添加好友
|
||||
async function addFriend() {
|
||||
try {
|
||||
const { value } = await messageBox.prompt({
|
||||
title: '添加好友',
|
||||
inputValue: '你好,我想加你为好友',
|
||||
inputPlaceholder: '请输入验证消息'
|
||||
inputValue: '我是...',
|
||||
inputPlaceholder: '请输入验证信息'
|
||||
})
|
||||
|
||||
await contactApi.addFriend({
|
||||
to_user_id: userId.value,
|
||||
message: value || ''
|
||||
})
|
||||
toast.success('好友请求已发送')
|
||||
await contactApi.addFriend({ to_user_id: userId.value, message: value || '' })
|
||||
toast.success('好友申请已发送')
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
toast.error(e?.message || '发送失败')
|
||||
}
|
||||
if (e !== 'cancel') toast.error(e?.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() { uni.navigateBack() }
|
||||
function goChat() { if (!contact.value) return; uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}` }) }
|
||||
function startAudioCall() { if (!contact.value) return; uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}&callType=audio` }) }
|
||||
function startVideoCall() { if (!contact.value) return; uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}&callType=video` }) }
|
||||
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 toggleTopManual() { if (!contact.value) return; contact.value.is_top = !contact.value.is_top; await toggleTop() }
|
||||
async function toggleMuteManual() { if (!contact.value) return; contact.value.is_muted = !contact.value.is_muted; await toggleMute() }
|
||||
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('删除失败') } } }
|
||||
function goChat() {
|
||||
if (!contact.value) return
|
||||
uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}` })
|
||||
}
|
||||
function startAudioCall() {
|
||||
if (!contact.value) return
|
||||
uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}&callType=audio` })
|
||||
}
|
||||
function startVideoCall() {
|
||||
if (!contact.value) return
|
||||
uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}&callType=video` })
|
||||
}
|
||||
function openCommonGroup(group: CommonGroup) {
|
||||
uni.navigateTo({ url: `/pages/chat/index?roomId=${group.room_id}&name=${encodeURIComponent(group.room_name || '群聊')}&avatar=${encodeURIComponent(group.room_avatar || '')}` })
|
||||
}
|
||||
function viewMoments() {
|
||||
const uid = contact.value?.contact_user_id || userId.value
|
||||
if (!uid) return
|
||||
uni.navigateTo({ url: `/pages/moment/index?userId=${uid}` })
|
||||
}
|
||||
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 toggleBlockManual() {
|
||||
if (!contact.value) return
|
||||
contact.value.is_blocked = !contact.value.is_blocked
|
||||
await toggleBlock()
|
||||
}
|
||||
async function toggleBlock() {
|
||||
if (!contact.value) return
|
||||
const blocked = contact.value.is_blocked
|
||||
try {
|
||||
await contactApi.updateContact(contactId.value, { is_blocked: blocked })
|
||||
toast.success(blocked ? '已加入黑名单' : '已移出黑名单')
|
||||
} catch {
|
||||
contact.value.is_blocked = !blocked
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
async function toggleTopManual() {
|
||||
if (!contact.value) return
|
||||
contact.value.is_top = !contact.value.is_top
|
||||
await toggleTop()
|
||||
}
|
||||
async function toggleMuteManual() {
|
||||
if (!contact.value) return
|
||||
contact.value.is_muted = !contact.value.is_muted
|
||||
await toggleMute()
|
||||
}
|
||||
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-container {
|
||||
--bg-page: #f3f4f6; // gray-100 (页面背景)
|
||||
--bg-card: #ffffff; // 卡片背景
|
||||
--bg-tag: #f3f4f6; // gray-100 (标签背景)
|
||||
--bg-toggle: #e5e7eb; // gray-200 (开关关闭)
|
||||
--text-main: #111827; // gray-900 (名称)
|
||||
--text-sub: #6b7280; // gray-500 (签名)
|
||||
--text-tertiary: #9ca3af; // gray-400 (标签/值)
|
||||
--color-brand: #4F46E5; // indigo-600
|
||||
--border-color: #f9fafb; // gray-50
|
||||
--gradient-to: #ffffff; // 渐变终点色
|
||||
--bg-page: #f3f4f6;
|
||||
--bg-card: #ffffff;
|
||||
--bg-tag: #f3f4f6;
|
||||
--bg-toggle: #e5e7eb;
|
||||
--text-main: #111827;
|
||||
--text-sub: #6b7280;
|
||||
--text-tertiary: #9ca3af;
|
||||
--color-brand: #4F46E5;
|
||||
--border-color: #f9fafb;
|
||||
--gradient-to: #ffffff;
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
position: relative;
|
||||
|
||||
// 深色模式 - Warm Stone (与设计稿完全一致)
|
||||
&.theme-dark {
|
||||
--bg-page: #0c0a09; // warm-950
|
||||
--bg-card: #1c1917; // warm-900
|
||||
--bg-tag: #292524; // warm-800
|
||||
--bg-toggle: #44403c; // warm-700
|
||||
--text-main: #fafaf9; // warm-50
|
||||
--text-sub: #a8a29e; // warm-400
|
||||
--text-tertiary: #78716c; // warm-500
|
||||
--color-brand: #f97316; // orange-500
|
||||
--bg-page: #0c0a09;
|
||||
--bg-card: #1c1917;
|
||||
--bg-tag: #292524;
|
||||
--bg-toggle: #44403c;
|
||||
--text-main: #fafaf9;
|
||||
--text-sub: #a8a29e;
|
||||
--text-tertiary: #78716c;
|
||||
--color-brand: #f97316;
|
||||
--border-color: rgba(41, 37, 36, 0.5);
|
||||
--gradient-to: #0c0a09; // warm-950
|
||||
--gradient-to: #0c0a09;
|
||||
}
|
||||
}
|
||||
|
||||
// 动画
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20rpx); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -406,9 +501,6 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 视差背景 (与设计稿一致: blur-xl scale-110 opacity-70)
|
||||
// ==========================================
|
||||
.parallax-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -421,11 +513,10 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
.blur-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// blur-xl = 24px, 需要用px单位
|
||||
filter: blur(24px);
|
||||
-webkit-filter: blur(24px);
|
||||
transform: scale(1.1); // scale-110
|
||||
opacity: 0.7; // opacity-70
|
||||
transform: scale(1.1);
|
||||
opacity: 0.7;
|
||||
|
||||
.theme-dark & {
|
||||
opacity: 0.5;
|
||||
@@ -439,9 +530,6 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 导航栏 (与设计稿一致)
|
||||
// ==========================================
|
||||
.nav-header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
@@ -454,7 +542,7 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
/* #endif */
|
||||
|
||||
/* #ifndef MP-WEIXIN */
|
||||
padding: calc(var(--status-bar-height, 0) + 24rpx) 32rpx 16rpx;
|
||||
padding: calc(var(--status-bar-height, 20px) + 24rpx) 32rpx 16rpx;
|
||||
/* #endif */
|
||||
|
||||
.nav-back, .nav-more {
|
||||
@@ -471,7 +559,7 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
color: var(--text-main);
|
||||
|
||||
.theme-dark & {
|
||||
color: #e7e5e4; // warm-200
|
||||
color: #e7e5e4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,74 +574,60 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 主体滚动区
|
||||
// ==========================================
|
||||
.main-scroll {
|
||||
height: calc(100vh - 120rpx);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 身份卡片区 (与设计稿完全一致)
|
||||
// ==========================================
|
||||
.identity-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 64rpx 48rpx 64rpx;
|
||||
|
||||
// 头像: w-32 h-32 rounded-full border-[6px] border-white shadow-xl
|
||||
.avatar-wrapper {
|
||||
border-radius: 50%;
|
||||
border: 12rpx solid var(--bg-card); // border-[6px] = 12rpx
|
||||
box-shadow: 0 20rpx 50rpx rgba(0, 0, 0, 0.2); // shadow-xl
|
||||
border: 12rpx solid var(--bg-card);
|
||||
box-shadow: 0 20rpx 50rpx rgba(0, 0, 0, 0.2);
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
// 名字: text-2xl font-bold
|
||||
.user-name {
|
||||
font-size: 48rpx; // text-2xl = 24px = 48rpx
|
||||
font-weight: 700; // font-bold
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
// 标签行
|
||||
.tag-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
// 标签: text-xs bg-gray-100 px-2 py-0.5 rounded
|
||||
.info-tag {
|
||||
font-size: 24rpx; // text-xs = 12px = 24rpx
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-tag);
|
||||
padding: 8rpx 16rpx; // px-2 py-0.5
|
||||
border-radius: 8rpx; // rounded
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
// 签名: text-sm text-gray-500 text-center max-w-[200px]
|
||||
.bio-text {
|
||||
font-size: 28rpx; // text-sm = 14px = 28rpx
|
||||
font-size: 28rpx;
|
||||
color: var(--text-sub);
|
||||
text-align: center;
|
||||
line-height: 1.6; // leading-relaxed
|
||||
max-width: 400rpx; // max-w-[200px] = 400rpx
|
||||
line-height: 1.6;
|
||||
max-width: 400rpx;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 设置卡片 (与设计稿一致: rounded-2xl shadow-sm border)
|
||||
// ==========================================
|
||||
.settings-card {
|
||||
margin: 0 24rpx 48rpx;
|
||||
background: var(--bg-card);
|
||||
border-radius: 32rpx; // rounded-2xl = 16px = 32rpx
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); // shadow-sm
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||||
border: 2rpx solid var(--border-color);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -563,12 +637,11 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
}
|
||||
}
|
||||
|
||||
// 设置行 (与设计稿一致)
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx; // p-4 = 16px = 32rpx
|
||||
padding: 32rpx;
|
||||
border-bottom: 2rpx solid var(--border-color);
|
||||
transition: background 0.15s;
|
||||
|
||||
@@ -588,12 +661,12 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
}
|
||||
|
||||
.row-label {
|
||||
font-size: 28rpx; // text-sm = 14px = 28rpx
|
||||
font-weight: 500; // font-medium
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: var(--text-main);
|
||||
|
||||
.theme-dark & {
|
||||
color: #e7e5e4; // warm-200
|
||||
color: #e7e5e4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,28 +677,52 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
}
|
||||
|
||||
.row-value {
|
||||
font-size: 24rpx; // text-xs
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
color: #d1d5db; // gray-300
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义开关 (与设计稿一致)
|
||||
.common-groups-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx 32rpx 16rpx;
|
||||
|
||||
.section-header-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.section-header-count {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.group-name {
|
||||
max-width: 480rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toggle-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.custom-toggle {
|
||||
width: 80rpx; // w-10 = 40px = 80rpx
|
||||
height: 48rpx; // h-6 = 24px = 48rpx
|
||||
width: 80rpx;
|
||||
height: 48rpx;
|
||||
background: var(--bg-toggle);
|
||||
border-radius: 48rpx; // rounded-full
|
||||
border-radius: 48rpx;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
|
||||
@@ -633,11 +730,11 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
position: absolute;
|
||||
left: 8rpx;
|
||||
top: 8rpx;
|
||||
width: 32rpx; // w-4 = 16px = 32rpx
|
||||
height: 32rpx; // h-4 = 16px = 32rpx
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1); // shadow-sm
|
||||
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
@@ -645,42 +742,38 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
background: var(--color-brand);
|
||||
|
||||
.toggle-thumb {
|
||||
transform: translateX(32rpx); // 移动到右边
|
||||
transform: translateX(32rpx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 底部悬浮操作栏 (与设计稿一致: rounded-t-[32px] shadow-[0_-10px_40px])
|
||||
// ==========================================
|
||||
.floating-dock {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 32rpx 48rpx; // px-6 py-4
|
||||
padding-bottom: calc(64rpx + env(safe-area-inset-bottom)); // pb-8
|
||||
padding: 32rpx 48rpx;
|
||||
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
|
||||
background: var(--bg-card);
|
||||
border-top: 2rpx solid var(--border-color);
|
||||
border-radius: 64rpx 64rpx 0 0; // rounded-t-[32px] = 64rpx
|
||||
box-shadow: 0 -20rpx 80rpx rgba(0, 0, 0, 0.05); // shadow-[0_-10px_40px]
|
||||
border-radius: 64rpx 64rpx 0 0;
|
||||
box-shadow: 0 -20rpx 80rpx rgba(0, 0, 0, 0.05);
|
||||
z-index: 100;
|
||||
|
||||
.theme-dark & {
|
||||
border-top-color: #292524; // warm-800
|
||||
border-top-color: #292524;
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮行
|
||||
.dock-buttons {
|
||||
display: flex;
|
||||
gap: 32rpx; // gap-4 = 16px = 32rpx
|
||||
margin-bottom: 32rpx; // mb-4
|
||||
gap: 32rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.dock-btn {
|
||||
height: 96rpx; // h-12 = 48px = 96rpx
|
||||
border-radius: 32rpx; // rounded-2xl
|
||||
height: 96rpx;
|
||||
border-radius: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -688,7 +781,7 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
transition: all 0.15s;
|
||||
|
||||
svg {
|
||||
width: 40rpx; // w-5 h-5 = 20px = 40rpx
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
@@ -697,31 +790,22 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
// 次要按钮: flex-1
|
||||
&.secondary {
|
||||
flex: 1;
|
||||
background: var(--bg-tag);
|
||||
color: var(--text-main);
|
||||
|
||||
.theme-dark & {
|
||||
background: #292524; // warm-800
|
||||
color: #e7e5e4; // warm-200
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #e5e7eb; // gray-200
|
||||
.theme-dark & {
|
||||
background: #44403c; // warm-700
|
||||
}
|
||||
background: #292524;
|
||||
color: #e7e5e4;
|
||||
}
|
||||
}
|
||||
|
||||
// 主要按钮: flex-[2]
|
||||
&.primary {
|
||||
flex: 2;
|
||||
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
|
||||
color: #fff;
|
||||
font-weight: 700; // font-bold
|
||||
font-weight: 700;
|
||||
font-size: 28rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.3);
|
||||
|
||||
@@ -736,29 +820,24 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
}
|
||||
}
|
||||
|
||||
// 删除好友链接: text-xs text-gray-400 underline
|
||||
.delete-link {
|
||||
text-align: center;
|
||||
|
||||
text {
|
||||
font-size: 24rpx; // text-xs
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: underline; // underline
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:active text {
|
||||
color: #ef4444; // hover:text-red-500
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
// 底部留白
|
||||
.bottom-spacer {
|
||||
height: 360rpx;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 陌生人信息卡片 (参考设计稿)
|
||||
// ==========================================
|
||||
.stranger-card {
|
||||
margin: 0 24rpx 48rpx;
|
||||
background: var(--bg-card);
|
||||
@@ -804,9 +883,6 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 添加好友按钮 (参考设计稿)
|
||||
// ==========================================
|
||||
.add-friend-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -841,7 +917,6 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
}
|
||||
}
|
||||
|
||||
// 加载中
|
||||
.center-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -849,4 +924,3 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -498,7 +498,7 @@ async function logout() { try { await messageBox.confirm({ title: '提示', msg:
|
||||
|
||||
/* #ifndef MP-WEIXIN */
|
||||
// H5/App:仅状态栏
|
||||
padding-top: var(--status-bar-height, 0);
|
||||
padding-top: var(--status-bar-height, 20px);
|
||||
/* #endif */
|
||||
transition: background 0.3s;
|
||||
}
|
||||
@@ -652,8 +652,8 @@ async function logout() { try { await messageBox.confirm({ title: '提示', msg:
|
||||
/* #endif */
|
||||
|
||||
/* #ifndef MP-WEIXIN */
|
||||
margin-top: calc(var(--status-bar-height, 0) + 290rpx);
|
||||
height: calc(100vh - var(--status-bar-height, 0) - 290rpx);
|
||||
margin-top: calc(var(--status-bar-height, 20px) + 290rpx);
|
||||
height: calc(100vh - var(--status-bar-height, 20px) - 290rpx);
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
|
||||
@@ -2,21 +2,27 @@
|
||||
<view class="info-page" :class="{ dark: isDark }">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="群信息" />
|
||||
|
||||
|
||||
<view v-if="loading" class="loading-state">
|
||||
<wd-loading />
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<template v-else-if="groupInfo">
|
||||
<!-- 群头像和名称 -->
|
||||
<!-- 群头部信息 -->
|
||||
<view class="header-section">
|
||||
<app-avatar
|
||||
:src="groupInfo.avatar"
|
||||
:name="groupInfo.name || '群聊'"
|
||||
:size="160"
|
||||
radius="24rpx"
|
||||
/>
|
||||
<view
|
||||
class="header-avatar"
|
||||
:class="{ editable: canManageGroup }"
|
||||
@click="canManageGroup && editGroupAvatar()"
|
||||
>
|
||||
<app-avatar
|
||||
:src="groupInfo.avatar"
|
||||
:name="groupInfo.name || '群聊'"
|
||||
:size="160"
|
||||
radius="24rpx"
|
||||
/>
|
||||
</view>
|
||||
<text class="group-name">{{ groupInfo.name }}</text>
|
||||
<view class="info-tags">
|
||||
<text class="info-tag">ID: {{ groupInfo.room_id?.slice(-6) }}</text>
|
||||
@@ -24,7 +30,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<!-- 快捷操作 -->
|
||||
<view class="actions-grid">
|
||||
<!-- 发起通话 -->
|
||||
<view class="action-item" @click="handleStartCall">
|
||||
@@ -50,8 +56,8 @@
|
||||
<text class="action-label">公告</text>
|
||||
</view>
|
||||
|
||||
<!-- 管理员设置(仅群主) -->
|
||||
<view v-if="isOwner" class="action-item" @click="showAdminModal = true">
|
||||
<!-- 管理员设置(仅群主可见) -->
|
||||
<view v-if="isOwner" class="action-item" @click="openAdminModal">
|
||||
<view class="action-icon indigo">
|
||||
<wd-icon name="user" size="40rpx" color="#fff" />
|
||||
</view>
|
||||
@@ -67,7 +73,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 群公告预览 -->
|
||||
<!-- 群公告展示 -->
|
||||
<view v-if="announcement" class="announcement-section" @click="editAnnouncement">
|
||||
<view class="section-title">
|
||||
<wd-icon name="bell" size="28rpx" color="var(--color-primary)" />
|
||||
@@ -80,7 +86,7 @@
|
||||
<view class="members-section">
|
||||
<view class="section-header" @click="goMembers">
|
||||
<text class="title">群成员</text>
|
||||
<text class="more">全部 {{ members.length }} 人 ›</text>
|
||||
<text class="more">查看全部 {{ members.length }} 名成员</text>
|
||||
</view>
|
||||
<scroll-view class="members-scroll" scroll-x>
|
||||
<!-- 邀请入口 -->
|
||||
@@ -100,12 +106,12 @@
|
||||
@longpress="handleMemberLongPress(member)"
|
||||
>
|
||||
<view class="avatar-wrap">
|
||||
<app-avatar
|
||||
:src="member.user?.avatar"
|
||||
:name="member.nickname || member.user?.name"
|
||||
<app-avatar
|
||||
:src="member.user?.avatar"
|
||||
:name="member.nickname || member.user?.name"
|
||||
:size="96"
|
||||
radius="16rpx"
|
||||
/>
|
||||
/>
|
||||
<!-- 群主标识 -->
|
||||
<view v-if="member.role === 2" class="role-badge owner">
|
||||
<wd-icon name="crown" size="20rpx" color="#000" />
|
||||
@@ -124,7 +130,7 @@
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 群设置 -->
|
||||
<!-- 聊天设置 -->
|
||||
<wd-cell-group title="聊天设置">
|
||||
<wd-cell title="消息免打扰" center>
|
||||
<template #value>
|
||||
@@ -137,6 +143,7 @@
|
||||
</template>
|
||||
</wd-cell>
|
||||
<wd-cell title="我在本群的昵称" :value="myNickname || '未设置'" is-link @click="editMyNickname" />
|
||||
<wd-cell v-if="canManageGroup" title="群名称" :value="groupInfo.name" is-link @click="editGroupInfo" />
|
||||
</wd-cell-group>
|
||||
|
||||
<!-- 危险操作 -->
|
||||
@@ -149,7 +156,7 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 邀请成员弹窗 -->
|
||||
<!-- 邀请好友弹窗 -->
|
||||
<select-contacts-modal
|
||||
v-model="showInviteModal"
|
||||
:contacts="availableContacts"
|
||||
@@ -158,7 +165,7 @@
|
||||
@select="handleInviteMembers"
|
||||
/>
|
||||
|
||||
<!-- 发起群通话弹窗 -->
|
||||
<!-- 群通话弹窗 -->
|
||||
<group-call-modal
|
||||
v-model="showCallModal"
|
||||
:members="callCandidates"
|
||||
@@ -197,7 +204,7 @@
|
||||
</scroll-view>
|
||||
<view class="modal-footer">
|
||||
<wd-button plain @click="showAdminModal = false">取消</wd-button>
|
||||
<wd-button type="primary" :loading="savingAdmins" @click="handleSaveAdmins">保存设置</wd-button>
|
||||
<wd-button type="primary" :loading="savingAdmins" @click="handleSaveAdmins">保存</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
@@ -207,7 +214,7 @@
|
||||
<view class="transfer-modal">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">转让群主</text>
|
||||
<text class="modal-subtitle">选择新群主</text>
|
||||
<text class="modal-subtitle">转让后您将成为普通成员</text>
|
||||
</view>
|
||||
<scroll-view class="modal-content" scroll-y>
|
||||
<view
|
||||
@@ -234,7 +241,7 @@
|
||||
</scroll-view>
|
||||
<view class="modal-footer">
|
||||
<wd-button plain @click="showTransferModal = false">取消</wd-button>
|
||||
<wd-button type="warning" :disabled="!transferToUserId" :loading="transferring" @click="handleTransferOwner">确认转让</wd-button>
|
||||
<wd-button type="warning" :disabled="!transferToUserId" :loading="transferring" @click="handleTransferOwner">确定转让</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
@@ -247,12 +254,12 @@
|
||||
cancel-text="取消"
|
||||
/>
|
||||
|
||||
<!-- 禁言时长选择弹窗 -->
|
||||
<!-- 禁言时长选择 -->
|
||||
<wd-popup v-model="showMuteModal" position="bottom" safe-area-inset-bottom custom-style="border-radius: 24rpx 24rpx 0 0;">
|
||||
<view class="mute-modal">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">禁言成员</text>
|
||||
<text class="modal-subtitle">选择禁言 {{ mutingMember?.nickname || mutingMember?.user?.name || '该成员' }} 的时长</text>
|
||||
<text class="modal-title">设置禁言</text>
|
||||
<text class="modal-subtitle">确定将 {{ mutingMember?.nickname || mutingMember?.user?.name || '该成员' }} 禁言</text>
|
||||
</view>
|
||||
<view class="mute-options">
|
||||
<view class="mute-option" @click="confirmMute(600)">
|
||||
@@ -291,6 +298,7 @@ import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import * as roomApi from '@/api/modules/room'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import * as conversationApi from '@/api/modules/conversation'
|
||||
import * as attachmentApi from '@/api/modules/attachment'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
|
||||
@@ -306,7 +314,7 @@ const toast = useToast()
|
||||
const messageBox = useMessage()
|
||||
const groupWebRTC = useGroupWebRTC()
|
||||
|
||||
// 基础状态
|
||||
// 页面状态
|
||||
const loading = ref(true)
|
||||
const groupInfo = ref<GroupInfo | null>(null)
|
||||
const members = ref<GroupMember[]>([])
|
||||
@@ -324,7 +332,7 @@ const showTransferModal = ref(false)
|
||||
const showMemberActions = ref(false)
|
||||
const showMuteModal = ref(false)
|
||||
|
||||
// 禁言操作
|
||||
// 禁言相关
|
||||
const mutingMember = ref<GroupMember | null>(null)
|
||||
|
||||
// 管理员设置
|
||||
@@ -338,15 +346,16 @@ const transferring = ref(false)
|
||||
// 成员操作
|
||||
const selectedMember = ref<GroupMember | null>(null)
|
||||
|
||||
// 计算属性
|
||||
// 权限与展示
|
||||
const currentUserRole = computed(() => members.value.find(m => m.user_id === authStore.user?.id)?.role ?? 0)
|
||||
const isOwner = computed(() => currentUserRole.value === 2)
|
||||
const isAdmin = computed(() => currentUserRole.value === 1)
|
||||
const canInvite = computed(() => isOwner.value || isAdmin.value)
|
||||
const canManageGroup = computed(() => isOwner.value || isAdmin.value)
|
||||
const displayMembers = computed(() => members.value.slice(0, 12))
|
||||
const myNickname = computed(() => members.value.find(m => m.user_id === authStore.user?.id)?.nickname || '')
|
||||
|
||||
// 转换群成员为 Contact 格式
|
||||
// 通话候选成员,转换为 Contact 结构
|
||||
const callCandidates = computed(() => {
|
||||
return members.value
|
||||
.filter(m => m.user_id !== authStore.user?.id)
|
||||
@@ -359,29 +368,26 @@ const callCandidates = computed(() => {
|
||||
} as unknown as Contact))
|
||||
})
|
||||
|
||||
// 管理员候选人(排除群主)
|
||||
// 管理员候选(排除群主本人)
|
||||
const adminCandidates = computed(() => {
|
||||
return members.value.filter(m => m.user_id !== authStore.user?.id && m.role !== 2)
|
||||
})
|
||||
|
||||
// 转让候选人(排除自己)
|
||||
// 转让候选(排除自己)
|
||||
const transferCandidates = computed(() => {
|
||||
return members.value.filter(m => m.user_id !== authStore.user?.id)
|
||||
})
|
||||
|
||||
// 成员操作菜单项
|
||||
// 成员长按操作项
|
||||
const memberActionItems = computed(() => {
|
||||
if (!selectedMember.value) return []
|
||||
const items: any[] = []
|
||||
const items: Array<{ name: string; value: string; color?: string }> = []
|
||||
const member = selectedMember.value
|
||||
|
||||
// 查看资料
|
||||
items.push({ name: '查看资料', value: 'view' })
|
||||
|
||||
// 不能操作自己
|
||||
if (member.user_id === authStore.user?.id) return items
|
||||
|
||||
// 群主的操作
|
||||
if (isOwner.value) {
|
||||
if (member.role === 0) {
|
||||
items.push({ name: '设为管理员', value: 'setAdmin' })
|
||||
@@ -393,22 +399,20 @@ const memberActionItems = computed(() => {
|
||||
} else {
|
||||
items.push({ name: '禁言', value: 'mute' })
|
||||
}
|
||||
items.push({ name: '移除成员', value: 'remove', color: '#fa5151' })
|
||||
}
|
||||
// 管理员的操作
|
||||
else if (isAdmin.value && member.role === 0) {
|
||||
items.push({ name: '移出群聊', value: 'remove', color: '#fa5151' })
|
||||
} else if (isAdmin.value && member.role === 0) {
|
||||
if (isMemberMuted(member)) {
|
||||
items.push({ name: '解除禁言', value: 'unmute' })
|
||||
} else {
|
||||
items.push({ name: '禁言', value: 'mute' })
|
||||
}
|
||||
items.push({ name: '移除成员', value: 'remove', color: '#fa5151' })
|
||||
items.push({ name: '移出群聊', value: 'remove', color: '#fa5151' })
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// 检查成员是否被禁言
|
||||
/** 判断成员是否处于禁言状态 */
|
||||
function isMemberMuted(member: GroupMember): boolean {
|
||||
if (!member.muted_until) return false
|
||||
return new Date(member.muted_until) > new Date()
|
||||
@@ -430,6 +434,7 @@ onShow(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/** 加载群信息、成员与会话设置 */
|
||||
async function loadGroupInfo() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -438,64 +443,61 @@ async function loadGroupInfo() {
|
||||
roomApi.getGroupMembers(roomId.value),
|
||||
roomApi.getGroupAnnouncement(roomId.value).catch(() => ({ announcement: '' })),
|
||||
contactApi.getContacts(),
|
||||
// 获取会话设置,用于初始化免打扰和置顶状态
|
||||
conversationApi.getConversationByRoom(roomId.value).catch(() => null)
|
||||
])
|
||||
groupInfo.value = info
|
||||
members.value = memberList
|
||||
// 按角色排序
|
||||
members.value.sort((a, b) => b.role - a.role)
|
||||
announcement.value = announcementRes.announcement
|
||||
|
||||
// 初始化免打扰和置顶状态
|
||||
if (conversationRes) {
|
||||
isMuted.value = conversationRes.is_muted || false
|
||||
isTop.value = conversationRes.is_top || false
|
||||
}
|
||||
|
||||
// 过滤可邀请的好友
|
||||
const memberIds = new Set(memberList.map(m => m.user_id))
|
||||
availableContacts.value = (contacts || []).filter(c => {
|
||||
const contactUserId = c.user_id || c.contact_user_id || c.id
|
||||
return contactUserId && !memberIds.has(contactUserId)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载群信息失败:', error)
|
||||
console.error('加载群信息失败', error)
|
||||
toast.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导航
|
||||
/** 跳转群成员列表页 */
|
||||
function goMembers() {
|
||||
uni.navigateTo({ url: `/pages/group/members?roomId=${roomId.value}` })
|
||||
}
|
||||
|
||||
/** 群设置入口(预留) */
|
||||
function goSettings() {
|
||||
// 可以跳转到更详细的设置页,或者使用弹窗
|
||||
toast.show('更多设置功能开发中')
|
||||
toast.show('群设置功能开发中')
|
||||
}
|
||||
|
||||
// 通话
|
||||
/** 打开发起通话弹窗 */
|
||||
function handleStartCall() {
|
||||
if (callCandidates.value.length === 0) {
|
||||
toast.info('群里只有你一个人,无法通话')
|
||||
toast.info('暂无可通话的成员')
|
||||
return
|
||||
}
|
||||
showCallModal.value = true
|
||||
}
|
||||
|
||||
/** 确认发起群通话 */
|
||||
function handleConfirmStartCall(type: 'audio' | 'video', selectedIds: string[]) {
|
||||
if (!selectedIds.length) {
|
||||
toast.warning('请选择至少一名成员')
|
||||
toast.warning('请至少选择一名成员')
|
||||
return
|
||||
}
|
||||
groupWebRTC.startGroupCall(roomId.value, selectedIds, type)
|
||||
showCallModal.value = false
|
||||
}
|
||||
|
||||
// 邀请成员
|
||||
/** 邀请好友入群 */
|
||||
async function handleInviteMembers(selectedContacts: Contact[]) {
|
||||
const memberIds = selectedContacts.map(c => c.user_id || c.id).filter(Boolean) as string[]
|
||||
if (!memberIds.length) return
|
||||
@@ -510,11 +512,11 @@ async function handleInviteMembers(selectedContacts: Contact[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// 公告
|
||||
/** 编辑群公告 */
|
||||
async function editAnnouncement() {
|
||||
try {
|
||||
const { value } = await messageBox.prompt({
|
||||
title: '群公告',
|
||||
title: '编辑群公告',
|
||||
inputValue: announcement.value,
|
||||
inputPlaceholder: '请输入群公告'
|
||||
})
|
||||
@@ -529,17 +531,59 @@ async function editAnnouncement() {
|
||||
}
|
||||
}
|
||||
|
||||
// 我的群昵称
|
||||
/** 修改群名称(管理员/群主) */
|
||||
async function editGroupInfo() {
|
||||
if (!canManageGroup.value || !groupInfo.value) return
|
||||
try {
|
||||
const { value } = await messageBox.prompt({
|
||||
title: '修改群名称',
|
||||
inputValue: groupInfo.value.name,
|
||||
inputPlaceholder: '请输入群名称'
|
||||
})
|
||||
if (!value?.trim()) return
|
||||
await roomApi.updateGroup(roomId.value, { name: value.trim() })
|
||||
groupInfo.value.name = value.trim()
|
||||
toast.success('群名称已更新')
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') toast.error('修改失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 修改群头像(管理员/群主) */
|
||||
function editGroupAvatar() {
|
||||
if (!canManageGroup.value || !groupInfo.value) return
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: async (res) => {
|
||||
const filePath = res.tempFilePaths[0]
|
||||
if (!filePath) return
|
||||
try {
|
||||
toast.loading('上传中...')
|
||||
const attachment = await attachmentApi.uploadAttachment(filePath, 'image')
|
||||
await roomApi.updateGroup(roomId.value, { avatar: attachment.file_url })
|
||||
groupInfo.value!.avatar = attachment.file_url
|
||||
toast.close()
|
||||
toast.success('头像已更新')
|
||||
} catch (e) {
|
||||
toast.close()
|
||||
toast.error('头像上传失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 修改我在本群的昵称 */
|
||||
async function editMyNickname() {
|
||||
try {
|
||||
const { value } = await messageBox.prompt({
|
||||
title: '我在本群的昵称',
|
||||
title: '修改我在本群的昵称',
|
||||
inputValue: myNickname.value,
|
||||
inputPlaceholder: '请输入昵称'
|
||||
inputPlaceholder: '请输入群昵称'
|
||||
})
|
||||
|
||||
await roomApi.updateMyNickname(roomId.value, value)
|
||||
// 更新本地状态
|
||||
const me = members.value.find(m => m.user_id === authStore.user?.id)
|
||||
if (me) me.nickname = value
|
||||
toast.success('昵称已更新')
|
||||
@@ -550,7 +594,7 @@ async function editMyNickname() {
|
||||
}
|
||||
}
|
||||
|
||||
// 免打扰/置顶
|
||||
/** 切换消息免打扰 */
|
||||
async function toggleMute() {
|
||||
const newValue = isMuted.value
|
||||
try {
|
||||
@@ -558,14 +602,14 @@ async function toggleMute() {
|
||||
target_id: roomId.value,
|
||||
is_muted: newValue
|
||||
})
|
||||
toast.success(newValue ? '已开启免打扰' : '已关闭免打扰')
|
||||
toast.success(newValue ? '已开启消息免打扰' : '已关闭消息免打扰')
|
||||
} catch (e) {
|
||||
// 回滚状态
|
||||
isMuted.value = !newValue
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换置顶聊天 */
|
||||
async function toggleTop() {
|
||||
const newValue = isTop.value
|
||||
try {
|
||||
@@ -575,22 +619,23 @@ async function toggleTop() {
|
||||
})
|
||||
toast.success(newValue ? '已置顶' : '已取消置顶')
|
||||
} catch (e) {
|
||||
// 回滚状态
|
||||
isTop.value = !newValue
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 成员操作
|
||||
/** 点击成员查看资料 */
|
||||
function handleMemberClick(member: GroupMember) {
|
||||
uni.navigateTo({ url: `/pages/contact/detail?userId=${member.user_id}` })
|
||||
}
|
||||
|
||||
/** 长按成员弹出操作菜单 */
|
||||
function handleMemberLongPress(member: GroupMember) {
|
||||
selectedMember.value = member
|
||||
showMemberActions.value = true
|
||||
}
|
||||
|
||||
/** 处理成员操作菜单选择 */
|
||||
async function onMemberActionSelect(item: { value: string }) {
|
||||
if (!selectedMember.value) return
|
||||
const member = selectedMember.value
|
||||
@@ -606,7 +651,7 @@ async function onMemberActionSelect(item: { value: string }) {
|
||||
await setMemberRole(member, 0)
|
||||
break
|
||||
case 'mute':
|
||||
await muteMember(member)
|
||||
muteMember(member)
|
||||
break
|
||||
case 'unmute':
|
||||
await unmuteMember(member)
|
||||
@@ -618,6 +663,7 @@ async function onMemberActionSelect(item: { value: string }) {
|
||||
showMemberActions.value = false
|
||||
}
|
||||
|
||||
/** 设置成员角色 */
|
||||
async function setMemberRole(member: GroupMember, role: number) {
|
||||
try {
|
||||
await roomApi.updateMemberRole(roomId.value, member.user_id, { role })
|
||||
@@ -628,28 +674,31 @@ async function setMemberRole(member: GroupMember, role: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开禁言时长选择弹窗 */
|
||||
function muteMember(member: GroupMember) {
|
||||
mutingMember.value = member
|
||||
showMuteModal.value = true
|
||||
}
|
||||
|
||||
/** 确认禁言成员 */
|
||||
async function confirmMute(duration: number) {
|
||||
if (!mutingMember.value) return
|
||||
try {
|
||||
await roomApi.muteGroupMember(roomId.value, mutingMember.value.user_id, duration)
|
||||
const durationText = duration === 0 ? '永久' :
|
||||
duration < 3600 ? `${duration / 60}分钟` :
|
||||
duration < 86400 ? `${duration / 3600}小时` :
|
||||
const durationText = duration === 0 ? '永久' :
|
||||
duration < 3600 ? `${duration / 60}分钟` :
|
||||
duration < 86400 ? `${duration / 3600}小时` :
|
||||
`${duration / 86400}天`
|
||||
toast.success(`已禁言${durationText}`)
|
||||
showMuteModal.value = false
|
||||
mutingMember.value = null
|
||||
loadGroupInfo()
|
||||
} catch (e) {
|
||||
toast.error('操作失败')
|
||||
toast.error('禁言失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 解除成员禁言 */
|
||||
async function unmuteMember(member: GroupMember) {
|
||||
try {
|
||||
await roomApi.muteGroupMember(roomId.value, member.user_id, -1)
|
||||
@@ -660,14 +709,15 @@ async function unmuteMember(member: GroupMember) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 移出群成员 */
|
||||
async function removeMember(member: GroupMember) {
|
||||
try {
|
||||
await messageBox.confirm({
|
||||
title: '移除成员',
|
||||
msg: `确定要将"${member.nickname || member.user?.name || '该成员'}"移出群聊吗?`
|
||||
title: '移出群聊',
|
||||
msg: `确定将"${member.nickname || member.user?.name || '该成员'}"移出群聊吗?`
|
||||
})
|
||||
await roomApi.removeGroupMember(roomId.value, member.user_id)
|
||||
toast.success('已移除')
|
||||
toast.success('已移出群聊')
|
||||
loadGroupInfo()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
@@ -676,7 +726,13 @@ async function removeMember(member: GroupMember) {
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员设置
|
||||
/** 打开管理员设置弹窗并初始化选中项 */
|
||||
function openAdminModal() {
|
||||
initAdminSelection()
|
||||
showAdminModal.value = true
|
||||
}
|
||||
|
||||
/** 切换管理员选中状态 */
|
||||
function toggleAdminSelection(userId: string) {
|
||||
if (selectedAdminIds.value.has(userId)) {
|
||||
selectedAdminIds.value.delete(userId)
|
||||
@@ -686,6 +742,7 @@ function toggleAdminSelection(userId: string) {
|
||||
selectedAdminIds.value = new Set(selectedAdminIds.value)
|
||||
}
|
||||
|
||||
/** 保存管理员设置 */
|
||||
async function handleSaveAdmins() {
|
||||
savingAdmins.value = true
|
||||
try {
|
||||
@@ -712,13 +769,13 @@ async function handleSaveAdmins() {
|
||||
}
|
||||
}
|
||||
|
||||
// 转让群主
|
||||
/** 转让群主 */
|
||||
async function handleTransferOwner() {
|
||||
if (!transferToUserId.value) return
|
||||
transferring.value = true
|
||||
try {
|
||||
await roomApi.updateMemberRole(roomId.value, transferToUserId.value, { role: 2 })
|
||||
toast.success('群主已转让')
|
||||
toast.success('群主转让成功')
|
||||
showTransferModal.value = false
|
||||
transferToUserId.value = ''
|
||||
loadGroupInfo()
|
||||
@@ -729,26 +786,26 @@ async function handleTransferOwner() {
|
||||
}
|
||||
}
|
||||
|
||||
// 退出群聊
|
||||
/** 退出群聊 */
|
||||
async function quitGroup() {
|
||||
try {
|
||||
await messageBox.confirm({
|
||||
title: '提示',
|
||||
title: '退出群聊',
|
||||
msg: '确定要退出该群聊吗?'
|
||||
})
|
||||
await roomApi.quitGroup(roomId.value)
|
||||
toast.success('已退出')
|
||||
toast.success('已退出群聊')
|
||||
setTimeout(() => {
|
||||
uni.navigateBack({ delta: 2 })
|
||||
}, 1000)
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
toast.error('操作失败')
|
||||
toast.error('退出失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解散群聊
|
||||
/** 解散群聊 */
|
||||
async function dissolveGroup() {
|
||||
try {
|
||||
await messageBox.confirm({
|
||||
@@ -762,12 +819,12 @@ async function dissolveGroup() {
|
||||
}, 1000)
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
toast.error('操作失败')
|
||||
toast.error('解散失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化管理员设置弹窗时的选中状态
|
||||
/** 初始化管理员选中列表(当前管理员) */
|
||||
function initAdminSelection() {
|
||||
selectedAdminIds.value = new Set(
|
||||
members.value.filter(m => m.role === 1).map(m => m.user_id)
|
||||
@@ -800,6 +857,10 @@ function initAdminSelection() {
|
||||
background: var(--bg-content);
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.header-avatar.editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
margin-top: 24rpx;
|
||||
font-size: 40rpx;
|
||||
@@ -1086,7 +1147,7 @@ function initAdminSelection() {
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
// 禁言弹窗样式
|
||||
// 禁言弹窗
|
||||
.mute-modal {
|
||||
background: var(--bg-content);
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<wd-cell
|
||||
v-for="member in filteredMembers"
|
||||
:key="member.user_id"
|
||||
:title="member.nickname || member.user?.name || '未知'"
|
||||
:title="member.nickname || member.user?.name || '成员'"
|
||||
is-link
|
||||
@click="goMemberDetail(member)"
|
||||
>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
round
|
||||
/>
|
||||
<view class="user-info">
|
||||
<text class="name">{{ moment.user?.name || '未知' }}</text>
|
||||
<text class="name">{{ moment.user?.name || '用户' }}</text>
|
||||
<text class="time">{{ formatTime(moment.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<!-- 1. 沉浸式渐变导航栏 -->
|
||||
<view class="nav-header" :style="{ background: `rgba(var(--bg-nav-rgb), ${navOpacity})`, borderBottom: navOpacity > 0.8 ? '1rpx solid var(--border-color)' : 'none' }">
|
||||
<view class="nav-content">
|
||||
<text class="nav-title" :style="{ opacity: navOpacity }">发现</text>
|
||||
<text class="nav-title" :style="{ opacity: navOpacity }">朋友圈</text>
|
||||
|
||||
<view class="nav-right">
|
||||
<view class="nav-icon-btn" @click="goNotifications">
|
||||
|
||||
@@ -185,7 +185,7 @@ async function save() {
|
||||
/* #endif */
|
||||
|
||||
/* #ifndef MP-WEIXIN */
|
||||
padding: calc(var(--status-bar-height, 0) + 16rpx) 24rpx 16rpx;
|
||||
padding: calc(var(--status-bar-height, 20px) + 16rpx) 24rpx 16rpx;
|
||||
/* #endif */
|
||||
|
||||
.nav-back {
|
||||
|
||||
@@ -202,7 +202,7 @@ function goProfileEdit() { uni.navigateTo({ url: '/pages/profile/edit' }) }
|
||||
/* #endif */
|
||||
|
||||
/* #ifndef MP-WEIXIN */
|
||||
padding: calc(var(--status-bar-height, 0) + 24rpx) 40rpx 60rpx;
|
||||
padding: calc(var(--status-bar-height, 20px) + 24rpx) 40rpx 60rpx;
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
|
||||
@@ -446,7 +446,7 @@ function formatTime(time: string): string {
|
||||
/* #endif */
|
||||
|
||||
/* #ifndef MP-WEIXIN */
|
||||
padding: calc(var(--status-bar-height, 0) + 16rpx) 24rpx 16rpx;
|
||||
padding: calc(var(--status-bar-height, 20px) + 16rpx) 24rpx 16rpx;
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<wd-icon name="user" size="40rpx" color="#fff" />
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
<text class="item-text">账号管理</text>
|
||||
<text class="item-text">账号与安全</text>
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
@@ -72,7 +72,7 @@
|
||||
<view class="settings-group animate-fade-in-up" style="animation-delay: 50ms;">
|
||||
<view class="group-title">通用</view>
|
||||
<view class="group-card">
|
||||
<view class="setting-item" @click="goTo('/pages/settings/notification')">
|
||||
<view class="setting-item" @click="toggleNotification">
|
||||
<view class="icon-box red">
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
|
||||
@@ -84,7 +84,8 @@
|
||||
<wd-icon name="bell" size="40rpx" color="#fff" />
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
<text class="item-text">新消息通知</text>
|
||||
<text class="item-text">消息通知</text>
|
||||
<text class="item-value">{{ notifyEnabled ? '已开启' : '已关闭' }}</text>
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
@@ -165,7 +166,7 @@
|
||||
<wd-icon name="chat" size="40rpx" color="#fff" />
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
<text class="item-text">帮助与反馈</text>
|
||||
<text class="item-text">意见反馈</text>
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
@@ -188,7 +189,7 @@
|
||||
<wd-icon name="info" size="40rpx" color="#fff" />
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
<text class="item-text">关于我们</text>
|
||||
<text class="item-text">关于 NL-IM</text>
|
||||
<text class="item-value">v1.0.0</text>
|
||||
<!-- #ifdef H5 || APP-PLUS -->
|
||||
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -215,16 +216,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { useToast, useMessage } from 'wot-design-uni'
|
||||
import * as settingsApi from '@/api/modules/settings'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
const message = useMessage()
|
||||
const cacheSize = ref('12.5MB')
|
||||
const notifyEnabled = ref(true)
|
||||
|
||||
const themeText = computed(() => {
|
||||
const mode = uni.getStorageSync('nl_im_theme_mode') || 'system'
|
||||
@@ -232,9 +235,36 @@ const themeText = computed(() => {
|
||||
return texts[mode] || '跟随系统'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await settingsApi.getUserSettings()
|
||||
if (settings.notification_enabled !== undefined) {
|
||||
notifyEnabled.value = settings.notification_enabled !== '0'
|
||||
}
|
||||
if (settings.cache_size_display) {
|
||||
cacheSize.value = settings.cache_size_display
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
function goBack() { uni.navigateBack() }
|
||||
function goTo(url: string) { uni.navigateTo({ url }) }
|
||||
|
||||
async function toggleNotification() {
|
||||
notifyEnabled.value = !notifyEnabled.value
|
||||
try {
|
||||
await settingsApi.updateUserSettings({
|
||||
notification_enabled: notifyEnabled.value ? '1' : '0',
|
||||
})
|
||||
toast.success(notifyEnabled.value ? '已开启通知' : '已关闭通知')
|
||||
} catch {
|
||||
notifyEnabled.value = !notifyEnabled.value
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
message.confirm({
|
||||
title: '提示',
|
||||
@@ -243,7 +273,7 @@ function handleLogout() {
|
||||
try {
|
||||
await authStore.logout()
|
||||
uni.reLaunch({ url: '/pages/login/index' })
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('退出失败')
|
||||
}
|
||||
}
|
||||
@@ -252,8 +282,11 @@ function handleLogout() {
|
||||
|
||||
function handleClearCache() {
|
||||
toast.loading('清理中...')
|
||||
setTimeout(() => {
|
||||
setTimeout(async () => {
|
||||
cacheSize.value = '0KB'
|
||||
try {
|
||||
await settingsApi.updateUserSettings({ cache_size_display: '0KB' })
|
||||
} catch { /* ignore */ }
|
||||
toast.success('清理完成')
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ function selectTheme(mode: 'system' | 'light' | 'dark') {
|
||||
/* #endif */
|
||||
|
||||
/* #ifndef MP-WEIXIN */
|
||||
padding: calc(var(--status-bar-height, 0) + 16rpx) 24rpx 16rpx;
|
||||
padding: calc(var(--status-bar-height, 20px) + 16rpx) 24rpx 16rpx;
|
||||
/* #endif */
|
||||
|
||||
.nav-back {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ChatMessage, Contact } from '@/types/api'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import { mergeMessageIntoList } from '@/utils/messageMerge'
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const currentTarget = ref<Contact | null>(null)
|
||||
@@ -28,13 +29,23 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加消息
|
||||
* 添加消息(带去重/合并 pending)
|
||||
*/
|
||||
function addMessage(roomId: string, message: ChatMessage) {
|
||||
if (!messages.value[roomId]) {
|
||||
messages.value[roomId] = []
|
||||
}
|
||||
messages.value[roomId].push(message)
|
||||
mergeMessageIntoList(messages.value[roomId], message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 用服务端返回的消息替换本地 pending 项
|
||||
*/
|
||||
function reconcileMessage(roomId: string, serverMessage: ChatMessage) {
|
||||
if (!messages.value[roomId]) {
|
||||
messages.value[roomId] = []
|
||||
}
|
||||
mergeMessageIntoList(messages.value[roomId], { ...serverMessage, pending: false })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,6 +166,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
myMuteStatus,
|
||||
setCurrentTarget,
|
||||
addMessage,
|
||||
reconcileMessage,
|
||||
getRoomMessages,
|
||||
setRoomMessages,
|
||||
setContacts,
|
||||
|
||||
@@ -207,7 +207,7 @@ page {
|
||||
/* #endif */
|
||||
|
||||
/* #ifndef MP-WEIXIN */
|
||||
padding-top: var(--status-bar-height, 0);
|
||||
padding-top: var(--status-bar-height, 20px);
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ page {
|
||||
|
||||
// 微信小程序导航栏占位(含状态栏 + 导航栏)
|
||||
.mp-navbar-spacer {
|
||||
height: calc(var(--status-bar-height, 0) + var(--mp-navbar-height, 44px));
|
||||
height: calc(var(--status-bar-height, 20px) + var(--mp-navbar-height, 44px));
|
||||
}
|
||||
|
||||
// 响应式断点
|
||||
@@ -476,7 +476,7 @@ page {
|
||||
|
||||
/* #ifndef MP-WEIXIN */
|
||||
// H5/App:仅状态栏高度
|
||||
padding-top: var(--status-bar-height, 0);
|
||||
padding-top: var(--status-bar-height, 20px);
|
||||
/* #endif */
|
||||
|
||||
.navbar-content {
|
||||
|
||||
@@ -61,12 +61,21 @@ export interface Contact {
|
||||
is_special_care?: boolean
|
||||
is_blocked?: boolean
|
||||
user?: User // 关联的用户信息(单聊时使用)
|
||||
common_groups?: CommonGroup[] // 共同群聊(详情接口返回)
|
||||
last_msg?: string
|
||||
last_time?: number
|
||||
unread?: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
// 共同群聊(联系人详情接口返回)
|
||||
export interface CommonGroup {
|
||||
room_id: string
|
||||
room_name: string
|
||||
room_avatar?: string
|
||||
room_type?: string
|
||||
}
|
||||
|
||||
// 好友申请
|
||||
export interface FriendRequest {
|
||||
id: number
|
||||
@@ -106,6 +115,8 @@ export interface ChatMessage {
|
||||
id: number
|
||||
room_id: string
|
||||
sender_user_id: string
|
||||
sender_ip?: string
|
||||
ip_location?: string
|
||||
receiver_user_id?: string
|
||||
message_type: number // 0:文本 1:图片 2:语音 3:视频 4:系统 5:好友通知 6:信令 7:群通知 8:文件 9:朋友圈通知
|
||||
content: string
|
||||
@@ -113,6 +124,8 @@ export interface ChatMessage {
|
||||
extra?: string | Record<string, any>
|
||||
created_at: string
|
||||
isSelf?: boolean // 前端标记
|
||||
pending?: boolean // 乐观发送待确认
|
||||
is_read?: boolean // 单聊已读回执
|
||||
}
|
||||
|
||||
// 发送消息请求
|
||||
|
||||
@@ -1,36 +1,26 @@
|
||||
/**
|
||||
* 图片 URL 处理工具函数
|
||||
*/
|
||||
|
||||
// 图片资源基础域名
|
||||
const IMAGE_BASE_URL = 'https://g-ws.nailaoyun.cn'
|
||||
import { API_ORIGIN } from '@/config/app'
|
||||
|
||||
/**
|
||||
* 解析图片 URL,为相对路径添加域名前缀
|
||||
* @param url 图片路径(可能是相对路径或完整 URL)
|
||||
* @returns 完整的图片 URL
|
||||
*/
|
||||
export function resolveImageUrl(url?: string): string {
|
||||
if (!url) return ''
|
||||
|
||||
// 已经是完整 URL(http/https/data URI)
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
|
||||
return url
|
||||
}
|
||||
|
||||
// 仅处理 /upload/ 开头的路径
|
||||
if (url.startsWith('/uploads/')) {
|
||||
return IMAGE_BASE_URL + url
|
||||
const normalized = url.startsWith('/') ? url : `/${url}`
|
||||
if (normalized.startsWith('/uploads/')) {
|
||||
return API_ORIGIN + normalized
|
||||
}
|
||||
|
||||
// 其他情况直接返回
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片基础 URL
|
||||
*/
|
||||
export function getImageBaseUrl(): string {
|
||||
return IMAGE_BASE_URL
|
||||
return API_ORIGIN
|
||||
}
|
||||
|
||||
|
||||
57
src/utils/messageMerge.ts
Normal file
57
src/utils/messageMerge.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 消息合并工具:解决乐观发送(临时 ID)与服务端 ID 不一致导致的重复展示
|
||||
*/
|
||||
import type { ChatMessage } from '@/types/api'
|
||||
|
||||
/** 解析 extra 中的 client_msg_id */
|
||||
export function getClientMsgId(message: ChatMessage): string | undefined {
|
||||
const extra = message.extra
|
||||
if (!extra) return undefined
|
||||
if (typeof extra === 'string') {
|
||||
try {
|
||||
return JSON.parse(extra || '{}')?.client_msg_id
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return extra.client_msg_id
|
||||
}
|
||||
|
||||
/** 生成客户端消息唯一标识,写入 extra.client_msg_id */
|
||||
export function createClientMsgId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并消息到列表:按服务端 id 去重,或按 client_msg_id 替换 pending 项
|
||||
*/
|
||||
export function mergeMessageIntoList(list: ChatMessage[], message: ChatMessage): boolean {
|
||||
if (message.id && list.some((m) => m.id === message.id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const clientMsgId = getClientMsgId(message)
|
||||
if (clientMsgId) {
|
||||
const idx = list.findIndex((m) => getClientMsgId(m) === clientMsgId)
|
||||
if (idx > -1) {
|
||||
list[idx] = { ...message, isSelf: list[idx].isSelf ?? message.isSelf, pending: false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:匹配 pending 的自发消息(同内容、同类型)
|
||||
const fuzzyIdx = list.findIndex(
|
||||
(m) =>
|
||||
m.pending &&
|
||||
m.sender_user_id === message.sender_user_id &&
|
||||
m.message_type === message.message_type &&
|
||||
m.content === message.content
|
||||
)
|
||||
if (fuzzyIdx > -1) {
|
||||
list[fuzzyIdx] = { ...message, isSelf: true, pending: false }
|
||||
return true
|
||||
}
|
||||
|
||||
list.push(message)
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user