修复一些问题

This commit is contained in:
李琦
2026-07-08 08:18:02 +08:00
parent 41e56b3be6
commit 497d0a840f
43 changed files with 1078 additions and 554 deletions

2
.gitignore vendored
View File

@@ -19,3 +19,5 @@ dist
*.njsproj
*.sln
*.sw?
/.mimocode/
/.vscode/

26
components.d.ts vendored
View File

@@ -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']
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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 })
}

View 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 })
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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>

View File

@@ -179,7 +179,7 @@ function handleClose() {
.modal-container {
position: fixed;
inset: 0;
z-index: 1000;
z-index: 2100;
--bg-surface: #fff;
--bg-hover: #f5f5f5;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);

View 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>

View File

@@ -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>

View 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>

View File

@@ -23,7 +23,7 @@
/>
</view>
<text class="username">{{ user?.name || '请登录' }}</text>
<text class="bio">{{ user?.desc || '编辑个,展示你的独特个性' }}</text>
<text class="bio">{{ user?.desc || '编辑个性签名,展示你的独特个性' }}</text>
</view>
<!-- 菜单列表 -->

View 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>

View File

@@ -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 */
}

View File

@@ -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
View 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
View 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

View 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>

View 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>

View File

@@ -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>

View File

@@ -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 */
}

View File

@@ -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>

View File

@@ -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 */
}

View File

@@ -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);

View File

@@ -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)"
>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 {

View File

@@ -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 */
}

View File

@@ -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 */
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 // 单聊已读回执
}
// 发送消息请求

View File

@@ -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 ''
// 已经是完整 URLhttp/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
View 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
}