初始化
This commit is contained in:
16
components.d.ts
vendored
16
components.d.ts
vendored
@@ -13,10 +13,26 @@ declare module 'vue' {
|
||||
AppLoading: typeof import('./src/components/common/AppLoading.vue')['default']
|
||||
AppLogos: typeof import('./src/components/AppLogos.vue')['default']
|
||||
AppNavBar: typeof import('./src/components/common/AppNavBar.vue')['default']
|
||||
AppTabBar: typeof import('./src/components/common/AppTabBar.vue')['default']
|
||||
CallWindow: typeof import('./src/components/call/CallWindow.vue')['default']
|
||||
GroupCallBanner: typeof import('./src/components/call/GroupCallBanner.vue')['default']
|
||||
GroupCallIncoming: typeof import('./src/components/call/GroupCallIncoming.vue')['default']
|
||||
GroupCallModal: typeof import('./src/components/call/GroupCallModal.vue')['default']
|
||||
GroupCallWindow: typeof import('./src/components/call/GroupCallWindow.vue')['default']
|
||||
ImagePreview: typeof import('./src/components/common/ImagePreview.vue')['default']
|
||||
InputEntry: typeof import('./src/components/InputEntry.vue')['default']
|
||||
MentionPicker: typeof import('./src/components/chat/MentionPicker.vue')['default']
|
||||
MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default']
|
||||
MessageInput: typeof import('./src/components/chat/MessageInput.vue')['default']
|
||||
MomentCard: typeof import('./src/components/moment/MomentCard.vue')['default']
|
||||
MomentCommentInput: typeof import('./src/components/moment/MomentCommentInput.vue')['default']
|
||||
MomentCommentList: typeof import('./src/components/moment/MomentCommentList.vue')['default']
|
||||
MomentNotificationPanel: typeof import('./src/components/moment/MomentNotificationPanel.vue')['default']
|
||||
SelectContactsModal: typeof import('./src/components/chat/SelectContactsModal.vue')['default']
|
||||
UserInfoCard: typeof import('./src/components/common/UserInfoCard.vue')['default']
|
||||
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']
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"@uni-helper/unh": "^0.2.7",
|
||||
"@uni-helper/uni-types": "^1.0.0-alpha.7",
|
||||
"@uni-helper/vite-plugin-uni-components": "^0.2.3",
|
||||
"@uni-ku/root": "^1.4.1",
|
||||
"@vue/runtime-core": "3.4.21",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"eslint": "^9.39.1",
|
||||
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -108,6 +108,9 @@ importers:
|
||||
'@uni-helper/vite-plugin-uni-components':
|
||||
specifier: ^0.2.3
|
||||
version: 0.2.3(rollup@4.53.3)
|
||||
'@uni-ku/root':
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.1(vite@5.2.8(@types/node@24.10.1)(sass@1.64.2)(terser@5.44.1))
|
||||
'@vue/runtime-core':
|
||||
specifier: 3.4.21
|
||||
version: 3.4.21
|
||||
@@ -1825,6 +1828,11 @@ packages:
|
||||
'@uni-helper/vite-plugin-uni-components@0.2.3':
|
||||
resolution: {integrity: sha512-in7jRYQnPkLg6W13dzz8mzHDuZC+OFc3qb8MrnmDQgniIxHfxJw8vaibL1au6gvq7ij3h6EHvhHaRytIJ2PkYw==}
|
||||
|
||||
'@uni-ku/root@1.4.1':
|
||||
resolution: {integrity: sha512-DI9LLbWmPXjqJVwZTC84GuvSz9vzOgSUjbFrOE1y3kivttEReNVqwWM3n/kzFoNuS3KKm/DissOph4rFs912Zw==}
|
||||
peerDependencies:
|
||||
vite: ^5.0.0
|
||||
|
||||
'@vitejs/plugin-legacy@5.3.2':
|
||||
resolution: {integrity: sha512-8moCOrIMaZ/Rjln0Q6GsH6s8fAt1JOI3k8nmfX4tXUxE5KAExVctSyOBk+A25GClsdSWqIk2yaUthH3KJ2X4tg==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -8291,6 +8299,13 @@ snapshots:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
'@uni-ku/root@1.4.1(vite@5.2.8(@types/node@24.10.1)(sass@1.64.2)(terser@5.44.1))':
|
||||
dependencies:
|
||||
'@vue/compiler-sfc': 3.4.21
|
||||
chokidar: 3.6.0
|
||||
jsonc-parser: 3.3.1
|
||||
vite: 5.2.8(@types/node@24.10.1)(sass@1.64.2)(terser@5.44.1)
|
||||
|
||||
'@vitejs/plugin-legacy@5.3.2(terser@5.44.1)(vite@5.2.8(@types/node@24.10.1)(sass@1.64.2)(terser@5.44.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.25.2
|
||||
|
||||
12
src/App.ku.vue
Normal file
12
src/App.ku.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from './composables/useTheme'
|
||||
|
||||
const { theme } = useTheme()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<wd-config-provider :theme="theme">
|
||||
<KuRootView />
|
||||
</wd-config-provider>
|
||||
</template>
|
||||
|
||||
44
src/App.vue
44
src/App.vue
@@ -1,10 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch, onShow } from '@dcloudio/uni-app'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { useAuthStore, useConversationStore } from '@/stores'
|
||||
import { wsManager } from '@/api/websocket'
|
||||
import type { ChatMessage } from '@/types/api'
|
||||
|
||||
const { initTheme } = useTheme()
|
||||
const authStore = useAuthStore()
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
// 全局消息处理器
|
||||
function handleGlobalMessage(message: ChatMessage) {
|
||||
// 忽略信令消息
|
||||
if (message.message_type === 6) return
|
||||
|
||||
// 更新会话列表
|
||||
const isSelf = message.sender_user_id === authStore.user?.id
|
||||
conversationStore.handleMessageUpdate(message, isSelf, false)
|
||||
}
|
||||
|
||||
// 初始化 WebSocket 连接
|
||||
async function initWebSocket() {
|
||||
const userId = authStore.user?.id
|
||||
if (!userId) return
|
||||
|
||||
try {
|
||||
await wsManager.connect(userId)
|
||||
// 注册全局消息处理器
|
||||
wsManager.onMessage(handleGlobalMessage)
|
||||
console.log('✅ WebSocket 全局初始化成功')
|
||||
} catch (error) {
|
||||
console.error('WebSocket 初始化失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onLaunch(() => {
|
||||
console.log('App Launch')
|
||||
@@ -12,10 +40,15 @@ onLaunch(() => {
|
||||
initTheme()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
onShow(async () => {
|
||||
console.log('App Show')
|
||||
// 检查登录状态
|
||||
authStore.checkAuth()
|
||||
const isAuth = await authStore.checkAuth()
|
||||
|
||||
// 如果已登录,初始化 WebSocket
|
||||
if (isAuth && authStore.user?.id) {
|
||||
initWebSocket()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -39,7 +72,10 @@ page {
|
||||
}
|
||||
|
||||
/* 暗色模式下的 wot-design-uni 组件适配 */
|
||||
page.dark {
|
||||
page.dark,
|
||||
.dark,
|
||||
html.dark,
|
||||
body.dark {
|
||||
// Cell
|
||||
--wot-cell-bg-color: var(--bg-content);
|
||||
--wot-cell-title-color: var(--text-primary);
|
||||
|
||||
@@ -149,3 +149,8 @@ export function getGroupSettings(roomId: string) {
|
||||
updated_at: string
|
||||
}>(`/groups/${roomId}/settings`)
|
||||
}
|
||||
|
||||
// 更新我在群里的昵称
|
||||
export function updateMyNickname(roomId: string, nickname: string) {
|
||||
return request.post<void>(`/groups/${roomId}/nickname`, { nickname })
|
||||
}
|
||||
325
src/components/call/CallWindow.vue
Normal file
325
src/components/call/CallWindow.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<template>
|
||||
<!-- #ifdef H5 -->
|
||||
<view v-if="call.active" class="call-window" :class="{ minimized: call.minimized }">
|
||||
<!-- 最小化状态 -->
|
||||
<view v-if="call.minimized" class="minimized-bar" @click="toggleMinimize">
|
||||
<wd-icon :name="call.type === 'video' ? 'video' : 'phone'" size="32rpx" />
|
||||
<text class="call-duration">{{ formatDuration(call.duration) }}</text>
|
||||
<wd-icon name="expand" size="32rpx" />
|
||||
</view>
|
||||
|
||||
<!-- 完整窗口 -->
|
||||
<view v-else class="full-window">
|
||||
<!-- 顶部控制栏 -->
|
||||
<view class="header">
|
||||
<view class="status-text">{{ call.statusText }}</view>
|
||||
<view v-if="call.status === 'connected'" class="duration">
|
||||
{{ formatDuration(call.duration) }}
|
||||
</view>
|
||||
<wd-icon name="arrow-down" size="40rpx" class="minimize-btn" @click="toggleMinimize" />
|
||||
</view>
|
||||
|
||||
<!-- 视频区域 -->
|
||||
<view v-if="call.type === 'video'" class="video-area">
|
||||
<!-- 远程视频 -->
|
||||
<video
|
||||
v-if="remoteStream"
|
||||
ref="remoteVideoRef"
|
||||
class="remote-video"
|
||||
autoplay
|
||||
playsinline
|
||||
/>
|
||||
<view v-else class="video-placeholder">
|
||||
<app-avatar :src="call.callerAvatar" :name="call.callerName" :size="160" />
|
||||
<text class="name">{{ call.callerName || '对方' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 本地视频(小窗口) -->
|
||||
<video
|
||||
v-if="localStream && !call.camOff"
|
||||
ref="localVideoRef"
|
||||
class="local-video"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 语音通话 - 头像显示 -->
|
||||
<view v-else class="audio-area">
|
||||
<app-avatar :src="call.callerAvatar" :name="call.callerName" :size="200" />
|
||||
<text class="caller-name">{{ call.callerName || '对方' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 底部控制按钮 -->
|
||||
<view class="controls">
|
||||
<!-- 来电状态 - 接听/拒绝 -->
|
||||
<template v-if="call.status === 'incoming'">
|
||||
<view class="control-btn decline" @click="$emit('reject')">
|
||||
<wd-icon name="phone" size="48rpx" color="#fff" />
|
||||
</view>
|
||||
<view class="control-btn accept" @click="$emit('accept')">
|
||||
<wd-icon name="phone" size="48rpx" color="#fff" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 通话中状态 -->
|
||||
<template v-else-if="call.status === 'connected'">
|
||||
<view class="control-btn" :class="{ active: call.muted }" @click="$emit('toggleMute')">
|
||||
<wd-icon :name="call.muted ? 'mute' : 'audio'" size="40rpx" />
|
||||
</view>
|
||||
<view v-if="call.type === 'video'" class="control-btn" :class="{ active: call.camOff }" @click="$emit('toggleCamera')">
|
||||
<wd-icon :name="call.camOff ? 'video-off' : 'video'" size="40rpx" />
|
||||
</view>
|
||||
<view class="control-btn hangup" @click="$emit('end')">
|
||||
<wd-icon name="phone" size="48rpx" color="#fff" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 呼叫中状态 -->
|
||||
<template v-else-if="call.status === 'outgoing'">
|
||||
<view class="control-btn hangup" @click="$emit('end')">
|
||||
<wd-icon name="phone" size="48rpx" color="#fff" />
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifndef H5 -->
|
||||
<view v-if="call.active" class="call-unsupported">
|
||||
<text>当前平台暂不支持音视频通话</text>
|
||||
<wd-button type="primary" @click="$emit('end')">关闭</wd-button>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { CallState } from '@/composables/useWebRTC'
|
||||
|
||||
interface Props {
|
||||
call: CallState
|
||||
localStream?: MediaStream | null
|
||||
remoteStream?: MediaStream | null
|
||||
formatDuration: (seconds: number) => string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'accept'): void
|
||||
(e: 'reject'): void
|
||||
(e: 'end'): void
|
||||
(e: 'toggleMute'): void
|
||||
(e: 'toggleCamera'): void
|
||||
(e: 'toggleMinimize'): void
|
||||
}>()
|
||||
|
||||
// Video 元素引用
|
||||
const localVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
// 监听 localStream 变化,设置 srcObject
|
||||
watch(() => props.localStream, (stream) => {
|
||||
// #ifdef H5
|
||||
nextTick(() => {
|
||||
if (localVideoRef.value && stream) {
|
||||
localVideoRef.value.srcObject = stream
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听 remoteStream 变化,设置 srcObject
|
||||
watch(() => props.remoteStream, (stream) => {
|
||||
// #ifdef H5
|
||||
nextTick(() => {
|
||||
if (remoteVideoRef.value && stream) {
|
||||
remoteVideoRef.value.srcObject = stream
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}, { immediate: true })
|
||||
|
||||
function toggleMinimize() {
|
||||
// 注意:这里需要通过 emit 触发,因为状态在父组件管理
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.call-window {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
|
||||
&.minimized {
|
||||
top: auto;
|
||||
bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||
left: 20rpx;
|
||||
right: auto;
|
||||
width: 280rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.minimized-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 0 24rpx;
|
||||
color: #fff;
|
||||
|
||||
.call-duration {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.full-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40rpx;
|
||||
padding-top: calc(40rpx + var(--status-bar-height, 0));
|
||||
position: relative;
|
||||
|
||||
.status-text {
|
||||
font-size: 32rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.duration {
|
||||
margin-left: 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.minimize-btn {
|
||||
position: absolute;
|
||||
right: 40rpx;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.video-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
.remote-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 30rpx;
|
||||
|
||||
.name {
|
||||
font-size: 36rpx;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.local-video {
|
||||
position: absolute;
|
||||
top: 40rpx;
|
||||
right: 40rpx;
|
||||
width: 200rpx;
|
||||
height: 280rpx;
|
||||
border-radius: 16rpx;
|
||||
object-fit: cover;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.audio-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 30rpx;
|
||||
|
||||
.caller-name {
|
||||
font-size: 40rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 60rpx;
|
||||
padding: 60rpx;
|
||||
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
|
||||
&.active {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
&.accept {
|
||||
background: #07c160;
|
||||
}
|
||||
|
||||
&.decline,
|
||||
&.hangup {
|
||||
background: #fa5151;
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
}
|
||||
|
||||
.call-unsupported {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
background: var(--bg-page);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 40rpx;
|
||||
|
||||
text {
|
||||
font-size: 32rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
189
src/components/call/GroupCallBanner.vue
Normal file
189
src/components/call/GroupCallBanner.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<view
|
||||
v-if="showBanner"
|
||||
class="group-call-banner"
|
||||
:class="{ dark: isDark }"
|
||||
>
|
||||
<view class="banner-left">
|
||||
<!-- 动态图标 -->
|
||||
<view class="call-icon">
|
||||
<view class="pulse-ring"></view>
|
||||
<view class="icon-inner">
|
||||
<wd-icon name="video" size="32rpx" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="call-info">
|
||||
<view class="call-title">
|
||||
<text class="title-text">群通话进行中</text>
|
||||
<text class="participant-count">{{ participantsCount }}人</text>
|
||||
</view>
|
||||
|
||||
<!-- 头像堆叠 -->
|
||||
<view class="avatar-stack">
|
||||
<view
|
||||
v-for="(user, index) in previewParticipants"
|
||||
:key="user.userId"
|
||||
class="stacked-avatar"
|
||||
:style="{ marginLeft: index > 0 ? '-16rpx' : '0' }"
|
||||
>
|
||||
<app-avatar
|
||||
:src="user.avatar"
|
||||
:name="user.name"
|
||||
:size="40"
|
||||
radius="50%"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
v-if="participantsCount > 5"
|
||||
class="more-count"
|
||||
>
|
||||
+{{ participantsCount - 5 }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-button
|
||||
type="success"
|
||||
size="small"
|
||||
@click="handleJoin"
|
||||
>
|
||||
<wd-icon name="phone" size="28rpx" />
|
||||
<text style="margin-left: 8rpx;">加入</text>
|
||||
</wd-button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
roomId: string
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const groupWebRTC = useGroupWebRTC()
|
||||
const { callState, participants } = groupWebRTC
|
||||
|
||||
const showBanner = computed(() => {
|
||||
return callState.groupId === props.roomId &&
|
||||
!callState.joined &&
|
||||
!callState.incoming &&
|
||||
callState.roomId !== ''
|
||||
})
|
||||
|
||||
const participantsCount = computed(() => participants.value.length)
|
||||
const previewParticipants = computed(() => participants.value.slice(0, 5))
|
||||
|
||||
function handleJoin() {
|
||||
groupWebRTC.joinCurrentCall()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.group-call-banner {
|
||||
margin: 16rpx 24rpx;
|
||||
padding: 24rpx;
|
||||
background: var(--bg-content);
|
||||
border: 1rpx solid var(--divider-color);
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.banner-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.call-icon {
|
||||
position: relative;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
opacity: 0.2;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.icon-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.call-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.call-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
|
||||
.title-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.participant-count {
|
||||
padding: 4rpx 12rpx;
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
font-size: 20rpx;
|
||||
border-radius: 8rpx;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-stack {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.stacked-avatar {
|
||||
border: 2rpx solid var(--bg-content);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.more-count {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
margin-left: -16rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
font-size: 18rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid var(--bg-content);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.2; }
|
||||
50% { transform: scale(1.1); opacity: 0.4; }
|
||||
}
|
||||
</style>
|
||||
|
||||
295
src/components/call/GroupCallIncoming.vue
Normal file
295
src/components/call/GroupCallIncoming.vue
Normal file
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<view v-if="callState.incoming" class="incoming-call-overlay">
|
||||
<!-- 动态背景 -->
|
||||
<view class="bg-effects">
|
||||
<view class="glow glow-1"></view>
|
||||
<view class="glow glow-2"></view>
|
||||
</view>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<view class="content">
|
||||
<!-- 头像区域 -->
|
||||
<view class="avatar-section">
|
||||
<view class="avatar-ring ring-1"></view>
|
||||
<view class="avatar-ring ring-2"></view>
|
||||
<app-avatar
|
||||
:src="callState.inviterAvatar"
|
||||
:name="callState.inviterName"
|
||||
:size="200"
|
||||
radius="50%"
|
||||
class="main-avatar"
|
||||
/>
|
||||
<view class="call-type-badge">
|
||||
<wd-icon
|
||||
:name="callState.type === 'video' ? 'video' : 'phone'"
|
||||
size="32rpx"
|
||||
color="#fff"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 文本信息 -->
|
||||
<text class="inviter-name">{{ callState.inviterName }}</text>
|
||||
<view class="invite-text">
|
||||
<text>邀请你加入群{{ callState.type === 'video' ? '视频' : '语音' }}通话</text>
|
||||
<view class="typing-dots">
|
||||
<view class="dot"></view>
|
||||
<view class="dot"></view>
|
||||
<view class="dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="actions">
|
||||
<!-- 拒绝 -->
|
||||
<view class="action-item" @click="handleReject">
|
||||
<view class="action-btn reject-btn">
|
||||
<wd-icon name="close" size="48rpx" color="#fff" />
|
||||
</view>
|
||||
<text class="action-label">拒绝</text>
|
||||
</view>
|
||||
|
||||
<!-- 接听 -->
|
||||
<view class="action-item" @click="handleAccept">
|
||||
<view class="action-btn accept-btn">
|
||||
<wd-icon
|
||||
:name="callState.type === 'video' ? 'video' : 'phone'"
|
||||
size="48rpx"
|
||||
color="#fff"
|
||||
/>
|
||||
</view>
|
||||
<text class="action-label">接听</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
|
||||
const groupWebRTC = useGroupWebRTC()
|
||||
const { callState } = groupWebRTC
|
||||
|
||||
function handleAccept() {
|
||||
groupWebRTC.acceptInvite()
|
||||
}
|
||||
|
||||
function handleReject() {
|
||||
groupWebRTC.rejectInvite()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.incoming-call-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10000;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.bg-effects {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
width: 600rpx;
|
||||
height: 600rpx;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
animation: pulse 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.glow-1 {
|
||||
top: 20%;
|
||||
left: 20%;
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
|
||||
.glow-2 {
|
||||
bottom: 20%;
|
||||
right: 20%;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
animation-delay: 1s;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
position: relative;
|
||||
margin-bottom: 48rpx;
|
||||
|
||||
.avatar-ring {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.1);
|
||||
animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
|
||||
&.ring-1 {
|
||||
inset: -20rpx;
|
||||
}
|
||||
|
||||
&.ring-2 {
|
||||
inset: -40rpx;
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
}
|
||||
|
||||
.main-avatar {
|
||||
border: 8rpx solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.call-type-badge {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 6rpx solid rgba(15, 23, 42, 0.95);
|
||||
box-shadow: 0 8rpx 20rpx rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.inviter-name {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 16rpx;
|
||||
letter-spacing: -1rpx;
|
||||
}
|
||||
|
||||
.invite-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
font-size: 30rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 80rpx;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
|
||||
.dot {
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s infinite;
|
||||
|
||||
&:nth-child(2) { animation-delay: 0.2s; }
|
||||
&:nth-child(3) { animation-delay: 0.4s; }
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 120rpx;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.reject-btn {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border: 2rpx solid rgba(239, 68, 68, 0.3);
|
||||
|
||||
&:active {
|
||||
background: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.accept-btn {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
background: #10b981;
|
||||
box-shadow: 0 10rpx 40rpx rgba(16, 185, 129, 0.4);
|
||||
animation: pulse-glow 2s infinite;
|
||||
|
||||
&:active {
|
||||
background: #059669;
|
||||
}
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(40rpx); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes ping {
|
||||
0% { transform: scale(1); opacity: 0.3; }
|
||||
75%, 100% { transform: scale(1.3); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.2; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: translateY(0); }
|
||||
40% { transform: translateY(-10rpx); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 10rpx 40rpx rgba(16, 185, 129, 0.4); }
|
||||
50% { box-shadow: 0 10rpx 60rpx rgba(16, 185, 129, 0.6); }
|
||||
}
|
||||
</style>
|
||||
|
||||
346
src/components/call/GroupCallModal.vue
Normal file
346
src/components/call/GroupCallModal.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<wd-popup
|
||||
v-model="modelValue"
|
||||
position="bottom"
|
||||
:safe-area-inset-bottom="true"
|
||||
custom-style="border-radius: 24rpx 24rpx 0 0; max-height: 80vh;"
|
||||
@close="handleClose"
|
||||
>
|
||||
<view class="call-modal" :class="{ dark: isDark }">
|
||||
<!-- 头部 -->
|
||||
<view class="modal-header">
|
||||
<view class="header-info">
|
||||
<text class="header-title">发起群通话</text>
|
||||
<text class="header-subtitle">选择成员加入通话 ({{ selectedIds.size }}/{{ members.length }})</text>
|
||||
</view>
|
||||
<wd-icon
|
||||
name="close"
|
||||
size="40rpx"
|
||||
color="var(--text-secondary)"
|
||||
@click="handleClose"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 搜索与全选 -->
|
||||
<view class="search-bar">
|
||||
<wd-search
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索成员..."
|
||||
hide-cancel
|
||||
/>
|
||||
<wd-button
|
||||
size="small"
|
||||
:type="isAllSelected ? 'success' : 'default'"
|
||||
plain
|
||||
@click="toggleSelectAll"
|
||||
>
|
||||
{{ isAllSelected ? '取消全选' : '全选' }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<!-- 成员列表 -->
|
||||
<scroll-view class="member-list" scroll-y>
|
||||
<view v-if="filteredMembers.length === 0" class="empty-state">
|
||||
<wd-icon name="search" size="80rpx" color="var(--text-tertiary)" />
|
||||
<text class="empty-text">未找到成员</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="member-grid">
|
||||
<view
|
||||
v-for="member in filteredMembers"
|
||||
:key="member.id || member.user_id"
|
||||
class="member-item"
|
||||
@click="toggleSelection(member.id || member.user_id || '')"
|
||||
>
|
||||
<!-- 头像 -->
|
||||
<view class="avatar-wrap">
|
||||
<app-avatar
|
||||
:src="member.user?.avatar"
|
||||
:name="member.remark_name || member.user?.name || '?'"
|
||||
:size="100"
|
||||
radius="50%"
|
||||
:class="{ 'avatar-selected': selectedIds.has(member.id || member.user_id || '') }"
|
||||
/>
|
||||
<!-- 选中标记 -->
|
||||
<view
|
||||
v-if="selectedIds.has(member.id || member.user_id || '')"
|
||||
class="check-badge"
|
||||
>
|
||||
<wd-icon name="check" size="20rpx" color="#fff" />
|
||||
</view>
|
||||
<!-- 在线状态 -->
|
||||
<view
|
||||
v-else-if="member.user?.is_online"
|
||||
class="online-dot"
|
||||
></view>
|
||||
</view>
|
||||
<!-- 名字 -->
|
||||
<text
|
||||
class="member-name"
|
||||
:class="{ 'name-selected': selectedIds.has(member.id || member.user_id || '') }"
|
||||
>
|
||||
{{ member.remark_name || member.user?.name || '未知成员' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="modal-footer">
|
||||
<text class="selected-count">
|
||||
已选择 <text class="count-num">{{ selectedIds.size }}</text> 人
|
||||
</text>
|
||||
<view class="action-buttons">
|
||||
<wd-button
|
||||
:disabled="selectedIds.size === 0"
|
||||
plain
|
||||
@click="emitStartCall('audio')"
|
||||
>
|
||||
<wd-icon name="phone" size="32rpx" />
|
||||
<text style="margin-left: 8rpx;">语音</text>
|
||||
</wd-button>
|
||||
<wd-button
|
||||
type="success"
|
||||
:disabled="selectedIds.size === 0"
|
||||
@click="emitStartCall('video')"
|
||||
>
|
||||
<wd-icon name="video" size="32rpx" />
|
||||
<text style="margin-left: 8rpx;">视频</text>
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { Contact } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
members: Contact[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'start-call': [type: 'audio' | 'video', selectedIds: string[]]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const searchQuery = ref('')
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
|
||||
// 监听打开/关闭重置状态
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
searchQuery.value = ''
|
||||
selectedIds.value = new Set()
|
||||
}
|
||||
})
|
||||
|
||||
// 过滤后的成员
|
||||
const filteredMembers = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
if (!query) return props.members
|
||||
return props.members.filter(m => {
|
||||
const name = m.remark_name || m.user?.name || ''
|
||||
return name.toLowerCase().includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
return filteredMembers.value.length > 0 && selectedIds.value.size === filteredMembers.value.length
|
||||
})
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
if (!id) return
|
||||
if (selectedIds.value.has(id)) {
|
||||
selectedIds.value.delete(id)
|
||||
} else {
|
||||
selectedIds.value.add(id)
|
||||
}
|
||||
// 触发响应式更新
|
||||
selectedIds.value = new Set(selectedIds.value)
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
selectedIds.value = new Set()
|
||||
} else {
|
||||
const ids = filteredMembers.value.map(m => m.id || m.user_id || '').filter(Boolean)
|
||||
selectedIds.value = new Set(ids)
|
||||
}
|
||||
}
|
||||
|
||||
function emitStartCall(type: 'audio' | 'video') {
|
||||
if (selectedIds.value.size === 0) return
|
||||
emit('start-call', type, Array.from(selectedIds.value))
|
||||
handleClose()
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.call-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-content);
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 32rpx;
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
padding: 24rpx 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.member-list {
|
||||
flex: 1;
|
||||
max-height: 50vh;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80rpx 0;
|
||||
gap: 16rpx;
|
||||
|
||||
.empty-text {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.member-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
width: calc(20% - 26rpx);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
|
||||
.avatar-selected {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 0 4rpx #10b981;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.check-badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -8rpx;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4rpx 10rpx rgba(16, 185, 129, 0.4);
|
||||
animation: bounceIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.online-dot {
|
||||
position: absolute;
|
||||
bottom: 4rpx;
|
||||
right: 4rpx;
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
background: #10b981;
|
||||
border: 4rpx solid var(--bg-content);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.name-selected {
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 24rpx 32rpx;
|
||||
border-top: 1rpx solid var(--divider-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
.count-num {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
@keyframes bounceIn {
|
||||
0% { transform: scale(0); opacity: 0; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
474
src/components/call/GroupCallWindow.vue
Normal file
474
src/components/call/GroupCallWindow.vue
Normal file
@@ -0,0 +1,474 @@
|
||||
<template>
|
||||
<view
|
||||
v-if="callState.joined && !callState.incoming"
|
||||
class="call-window"
|
||||
:class="{ minimized: callState.minimized }"
|
||||
>
|
||||
<!-- 顶部功能栏 -->
|
||||
<view class="window-header" @click="handleMinimize">
|
||||
<view class="header-left">
|
||||
<view class="status-dot"></view>
|
||||
<text class="header-title">群聊通话</text>
|
||||
<text class="duration">{{ formatDuration(callState.duration) }}</text>
|
||||
</view>
|
||||
<view class="header-right" @click.stop>
|
||||
<view class="minimize-btn" @click="toggleMinimize">
|
||||
<wd-icon :name="callState.minimized ? 'expand' : 'collapse'" size="32rpx" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最小化模式 -->
|
||||
<view v-if="callState.minimized" class="minimized-content" @click="toggleMinimize">
|
||||
<wd-icon name="phone" size="48rpx" color="#10b981" />
|
||||
<text class="mini-duration">{{ formatDuration(callState.duration) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 正常模式 -->
|
||||
<template v-else>
|
||||
<!-- 视频网格 -->
|
||||
<scroll-view class="video-grid" scroll-y>
|
||||
<!-- 自己的画面 -->
|
||||
<view class="video-item self-video">
|
||||
<!-- #ifdef H5 -->
|
||||
<video
|
||||
v-if="localStream && !callState.isSelfCamOff"
|
||||
ref="localVideoRef"
|
||||
class="video-element mirror"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
:srcObject="localStream"
|
||||
></video>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 摄像头关闭时显示头像 -->
|
||||
<view v-if="callState.isSelfCamOff" class="cam-off-placeholder">
|
||||
<app-avatar
|
||||
:name="'我'"
|
||||
:size="120"
|
||||
radius="50%"
|
||||
/>
|
||||
<text class="cam-off-text">摄像头已关闭</text>
|
||||
</view>
|
||||
|
||||
<view class="video-label">
|
||||
<text class="label-name">我</text>
|
||||
<wd-icon
|
||||
v-if="callState.isSelfMuted"
|
||||
name="volume-mute"
|
||||
size="24rpx"
|
||||
color="#ef4444"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 其他成员 -->
|
||||
<view
|
||||
v-for="user in participants"
|
||||
:key="user.userId"
|
||||
class="video-item"
|
||||
>
|
||||
<!-- #ifdef H5 -->
|
||||
<video
|
||||
v-if="user.stream && !user.isCamOff"
|
||||
class="video-element"
|
||||
autoplay
|
||||
playsinline
|
||||
:srcObject="user.stream"
|
||||
></video>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 无视频流或摄像头关闭 -->
|
||||
<view v-if="!user.stream || user.isCamOff" class="cam-off-placeholder">
|
||||
<view class="avatar-wrap">
|
||||
<app-avatar
|
||||
:src="user.avatar"
|
||||
:name="user.name"
|
||||
:size="120"
|
||||
radius="50%"
|
||||
/>
|
||||
<view v-if="user.status === 'connecting'" class="connecting-overlay">
|
||||
<wd-loading color="#fff" size="32rpx" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="participant-name">{{ user.name }}</text>
|
||||
<text class="participant-status">
|
||||
{{ user.status === 'connecting' ? '连接中...' : user.status === 'rejected' ? '已拒绝' : '摄像头关闭' }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="video-label">
|
||||
<text class="label-name">{{ user.name }}</text>
|
||||
<wd-icon
|
||||
v-if="user.isMuted"
|
||||
name="volume-mute"
|
||||
size="24rpx"
|
||||
color="#ef4444"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 说话指示 -->
|
||||
<view
|
||||
v-if="!user.isMuted && user.volume && user.volume > 0.05"
|
||||
class="speaking-indicator"
|
||||
></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部控制栏 -->
|
||||
<view class="control-bar">
|
||||
<!-- 静音 -->
|
||||
<view class="control-item" @click="webRTC.toggleSelfMute()">
|
||||
<view class="control-btn" :class="{ active: callState.isSelfMuted }">
|
||||
<wd-icon
|
||||
:name="callState.isSelfMuted ? 'volume-mute' : 'volume'"
|
||||
size="40rpx"
|
||||
color="#fff"
|
||||
/>
|
||||
</view>
|
||||
<text class="control-label">{{ callState.isSelfMuted ? '已静音' : '静音' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 挂断 -->
|
||||
<view class="control-item" @click="webRTC.leaveCall()">
|
||||
<view class="control-btn hangup-btn">
|
||||
<wd-icon name="phone-off" size="48rpx" color="#fff" />
|
||||
</view>
|
||||
<text class="control-label">挂断</text>
|
||||
</view>
|
||||
|
||||
<!-- 摄像头 -->
|
||||
<view class="control-item" @click="webRTC.toggleSelfCamera()">
|
||||
<view class="control-btn" :class="{ active: callState.isSelfCamOff }">
|
||||
<wd-icon
|
||||
:name="callState.isSelfCamOff ? 'video-off' : 'video'"
|
||||
size="40rpx"
|
||||
color="#fff"
|
||||
/>
|
||||
</view>
|
||||
<text class="control-label">{{ callState.isSelfCamOff ? '已关闭' : '摄像头' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
|
||||
const webRTC = useGroupWebRTC()
|
||||
const { callState, participants, formatDuration, localStream } = webRTC
|
||||
|
||||
const localVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
// 绑定本地视频流
|
||||
watchEffect(() => {
|
||||
const stream = localStream.value
|
||||
const videoEl = localVideoRef.value
|
||||
if (videoEl && stream) {
|
||||
videoEl.muted = true
|
||||
if ((videoEl as any).srcObject !== stream) {
|
||||
(videoEl as any).srcObject = stream
|
||||
videoEl.play().catch(e => console.error(e))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function toggleMinimize() {
|
||||
callState.minimized = !callState.minimized
|
||||
}
|
||||
|
||||
function handleMinimize() {
|
||||
if (callState.minimized) {
|
||||
toggleMinimize()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.call-window {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: #1e1e1e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.5);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
// 正常模式
|
||||
&:not(.minimized) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
// 最小化模式
|
||||
&.minimized {
|
||||
top: calc(var(--status-bar-height, 0) + 160rpx);
|
||||
right: 24rpx;
|
||||
width: 200rpx;
|
||||
height: 280rpx;
|
||||
border-radius: 24rpx;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.window-header {
|
||||
height: 88rpx;
|
||||
padding: 0 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
|
||||
.minimized & {
|
||||
background: transparent;
|
||||
height: 60rpx;
|
||||
padding: 0 16rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
|
||||
.minimized & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-family: monospace;
|
||||
padding-left: 12rpx;
|
||||
border-left: 2rpx solid rgba(255, 255, 255, 0.2);
|
||||
margin-left: 4rpx;
|
||||
|
||||
.minimized & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.minimize-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.minimized-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.mini-duration {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
flex: 1;
|
||||
padding: 100rpx 16rpx 16rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
position: relative;
|
||||
width: calc(50% - 8rpx);
|
||||
aspect-ratio: 4 / 3;
|
||||
background: #252525;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.video-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
|
||||
&.mirror {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
|
||||
.cam-off-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.connecting-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cam-off-text,
|
||||
.participant-name {
|
||||
font-size: 24rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.participant-status {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.video-label {
|
||||
position: absolute;
|
||||
bottom: 12rpx;
|
||||
left: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 8rpx;
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
.label-name {
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
max-width: 120rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.speaking-indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 4rpx solid rgba(16, 185, 129, 0.6);
|
||||
border-radius: 16rpx;
|
||||
pointer-events: none;
|
||||
animation: speakPulse 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
height: 180rpx;
|
||||
padding: 24rpx 0 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 80rpx;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.control-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
|
||||
:deep(.wd-icon) {
|
||||
color: #000 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hangup-btn {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 32rpx;
|
||||
background: #ef4444;
|
||||
box-shadow: 0 10rpx 40rpx rgba(239, 68, 68, 0.4);
|
||||
|
||||
&:active {
|
||||
background: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes speakPulse {
|
||||
0% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
154
src/components/chat/MentionPicker.vue
Normal file
154
src/components/chat/MentionPicker.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<view v-if="show" class="mention-picker" :class="{ dark: isDark }">
|
||||
<!-- 搜索框 -->
|
||||
<view class="search-section">
|
||||
<wd-search
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索用户..."
|
||||
hide-cancel
|
||||
focus
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<scroll-view class="user-list" scroll-y>
|
||||
<view v-if="filteredUsers.length === 0" class="empty-state">
|
||||
<text>暂无匹配用户</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(user, index) in filteredUsers"
|
||||
:key="user.id"
|
||||
class="user-item"
|
||||
:class="{ selected: index === selectedIndex }"
|
||||
@click="selectUser(user)"
|
||||
>
|
||||
<app-avatar
|
||||
:src="user.avatar"
|
||||
:name="user.name"
|
||||
:size="64"
|
||||
radius="50%"
|
||||
/>
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{ user.name || user.id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
|
||||
export interface MentionUser {
|
||||
id: string
|
||||
name: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
show: boolean
|
||||
users: MentionUser[]
|
||||
}>(), {})
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [user: MentionUser]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const searchQuery = ref('')
|
||||
const selectedIndex = ref(0)
|
||||
|
||||
// 过滤用户
|
||||
const filteredUsers = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
if (!query) return props.users.slice(0, 20)
|
||||
return props.users.filter(user =>
|
||||
user.name?.toLowerCase().includes(query) ||
|
||||
user.id?.toLowerCase().includes(query)
|
||||
).slice(0, 20)
|
||||
})
|
||||
|
||||
// 选择用户
|
||||
function selectUser(user: MentionUser) {
|
||||
emit('select', user)
|
||||
searchQuery.value = ''
|
||||
selectedIndex.value = 0
|
||||
}
|
||||
|
||||
// 监听显示状态
|
||||
watch(() => props.show, (show) => {
|
||||
if (show) {
|
||||
searchQuery.value = ''
|
||||
selectedIndex.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
// 监听搜索变化
|
||||
watch(searchQuery, () => {
|
||||
selectedIndex.value = 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mention-picker {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-bottom: 16rpx;
|
||||
background: var(--bg-content);
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 -8rpx 40rpx rgba(0, 0, 0, 0.2);
|
||||
border: 1rpx solid var(--divider-color);
|
||||
overflow: hidden;
|
||||
z-index: 100;
|
||||
max-height: 500rpx;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
padding: 16rpx;
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
}
|
||||
|
||||
.user-list {
|
||||
max-height: 400rpx;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40rpx;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 24rpx;
|
||||
gap: 20rpx;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:active,
|
||||
&.selected {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,3 +105,10 @@ const bubbleClass = computed(() => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
<template>
|
||||
<view class="message-input">
|
||||
<!-- @ 提及弹窗 -->
|
||||
<wd-popup v-model="showMentionPicker" position="bottom" safe-area-inset-bottom>
|
||||
<view class="mention-picker">
|
||||
<view class="mention-header">
|
||||
<text class="title">选择要 @ 的成员</text>
|
||||
<wd-icon name="close" size="40rpx" @click="showMentionPicker = false" />
|
||||
</view>
|
||||
<scroll-view scroll-y class="mention-list">
|
||||
<view
|
||||
v-for="member in members"
|
||||
:key="member.user_id"
|
||||
class="mention-item"
|
||||
@click="selectMention(member)"
|
||||
>
|
||||
<app-avatar
|
||||
:src="member.user?.avatar"
|
||||
:name="member.nickname || member.user?.name"
|
||||
:size="72"
|
||||
radius="8rpx"
|
||||
/>
|
||||
<text class="mention-name">{{ member.nickname || member.user?.name || '未知' }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<view class="input-toolbar">
|
||||
<wd-icon name="emotion" size="48rpx" class="tool-icon" @click="$emit('emoji')" />
|
||||
<wd-icon name="picture" size="48rpx" class="tool-icon" @click="$emit('image')" />
|
||||
<wd-icon name="folder" size="48rpx" class="tool-icon" @click="$emit('file')" />
|
||||
<wd-icon v-if="isGroup" name="at-sign" size="48rpx" class="tool-icon" @click="openMentionPicker" />
|
||||
</view>
|
||||
|
||||
<!-- 输入框 -->
|
||||
@@ -16,6 +43,7 @@
|
||||
:rows="1"
|
||||
placeholder="输入消息..."
|
||||
no-border
|
||||
@input="onInput"
|
||||
@confirm="onSend"
|
||||
/>
|
||||
</view>
|
||||
@@ -41,13 +69,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { GroupMember } from '@/types/api'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
isGroup?: boolean
|
||||
members?: GroupMember[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: ''
|
||||
modelValue: '',
|
||||
isGroup: false,
|
||||
members: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -57,9 +91,11 @@ const emit = defineEmits<{
|
||||
(e: 'image'): void
|
||||
(e: 'file'): void
|
||||
(e: 'more'): void
|
||||
(e: 'mention', member: GroupMember): void
|
||||
}>()
|
||||
|
||||
const inputValue = ref(props.modelValue)
|
||||
const showMentionPicker = ref(false)
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
inputValue.value = val
|
||||
@@ -69,6 +105,30 @@ watch(inputValue, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
function onInput(e: any) {
|
||||
const value = e.detail?.value || e.value || inputValue.value
|
||||
// 检测 @ 符号
|
||||
if (props.isGroup && value.endsWith('@')) {
|
||||
showMentionPicker.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function openMentionPicker() {
|
||||
showMentionPicker.value = true
|
||||
}
|
||||
|
||||
function selectMention(member: GroupMember) {
|
||||
const name = member.nickname || member.user?.name || ''
|
||||
// 如果已经输入了 @,就替换;否则追加 @名字
|
||||
if (inputValue.value.endsWith('@')) {
|
||||
inputValue.value = inputValue.value + name + ' '
|
||||
} else {
|
||||
inputValue.value = inputValue.value + '@' + name + ' '
|
||||
}
|
||||
showMentionPicker.value = false
|
||||
emit('mention', member)
|
||||
}
|
||||
|
||||
function onSend() {
|
||||
if (!inputValue.value.trim()) return
|
||||
emit('send', inputValue.value.trim())
|
||||
@@ -103,4 +163,51 @@ function onSend() {
|
||||
border-radius: 16rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
}
|
||||
|
||||
// @ 提及选择器
|
||||
.mention-picker {
|
||||
max-height: 60vh;
|
||||
background: var(--bg-content);
|
||||
|
||||
.mention-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 30rpx;
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
|
||||
.title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.mention-list {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.mention-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 30rpx;
|
||||
gap: 20rpx;
|
||||
|
||||
&:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mention-name {
|
||||
font-size: 30rpx;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
348
src/components/chat/SelectContactsModal.vue
Normal file
348
src/components/chat/SelectContactsModal.vue
Normal file
@@ -0,0 +1,348 @@
|
||||
<template>
|
||||
<wd-popup
|
||||
v-model="modelValue"
|
||||
position="bottom"
|
||||
:safe-area-inset-bottom="true"
|
||||
custom-style="border-radius: 24rpx 24rpx 0 0; max-height: 80vh;"
|
||||
@close="handleClose"
|
||||
>
|
||||
<view class="contacts-modal" :class="{ dark: isDark }">
|
||||
<!-- 头部 -->
|
||||
<view class="modal-header">
|
||||
<view class="header-info">
|
||||
<text class="header-title">{{ title || (mode === 'invite' ? '邀请好友' : '选择联系人') }}</text>
|
||||
<text class="header-subtitle">已选择 {{ selectedContacts.length }} 人</text>
|
||||
</view>
|
||||
<wd-icon
|
||||
name="close"
|
||||
size="40rpx"
|
||||
color="var(--text-secondary)"
|
||||
@click="handleClose"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<view class="search-bar">
|
||||
<wd-search
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索联系人..."
|
||||
hide-cancel
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 已选联系人 -->
|
||||
<scroll-view v-if="selectedContacts.length > 0" class="selected-bar" scroll-x>
|
||||
<view
|
||||
v-for="contact in selectedContacts"
|
||||
:key="contact.id"
|
||||
class="selected-tag"
|
||||
@click="removeContact(contact.id || contact.user_id || '')"
|
||||
>
|
||||
<text class="tag-name">{{ contact.user?.name || contact.remark_name }}</text>
|
||||
<wd-icon name="close" size="24rpx" color="#fff" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 联系人列表 -->
|
||||
<scroll-view class="contact-list" scroll-y>
|
||||
<view v-if="filteredContacts.length === 0" class="empty-state">
|
||||
<wd-icon name="search" size="80rpx" color="var(--text-tertiary)" />
|
||||
<text class="empty-text">暂无联系人</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="contact in filteredContacts"
|
||||
:key="contact.id"
|
||||
class="contact-item"
|
||||
:class="{ selected: isSelected(contact.id || contact.user_id || '') }"
|
||||
@click="toggleContact(contact)"
|
||||
>
|
||||
<app-avatar
|
||||
:src="contact.user?.avatar"
|
||||
:name="contact.user?.name || contact.remark_name || '?'"
|
||||
:size="80"
|
||||
radius="50%"
|
||||
/>
|
||||
<view class="contact-info">
|
||||
<text class="contact-name">{{ contact.user?.name || contact.remark_name }}</text>
|
||||
<text v-if="contact.user?.desc" class="contact-desc">{{ contact.user.desc }}</text>
|
||||
</view>
|
||||
<view class="check-box" :class="{ checked: isSelected(contact.id || contact.user_id || '') }">
|
||||
<wd-icon
|
||||
v-if="isSelected(contact.id || contact.user_id || '')"
|
||||
name="check"
|
||||
size="24rpx"
|
||||
color="#fff"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 群名称输入(创建模式) -->
|
||||
<view v-if="mode === 'create'" class="group-name-input">
|
||||
<wd-input
|
||||
v-model="groupName"
|
||||
placeholder="请输入群名称(必填)"
|
||||
:maxlength="20"
|
||||
no-border
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<view class="modal-footer">
|
||||
<wd-button plain @click="handleClose">取消</wd-button>
|
||||
<wd-button
|
||||
type="primary"
|
||||
:disabled="selectedContacts.length < 1 || (mode === 'create' && !groupName.trim())"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ mode === 'invite' ? '邀请' : '创建群聊' }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { Contact } from '@/types/api'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
contacts: Contact[]
|
||||
mode?: 'create' | 'invite'
|
||||
title?: string
|
||||
}>(), {
|
||||
mode: 'create'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'create': [data: { member_ids: string[]; name?: string; avatar?: string }]
|
||||
'invite': [data: { member_ids: string[] }]
|
||||
'select': [contacts: Contact[]]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const searchQuery = ref('')
|
||||
const groupName = ref('')
|
||||
const selectedContacts = ref<Contact[]>([])
|
||||
|
||||
// 重置状态
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
searchQuery.value = ''
|
||||
groupName.value = ''
|
||||
selectedContacts.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// 过滤联系人
|
||||
const filteredContacts = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.contacts.filter(c => {
|
||||
const name = (c.user?.name || c.remark_name || '').toLowerCase()
|
||||
return name.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
// 判断是否选中
|
||||
function isSelected(contactId: string): boolean {
|
||||
return selectedContacts.value.some(c => (c.id || c.user_id) === contactId)
|
||||
}
|
||||
|
||||
// 切换选择
|
||||
function toggleContact(contact: Contact) {
|
||||
const contactId = contact.id || contact.user_id || ''
|
||||
const index = selectedContacts.value.findIndex(c => (c.id || c.user_id) === contactId)
|
||||
if (index > -1) {
|
||||
selectedContacts.value.splice(index, 1)
|
||||
} else {
|
||||
selectedContacts.value.push(contact)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除已选
|
||||
function removeContact(contactId: string) {
|
||||
const index = selectedContacts.value.findIndex(c => (c.id || c.user_id) === contactId)
|
||||
if (index > -1) {
|
||||
selectedContacts.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 确认
|
||||
function handleConfirm() {
|
||||
if (selectedContacts.value.length < 1) return
|
||||
|
||||
const memberIds = selectedContacts.value.map(c => c.user_id || c.id).filter(Boolean) as string[]
|
||||
|
||||
if (props.mode === 'invite') {
|
||||
emit('invite', { member_ids: memberIds })
|
||||
emit('select', selectedContacts.value)
|
||||
} else {
|
||||
if (!groupName.value.trim()) return
|
||||
emit('create', {
|
||||
member_ids: memberIds,
|
||||
name: groupName.value.trim(),
|
||||
avatar: groupName.value.charAt(0).toUpperCase()
|
||||
})
|
||||
}
|
||||
|
||||
handleClose()
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.contacts-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-content);
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 32rpx;
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
padding: 16rpx 24rpx;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.selected-bar {
|
||||
padding: 16rpx 24rpx;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
}
|
||||
|
||||
.selected-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
background: var(--color-primary);
|
||||
border-radius: 24rpx;
|
||||
margin-right: 12rpx;
|
||||
|
||||
.tag-name {
|
||||
font-size: 24rpx;
|
||||
color: #fff;
|
||||
max-width: 120rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-list {
|
||||
flex: 1;
|
||||
max-height: 50vh;
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80rpx;
|
||||
gap: 16rpx;
|
||||
|
||||
.empty-text {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
border-radius: 16rpx;
|
||||
gap: 20rpx;
|
||||
transition: background 0.2s;
|
||||
|
||||
&.selected {
|
||||
background: rgba(var(--color-primary-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.contact-name {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.contact-desc {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4rpx;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.check-box {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.checked {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.group-name-input {
|
||||
padding: 16rpx 24rpx;
|
||||
border-top: 1rpx solid var(--divider-color);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 24rpx 32rpx;
|
||||
border-top: 1rpx solid var(--divider-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<view class="app-avatar" :style="avatarStyle" @click="onClick">
|
||||
<wd-img
|
||||
v-if="src"
|
||||
:src="src"
|
||||
v-if="resolvedSrc"
|
||||
:src="resolvedSrc"
|
||||
:width="sizeRpx"
|
||||
:height="sizeRpx"
|
||||
:round="round"
|
||||
@@ -31,6 +31,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
|
||||
interface Props {
|
||||
src?: string
|
||||
@@ -54,6 +55,23 @@ 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/')
|
||||
}
|
||||
|
||||
// 处理图片 URL,添加域名前缀,并验证是否为有效图片 URL
|
||||
const resolvedSrc = computed(() => {
|
||||
// 先检查原始 src 是否为有效图片路径
|
||||
if (!isValidImageUrl(props.src)) return ''
|
||||
return resolveImageUrl(props.src)
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
@@ -113,3 +131,10 @@ function onClick() {
|
||||
border: 2rpx solid var(--bg-content);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -48,3 +48,10 @@ withDefaults(defineProps<Props>(), {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,136 +1,76 @@
|
||||
<template>
|
||||
<view class="app-navbar" :class="{ dark: isDark }">
|
||||
<view class="navbar-status-bar" />
|
||||
<view class="navbar-content">
|
||||
<!-- 左侧 -->
|
||||
<view class="navbar-left" @click="onLeftClick">
|
||||
<slot name="left">
|
||||
<template v-if="showBack">
|
||||
<wd-icon name="arrow-left" size="44rpx" />
|
||||
<wd-navbar
|
||||
:title="title"
|
||||
:left-arrow="leftArrow"
|
||||
:fixed="fixed"
|
||||
:placeholder="placeholder"
|
||||
:bordered="bordered"
|
||||
:safe-area-inset-top="safeAreaInsetTop"
|
||||
:z-index="zIndex"
|
||||
custom-class="app-navbar"
|
||||
@click-left="onClickLeft"
|
||||
@click-right="onClickRight"
|
||||
>
|
||||
<template v-if="$slots.left" #left>
|
||||
<slot name="left" />
|
||||
</template>
|
||||
<template v-else-if="showAvatar">
|
||||
<wd-img
|
||||
v-if="avatarUrl"
|
||||
:src="avatarUrl"
|
||||
width="64rpx"
|
||||
height="64rpx"
|
||||
round
|
||||
/>
|
||||
<view v-else class="avatar-placeholder">
|
||||
{{ avatarText }}
|
||||
</view>
|
||||
<template v-if="$slots.title" #title>
|
||||
<slot name="title" />
|
||||
</template>
|
||||
</slot>
|
||||
</view>
|
||||
|
||||
<!-- 标题 -->
|
||||
<view class="navbar-title">
|
||||
<slot name="title">
|
||||
<text class="title-text">{{ title }}</text>
|
||||
</slot>
|
||||
</view>
|
||||
|
||||
<!-- 右侧 -->
|
||||
<view class="navbar-right" @click="onRightClick">
|
||||
<template v-if="$slots.right" #right>
|
||||
<slot name="right" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</wd-navbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
showBack?: boolean
|
||||
showAvatar?: boolean
|
||||
avatarUrl?: string
|
||||
avatarText?: string
|
||||
leftArrow?: boolean
|
||||
fixed?: boolean
|
||||
placeholder?: boolean
|
||||
bordered?: boolean
|
||||
safeAreaInsetTop?: boolean
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
showBack: false,
|
||||
showAvatar: false,
|
||||
avatarUrl: '',
|
||||
avatarText: '?'
|
||||
leftArrow: true,
|
||||
fixed: true,
|
||||
placeholder: true,
|
||||
bordered: true,
|
||||
safeAreaInsetTop: true,
|
||||
zIndex: 100
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'left-click'): void
|
||||
(e: 'right-click'): void
|
||||
(e: 'click-left'): void
|
||||
(e: 'click-right'): void
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
function onLeftClick() {
|
||||
if (props.showBack) {
|
||||
uni.navigateBack()
|
||||
function onClickLeft() {
|
||||
emit('click-left')
|
||||
// 默认返回上一页
|
||||
if (props.leftArrow) {
|
||||
uni.navigateBack({
|
||||
fail: () => {
|
||||
// 如果没有上一页,则跳转到首页
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
}
|
||||
emit('left-click')
|
||||
}
|
||||
|
||||
function onRightClick() {
|
||||
emit('right-click')
|
||||
function onClickRight() {
|
||||
emit('click-right')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
<style lang="scss">
|
||||
.app-navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
|
||||
.navbar-status-bar {
|
||||
height: var(--status-bar-height, 0);
|
||||
}
|
||||
|
||||
.navbar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 88rpx;
|
||||
padding: 0 30rpx;
|
||||
}
|
||||
|
||||
.navbar-left {
|
||||
min-width: 80rpx;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navbar-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
.title-text {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
min-width: 80rpx;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
--wot-navbar-bg-color: var(--nav-bg, #ededed);
|
||||
--wot-navbar-title-color: var(--text-primary, #333);
|
||||
--wot-navbar-icon-color: var(--text-primary, #333);
|
||||
}
|
||||
</style>
|
||||
|
||||
158
src/components/common/AppTabBar.vue
Normal file
158
src/components/common/AppTabBar.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<view class="app-tabbar">
|
||||
<view class="tabbar-wrapper">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
class="tabbar-item"
|
||||
:class="{ active: current === tab.name }"
|
||||
@click="switchTab(tab.name)"
|
||||
>
|
||||
<view class="tabbar-icon">
|
||||
<!-- 消息图标 -->
|
||||
<template v-if="tab.name === 'messages'">
|
||||
<svg v-if="current === tab.name" class="icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>
|
||||
</svg>
|
||||
<svg v-else class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<!-- 未读角标 -->
|
||||
<view v-if="unreadCount > 0" class="badge">
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 联系人图标 -->
|
||||
<template v-else-if="tab.name === 'contacts'">
|
||||
<svg v-if="current === tab.name" class="icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
<svg v-else class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 朋友圈图标 -->
|
||||
<template v-else-if="tab.name === 'moments'">
|
||||
<svg v-if="current === tab.name" class="icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
<svg v-else class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
|
||||
<line x1="9" y1="9" x2="9.01" y2="9"/>
|
||||
<line x1="15" y1="9" x2="15.01" y2="9"/>
|
||||
</svg>
|
||||
</template>
|
||||
</view>
|
||||
<text class="tabbar-text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
interface Props {
|
||||
current?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
current: 'messages'
|
||||
})
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const unreadCount = computed(() => conversationStore.totalUnread)
|
||||
|
||||
const tabs = [
|
||||
{ name: 'messages', label: '消息', path: '/pages/index/index' },
|
||||
{ name: 'contacts', label: '联系人', path: '/pages/contact/index' },
|
||||
{ name: 'moments', label: '朋友圈', path: '/pages/moment/index' }
|
||||
]
|
||||
|
||||
function switchTab(name: string) {
|
||||
// 点击当前 tab 不跳转
|
||||
if (props.current === name) return
|
||||
|
||||
const tab = tabs.find(t => t.name === name)
|
||||
if (tab) {
|
||||
uni.reLaunch({ url: tab.path })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-tabbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 999;
|
||||
background: var(--bg-content, #fff);
|
||||
border-top: 1rpx solid var(--divider-color, #eee);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.tabbar-wrapper {
|
||||
display: flex;
|
||||
height: 100rpx;
|
||||
}
|
||||
|
||||
.tabbar-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
color: #999;
|
||||
transition: color 0.2s;
|
||||
|
||||
&.active {
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.tabbar-icon {
|
||||
position: relative;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -20rpx;
|
||||
min-width: 32rpx;
|
||||
height: 32rpx;
|
||||
padding: 0 8rpx;
|
||||
background: #fa5151;
|
||||
color: #fff;
|
||||
font-size: 20rpx;
|
||||
line-height: 32rpx;
|
||||
text-align: center;
|
||||
border-radius: 32rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabbar-text {
|
||||
font-size: 22rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
385
src/components/common/ImagePreview.vue
Normal file
385
src/components/common/ImagePreview.vue
Normal file
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<view v-if="show" class="image-preview" @touchmove.stop.prevent>
|
||||
<!-- 背景遮罩 -->
|
||||
<view class="preview-backdrop" @click="handleClose" />
|
||||
|
||||
<!-- 主图区域 -->
|
||||
<swiper
|
||||
class="preview-swiper"
|
||||
:current="currentIndex"
|
||||
:indicator-dots="false"
|
||||
@change="onSwiperChange"
|
||||
>
|
||||
<swiper-item v-for="(image, index) in images" :key="index" class="swiper-item">
|
||||
<movable-area class="movable-area">
|
||||
<movable-view
|
||||
class="movable-view"
|
||||
direction="all"
|
||||
:scale="true"
|
||||
:scale-min="1"
|
||||
:scale-max="4"
|
||||
:scale-value="scale"
|
||||
@scale="onScale"
|
||||
>
|
||||
<image
|
||||
class="preview-image"
|
||||
:src="image"
|
||||
mode="aspectFit"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</movable-view>
|
||||
</movable-area>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<!-- 顶部工具栏 -->
|
||||
<view class="top-bar">
|
||||
<view class="close-btn" @click="handleClose">
|
||||
<wd-icon name="close" size="40rpx" color="#fff" />
|
||||
</view>
|
||||
<view class="page-indicator">
|
||||
<text>{{ currentIndex + 1 }} / {{ images.length }}</text>
|
||||
</view>
|
||||
<view class="actions">
|
||||
<view class="action-btn" @click="saveImage">
|
||||
<wd-icon name="download" size="36rpx" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部缩略图 -->
|
||||
<view v-if="images.length > 1" class="thumbnail-bar">
|
||||
<scroll-view class="thumbnail-scroll" scroll-x>
|
||||
<view
|
||||
v-for="(image, index) in images"
|
||||
:key="index"
|
||||
class="thumbnail-item"
|
||||
:class="{ active: index === currentIndex }"
|
||||
@click="goToImage(index)"
|
||||
>
|
||||
<image :src="image" mode="aspectFill" class="thumbnail-image" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 底部控制栏 -->
|
||||
<view class="bottom-bar">
|
||||
<!-- 缩放按钮 -->
|
||||
<view class="zoom-controls">
|
||||
<view class="zoom-btn" @click="zoomOut">
|
||||
<wd-icon name="remove" size="36rpx" color="#fff" />
|
||||
</view>
|
||||
<text class="zoom-text">{{ Math.round(scale * 100) }}%</text>
|
||||
<view class="zoom-btn" @click="zoomIn">
|
||||
<wd-icon name="add" size="36rpx" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 旋转按钮 -->
|
||||
<view class="rotate-btn" @click="rotate">
|
||||
<wd-icon name="refresh" size="36rpx" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-toast />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
images: string[]
|
||||
initialIndex?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'update:show': [value: boolean]
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const currentIndex = ref(0)
|
||||
const scale = ref(1)
|
||||
const rotation = ref(0)
|
||||
|
||||
// 监听打开
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
currentIndex.value = props.initialIndex || 0
|
||||
scale.value = 1
|
||||
rotation.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
// swiper 切换
|
||||
function onSwiperChange(e: any) {
|
||||
currentIndex.value = e.detail.current
|
||||
scale.value = 1 // 切换后重置缩放
|
||||
}
|
||||
|
||||
// 跳转到指定图片
|
||||
function goToImage(index: number) {
|
||||
currentIndex.value = index
|
||||
scale.value = 1
|
||||
}
|
||||
|
||||
// 缩放回调
|
||||
function onScale(e: any) {
|
||||
scale.value = e.detail.scale
|
||||
}
|
||||
|
||||
// 放大
|
||||
function zoomIn() {
|
||||
scale.value = Math.min(4, scale.value + 0.5)
|
||||
}
|
||||
|
||||
// 缩小
|
||||
function zoomOut() {
|
||||
scale.value = Math.max(1, scale.value - 0.5)
|
||||
}
|
||||
|
||||
// 旋转 (仅显示提示,Uniapp 原生组件不支持旋转)
|
||||
function rotate() {
|
||||
toast.show('长按图片可保存')
|
||||
}
|
||||
|
||||
// 保存图片
|
||||
async function saveImage() {
|
||||
const currentImage = props.images[currentIndex.value]
|
||||
if (!currentImage) return
|
||||
|
||||
try {
|
||||
// 先下载图片
|
||||
const downloadResult = await new Promise<{ tempFilePath: string }>((resolve, reject) => {
|
||||
uni.downloadFile({
|
||||
url: currentImage,
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve(res)
|
||||
} else {
|
||||
reject(new Error('下载失败'))
|
||||
}
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
|
||||
// 保存到相册
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
uni.saveImageToPhotosAlbum({
|
||||
filePath: downloadResult.tempFilePath,
|
||||
success: () => resolve(),
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
|
||||
toast.success('已保存到相册')
|
||||
} catch (e: any) {
|
||||
if (e?.errMsg?.includes('auth deny')) {
|
||||
toast.error('请授权访问相册')
|
||||
// 可以打开设置页面
|
||||
uni.openSetting({})
|
||||
} else {
|
||||
toast.error('保存失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 图片加载错误
|
||||
function onImageError() {
|
||||
toast.error('图片加载失败')
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
emit('update:show', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image-preview {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.preview-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.preview-swiper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.swiper-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.movable-area {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.movable-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(88rpx + var(--status-bar-height, 0));
|
||||
padding-top: var(--status-bar-height, 0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-left: 24rpx;
|
||||
padding-right: 24rpx;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.6) 0%, transparent 100%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.close-btn,
|
||||
.action-btn {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.thumbnail-bar {
|
||||
position: absolute;
|
||||
bottom: 200rpx;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 16rpx 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.thumbnail-scroll {
|
||||
white-space: nowrap;
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.thumbnail-item {
|
||||
display: inline-block;
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
margin-right: 16rpx;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
border: 4rpx solid transparent;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
border-color: #fff;
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.thumbnail-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(120rpx + env(safe-area-inset-bottom));
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 60rpx;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 0.6) 0%, transparent 100%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 40rpx;
|
||||
padding: 8rpx 24rpx;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.zoom-text {
|
||||
font-size: 24rpx;
|
||||
color: #fff;
|
||||
min-width: 80rpx;
|
||||
text-align: center;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.rotate-btn {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
541
src/components/common/UserInfoCard.vue
Normal file
541
src/components/common/UserInfoCard.vue
Normal file
@@ -0,0 +1,541 @@
|
||||
<template>
|
||||
<wd-popup
|
||||
v-model="modelValue"
|
||||
position="bottom"
|
||||
:safe-area-inset-bottom="true"
|
||||
custom-style="border-radius: 32rpx 32rpx 0 0; max-height: 85vh;"
|
||||
@close="handleClose"
|
||||
>
|
||||
<view class="user-info-card" :class="{ dark: isDark }">
|
||||
<!-- 背景装饰 -->
|
||||
<view class="bg-decoration"></view>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<view class="close-btn" @click="handleClose">
|
||||
<wd-icon name="close" size="32rpx" color="rgba(255,255,255,0.6)" />
|
||||
</view>
|
||||
|
||||
<!-- 滚动内容 -->
|
||||
<scroll-view class="card-content" scroll-y>
|
||||
<!-- 头部信息 -->
|
||||
<view class="header-section">
|
||||
<!-- 头像 -->
|
||||
<view class="avatar-wrap" @click="viewAvatar">
|
||||
<app-avatar
|
||||
:src="user?.avatar"
|
||||
:name="user?.name || '未知'"
|
||||
:size="160"
|
||||
radius="50%"
|
||||
class="main-avatar"
|
||||
/>
|
||||
<view v-if="user?.is_online" class="online-dot"></view>
|
||||
</view>
|
||||
|
||||
<!-- 名称和签名 -->
|
||||
<view class="name-wrap">
|
||||
<text class="user-name">{{ user?.name || '未知' }}</text>
|
||||
<view v-if="user?.gender" class="gender-badge" :class="user.gender === 1 ? 'male' : 'female'">
|
||||
<wd-icon :name="user.gender === 1 ? 'male' : 'female'" size="20rpx" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="user-desc">{{ user?.desc || '暂无签名' }}</text>
|
||||
|
||||
<!-- ID标签 -->
|
||||
<view class="info-tags">
|
||||
<text class="info-tag" @click="copyText(user?.id)">ID: {{ user?.id || '未知' }}</text>
|
||||
<text v-if="user?.region" class="info-tag">{{ user.region }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 详细信息 -->
|
||||
<view class="info-section">
|
||||
<wd-cell-group>
|
||||
<wd-cell
|
||||
v-if="user?.phone"
|
||||
title="手机号"
|
||||
:value="user.phone"
|
||||
is-link
|
||||
@click="copyText(user.phone)"
|
||||
>
|
||||
<template #icon>
|
||||
<view class="cell-icon"><wd-icon name="phone" size="32rpx" /></view>
|
||||
</template>
|
||||
</wd-cell>
|
||||
<wd-cell
|
||||
v-if="user?.email"
|
||||
title="邮箱"
|
||||
:value="user.email"
|
||||
is-link
|
||||
@click="copyText(user.email)"
|
||||
>
|
||||
<template #icon>
|
||||
<view class="cell-icon"><wd-icon name="mail" size="32rpx" /></view>
|
||||
</template>
|
||||
</wd-cell>
|
||||
<wd-cell
|
||||
v-if="user?.region"
|
||||
title="地区"
|
||||
:value="user.region"
|
||||
>
|
||||
<template #icon>
|
||||
<view class="cell-icon"><wd-icon name="location" size="32rpx" /></view>
|
||||
</template>
|
||||
</wd-cell>
|
||||
</wd-cell-group>
|
||||
</view>
|
||||
|
||||
<!-- 朋友圈预览 -->
|
||||
<view class="moments-section">
|
||||
<view class="section-header" @click="$emit('view-moments')">
|
||||
<view class="header-left">
|
||||
<view class="cell-icon"><wd-icon name="picture" size="32rpx" /></view>
|
||||
<text class="section-title">朋友圈</text>
|
||||
</view>
|
||||
<view class="header-right">
|
||||
<text class="view-more">查看更多</text>
|
||||
<wd-icon name="arrow-right" size="28rpx" color="var(--color-primary)" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view v-if="loadingMoments" class="moments-loading">
|
||||
<wd-loading />
|
||||
</view>
|
||||
|
||||
<!-- 朋友圈内容 -->
|
||||
<view v-else-if="userMoments.length > 0" class="moments-content">
|
||||
<view class="moments-grid">
|
||||
<view
|
||||
v-for="(moment, index) in userMoments.slice(0, 3)"
|
||||
:key="moment.id"
|
||||
class="moment-item"
|
||||
@click="openMomentPreview(index)"
|
||||
>
|
||||
<wd-img
|
||||
v-if="getMomentImage(moment)"
|
||||
:src="resolveImageUrl(getMomentImage(moment) || '')"
|
||||
width="100%"
|
||||
height="100%"
|
||||
mode="aspectFill"
|
||||
radius="16rpx"
|
||||
/>
|
||||
<view v-else class="text-placeholder">
|
||||
<wd-icon name="file" size="40rpx" color="var(--text-tertiary)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="userMoments[0]?.content" class="latest-text">
|
||||
{{ userMoments[0].content }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else class="moments-empty">
|
||||
<wd-icon name="camera" size="60rpx" color="var(--text-tertiary)" />
|
||||
<text>暂无朋友圈动态</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="action-bar">
|
||||
<view class="action-btn primary" @click="$emit('send-message')">
|
||||
<wd-icon name="chat" size="40rpx" color="#fff" />
|
||||
<text>发消息</text>
|
||||
</view>
|
||||
<view class="action-btn" @click="$emit('audio-call')">
|
||||
<wd-icon name="phone" size="40rpx" />
|
||||
<text>语音</text>
|
||||
</view>
|
||||
<view class="action-btn" @click="$emit('video-call')">
|
||||
<wd-icon name="video" size="40rpx" />
|
||||
<text>视频</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-toast />
|
||||
</wd-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
import * as momentApi from '@/api/modules/moment'
|
||||
import { parseMediaUrls } from '@/types/moment'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { User } from '@/types/api'
|
||||
import type { Moment } from '@/types/moment'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
user: User | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'send-message': []
|
||||
'audio-call': []
|
||||
'video-call': []
|
||||
'view-moments': []
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const toast = useToast()
|
||||
|
||||
// 朋友圈相关
|
||||
const userMoments = ref<Moment[]>([])
|
||||
const loadingMoments = ref(false)
|
||||
|
||||
// 监听显示和用户变化
|
||||
watch(() => [props.modelValue, props.user], async ([show, user]) => {
|
||||
if (show && user && (user as User).id) {
|
||||
await loadUserMoments((user as User).id)
|
||||
} else {
|
||||
userMoments.value = []
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 加载用户朋友圈
|
||||
async function loadUserMoments(userId: string) {
|
||||
loadingMoments.value = true
|
||||
try {
|
||||
const response = await momentApi.getUserMoments(userId, 1, 3)
|
||||
userMoments.value = response.data || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load user moments:', e)
|
||||
userMoments.value = []
|
||||
} finally {
|
||||
loadingMoments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取动态第一张图片
|
||||
function getMomentImage(moment: Moment): string | null {
|
||||
if (moment.media_type === 1 && moment.media_urls) {
|
||||
const urls = parseMediaUrls(moment.media_urls)
|
||||
return urls[0] || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 打开朋友圈预览
|
||||
function openMomentPreview(index: number) {
|
||||
const images: string[] = []
|
||||
userMoments.value.slice(0, 3).forEach(moment => {
|
||||
if (moment.media_type === 1 && moment.media_urls) {
|
||||
const urls = parseMediaUrls(moment.media_urls)
|
||||
images.push(...urls.map(resolveImageUrl))
|
||||
}
|
||||
})
|
||||
|
||||
if (images.length > 0) {
|
||||
uni.previewImage({
|
||||
urls: images,
|
||||
current: images[Math.min(index, images.length - 1)]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 复制文本
|
||||
function copyText(text?: string) {
|
||||
if (!text) return
|
||||
uni.setClipboardData({
|
||||
data: text,
|
||||
success: () => {
|
||||
toast.success('复制成功')
|
||||
},
|
||||
fail: () => {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 查看头像
|
||||
function viewAvatar() {
|
||||
if (props.user?.avatar) {
|
||||
uni.previewImage({
|
||||
urls: [resolveImageUrl(props.user.avatar)]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-info-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-content);
|
||||
max-height: 85vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 300rpx;
|
||||
background: linear-gradient(180deg, rgba(var(--color-primary-rgb), 0.15) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 32rpx;
|
||||
right: 32rpx;
|
||||
z-index: 50;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
max-height: calc(85vh - 180rpx);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
padding: 80rpx 40rpx 40rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
|
||||
.main-avatar {
|
||||
border: 8rpx solid var(--bg-content);
|
||||
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.online-dot {
|
||||
position: absolute;
|
||||
bottom: 10rpx;
|
||||
right: 10rpx;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
border: 6rpx solid var(--bg-content);
|
||||
}
|
||||
}
|
||||
|
||||
.name-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.gender-badge {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.male {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&.female {
|
||||
background: rgba(236, 72, 153, 0.2);
|
||||
color: #ec4899;
|
||||
}
|
||||
}
|
||||
|
||||
.user-desc {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 12rpx;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-tags {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
padding: 8rpx 20rpx;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
padding: 0 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.cell-icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 12rpx;
|
||||
background: var(--bg-hover);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.moments-section {
|
||||
margin: 0 24rpx 24rpx;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.view-more {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.moments-loading {
|
||||
padding: 60rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.moments-content {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.moments-grid {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.moment-item {
|
||||
flex: 1;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
background: var(--bg-content);
|
||||
|
||||
.text-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-content);
|
||||
}
|
||||
}
|
||||
|
||||
.latest-text {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 16rpx;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.moments-empty {
|
||||
padding: 60rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 32rpx;
|
||||
padding: 24rpx 40rpx;
|
||||
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||
border-top: 1rpx solid var(--divider-color);
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 20rpx 32rpx;
|
||||
border-radius: 24rpx;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
min-width: 140rpx;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 8rpx 20rpx rgba(var(--color-primary-rgb), 0.3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
434
src/components/common/VideoPlayer.vue
Normal file
434
src/components/common/VideoPlayer.vue
Normal file
@@ -0,0 +1,434 @@
|
||||
<template>
|
||||
<view class="video-player" :class="{ fullscreen: isFullscreen, dark: isDark }">
|
||||
<!-- 原生视频 -->
|
||||
<video
|
||||
:id="videoId"
|
||||
ref="videoRef"
|
||||
class="video-element"
|
||||
:src="src"
|
||||
:poster="poster"
|
||||
:loop="isLoop"
|
||||
:controls="false"
|
||||
:show-center-play-btn="false"
|
||||
:enable-progress-gesture="true"
|
||||
object-fit="contain"
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@ended="onEnded"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@waiting="isBuffering = true"
|
||||
@error="onError"
|
||||
/>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view v-if="isBuffering" class="loading-overlay">
|
||||
<wd-loading color="#fff" size="48rpx" />
|
||||
</view>
|
||||
|
||||
<!-- 播放按钮(暂停时显示) -->
|
||||
<view v-if="!isPlaying && !isBuffering" class="play-overlay" @click="togglePlay">
|
||||
<view class="play-btn">
|
||||
<wd-icon name="play" size="60rpx" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 控制栏 -->
|
||||
<view
|
||||
v-show="showControls || !isPlaying"
|
||||
class="controls-bar"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 进度条 -->
|
||||
<view class="progress-bar" @click="seekTo">
|
||||
<view class="progress-bg">
|
||||
<view class="progress-buffered" :style="{ width: bufferedPercent + '%' }"></view>
|
||||
<view class="progress-played" :style="{ width: progressPercent + '%' }"></view>
|
||||
</view>
|
||||
<view class="progress-thumb" :style="{ left: progressPercent + '%' }"></view>
|
||||
</view>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<view class="controls-row">
|
||||
<!-- 左侧 -->
|
||||
<view class="controls-left">
|
||||
<!-- 播放/暂停 -->
|
||||
<view class="control-btn" @click="togglePlay">
|
||||
<wd-icon :name="isPlaying ? 'pause' : 'play'" size="36rpx" color="#fff" />
|
||||
</view>
|
||||
|
||||
<!-- 时间 -->
|
||||
<text class="time-text">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 右侧 -->
|
||||
<view class="controls-right">
|
||||
<!-- 倍速 -->
|
||||
<view class="control-btn speed-btn" @click="showSpeedSheet = true">
|
||||
<text class="speed-text">{{ playbackRate }}x</text>
|
||||
</view>
|
||||
|
||||
<!-- 循环 -->
|
||||
<view class="control-btn" :class="{ active: isLoop }" @click="isLoop = !isLoop">
|
||||
<wd-icon name="refresh" size="32rpx" />
|
||||
</view>
|
||||
|
||||
<!-- 全屏 -->
|
||||
<view class="control-btn" @click="toggleFullscreen">
|
||||
<wd-icon :name="isFullscreen ? 'compress' : 'expand'" size="32rpx" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 点击显示/隐藏控制栏 -->
|
||||
<view class="touch-area" @click="toggleControls" />
|
||||
|
||||
<!-- 倍速选择 -->
|
||||
<wd-action-sheet
|
||||
v-model="showSpeedSheet"
|
||||
:actions="speedOptions"
|
||||
@select="onSpeedSelect"
|
||||
cancel-text="取消"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
src: string
|
||||
poster?: string
|
||||
autoplay?: boolean
|
||||
}>(), {
|
||||
autoplay: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
ended: []
|
||||
error: [error: any]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
// 生成唯一 ID
|
||||
const videoId = `video_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
const videoRef = ref<any>(null)
|
||||
const videoContext = ref<UniApp.VideoContext | null>(null)
|
||||
|
||||
// 状态
|
||||
const isPlaying = ref(false)
|
||||
const isBuffering = ref(false)
|
||||
const isFullscreen = ref(false)
|
||||
const isLoop = ref(false)
|
||||
const showControls = ref(true)
|
||||
const showSpeedSheet = ref(false)
|
||||
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const buffered = ref(0)
|
||||
const playbackRate = ref(1)
|
||||
|
||||
// 定时器
|
||||
let controlsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 倍速选项
|
||||
const speedOptions = [
|
||||
{ name: '0.5x', value: 0.5 },
|
||||
{ name: '0.75x', value: 0.75 },
|
||||
{ name: '1.0x (正常)', value: 1 },
|
||||
{ name: '1.25x', value: 1.25 },
|
||||
{ name: '1.5x', value: 1.5 },
|
||||
{ name: '2.0x', value: 2 },
|
||||
]
|
||||
|
||||
// 计算属性
|
||||
const progressPercent = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
const bufferedPercent = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (buffered.value / duration.value) * 100
|
||||
})
|
||||
|
||||
// 方法
|
||||
function togglePlay() {
|
||||
if (!videoContext.value) return
|
||||
if (isPlaying.value) {
|
||||
videoContext.value.pause()
|
||||
} else {
|
||||
videoContext.value.play()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!videoContext.value) return
|
||||
if (isFullscreen.value) {
|
||||
videoContext.value.exitFullScreen()
|
||||
} else {
|
||||
videoContext.value.requestFullScreen({ direction: 0 })
|
||||
}
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
}
|
||||
|
||||
function toggleControls() {
|
||||
showControls.value = !showControls.value
|
||||
if (showControls.value && isPlaying.value) {
|
||||
hideControlsDelayed()
|
||||
}
|
||||
}
|
||||
|
||||
function hideControlsDelayed() {
|
||||
if (controlsTimer) clearTimeout(controlsTimer)
|
||||
controlsTimer = setTimeout(() => {
|
||||
if (isPlaying.value) {
|
||||
showControls.value = false
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function seekTo(e: any) {
|
||||
if (!videoContext.value || !duration.value) return
|
||||
|
||||
// 获取点击位置
|
||||
const rect = e.currentTarget
|
||||
const touch = e.detail || e.touches?.[0] || e
|
||||
const x = touch.x || touch.clientX || 0
|
||||
const width = rect.offsetWidth || 300
|
||||
const percent = Math.max(0, Math.min(1, x / width))
|
||||
const seekTime = percent * duration.value
|
||||
|
||||
videoContext.value.seek(seekTime)
|
||||
currentTime.value = seekTime
|
||||
}
|
||||
|
||||
function onSpeedSelect(item: { value: number }) {
|
||||
playbackRate.value = item.value
|
||||
if (videoContext.value) {
|
||||
videoContext.value.playbackRate(item.value)
|
||||
}
|
||||
showSpeedSheet.value = false
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
function onPlay() {
|
||||
isPlaying.value = true
|
||||
isBuffering.value = false
|
||||
hideControlsDelayed()
|
||||
}
|
||||
|
||||
function onPause() {
|
||||
isPlaying.value = false
|
||||
showControls.value = true
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
isPlaying.value = false
|
||||
showControls.value = true
|
||||
emit('ended')
|
||||
}
|
||||
|
||||
function onTimeUpdate(e: any) {
|
||||
currentTime.value = e.detail.currentTime || 0
|
||||
}
|
||||
|
||||
function onLoadedMetadata(e: any) {
|
||||
duration.value = e.detail.duration || 0
|
||||
if (props.autoplay && videoContext.value) {
|
||||
videoContext.value.play()
|
||||
}
|
||||
}
|
||||
|
||||
function onError(e: any) {
|
||||
isBuffering.value = false
|
||||
emit('error', e)
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (isNaN(seconds)) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
videoContext.value = uni.createVideoContext(videoId)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (controlsTimer) clearTimeout(controlsTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
|
||||
&.fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.video-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading-overlay,
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-left: 8rpx;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.touch-area {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 24rpx;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
height: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.progress-bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-buffered {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3rpx;
|
||||
}
|
||||
|
||||
.progress-played {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
border-radius: 3rpx;
|
||||
}
|
||||
|
||||
.progress-thumb {
|
||||
position: absolute;
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.controls-bar:hover &,
|
||||
.controls-bar:active & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.controls-left,
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.speed-btn {
|
||||
width: auto;
|
||||
padding: 0 16rpx;
|
||||
}
|
||||
|
||||
.speed-text {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-family: monospace;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,10 +3,34 @@ export { default as AppNavBar } from './common/AppNavBar.vue'
|
||||
export { default as AppAvatar } from './common/AppAvatar.vue'
|
||||
export { default as AppEmpty } from './common/AppEmpty.vue'
|
||||
export { default as AppLoading } from './common/AppLoading.vue'
|
||||
export { default as UserInfoCard } from './common/UserInfoCard.vue'
|
||||
export { default as VideoPlayer } from './common/VideoPlayer.vue'
|
||||
export { default as ImagePreview } from './common/ImagePreview.vue'
|
||||
|
||||
// 聊天组件
|
||||
export { default as MessageBubble } from './chat/MessageBubble.vue'
|
||||
export { default as MessageInput } from './chat/MessageInput.vue'
|
||||
export { default as SelectContactsModal } from './chat/SelectContactsModal.vue'
|
||||
export { default as MentionPicker } from './chat/MentionPicker.vue'
|
||||
|
||||
// 通话组件
|
||||
export { default as CallWindow } from './call/CallWindow.vue'
|
||||
export { default as GroupCallBanner } from './call/GroupCallBanner.vue'
|
||||
export { default as GroupCallIncoming } from './call/GroupCallIncoming.vue'
|
||||
export { default as GroupCallModal } from './call/GroupCallModal.vue'
|
||||
export { default as GroupCallWindow } from './call/GroupCallWindow.vue'
|
||||
|
||||
// 朋友圈组件
|
||||
export { default as MomentCard } from './moment/MomentCard.vue'
|
||||
export { default as UserMomentsModal } from './moment/UserMomentsModal.vue'
|
||||
export { default as VisibilitySelector } from './moment/VisibilitySelector.vue'
|
||||
export { default as MomentCommentInput } from './moment/MomentCommentInput.vue'
|
||||
export { default as MomentCommentList } from './moment/MomentCommentList.vue'
|
||||
export { default as MomentNotificationPanel } from './moment/MomentNotificationPanel.vue'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -25,20 +25,20 @@
|
||||
<wd-img
|
||||
v-for="(url, index) in mediaUrls"
|
||||
:key="index"
|
||||
:src="url"
|
||||
:src="resolveImageUrl(url)"
|
||||
:width="imageSize"
|
||||
:height="imageSize"
|
||||
mode="aspectFill"
|
||||
radius="8rpx"
|
||||
enable-preview
|
||||
:preview-src-list="mediaUrls"
|
||||
:preview-src-list="mediaUrls.map(resolveImageUrl)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 视频 -->
|
||||
<view v-if="moment.media_type === 2" class="card-video">
|
||||
<video
|
||||
:src="mediaUrls[0]"
|
||||
:src="resolveImageUrl(mediaUrls[0])"
|
||||
class="video-player"
|
||||
object-fit="cover"
|
||||
:show-fullscreen-btn="true"
|
||||
@@ -85,6 +85,7 @@
|
||||
import { computed } from 'vue'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
import { parseMediaUrls } from '@/types/moment'
|
||||
import type { Moment } from '@/types/moment'
|
||||
|
||||
@@ -217,3 +218,10 @@ function onAction() {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
149
src/components/moment/MomentCommentInput.vue
Normal file
149
src/components/moment/MomentCommentInput.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<wd-popup
|
||||
v-model="modelValue"
|
||||
position="bottom"
|
||||
:safe-area-inset-bottom="true"
|
||||
custom-style="border-radius: 24rpx 24rpx 0 0;"
|
||||
@close="handleClose"
|
||||
>
|
||||
<view class="comment-input" :class="{ dark: isDark }">
|
||||
<!-- 头部 -->
|
||||
<view class="input-header">
|
||||
<text class="header-title">{{ replyTo ? `回复 ${replyTo.user?.name}` : '写评论' }}</text>
|
||||
<wd-icon name="close" size="40rpx" color="var(--text-secondary)" @click="handleClose" />
|
||||
</view>
|
||||
|
||||
<!-- 输入区 -->
|
||||
<view class="input-body">
|
||||
<textarea
|
||||
v-model="content"
|
||||
class="comment-textarea"
|
||||
:placeholder="replyTo ? `回复 ${replyTo.user?.name}...` : '写评论...'"
|
||||
:maxlength="500"
|
||||
:focus="true"
|
||||
auto-height
|
||||
/>
|
||||
|
||||
<view class="input-footer">
|
||||
<text class="char-count">{{ content.length }}/500</text>
|
||||
<wd-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!content.trim() || submitting"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
发送
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-toast />
|
||||
</wd-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useMomentStore } from '@/stores/moment'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import type { MomentComment } from '@/types/moment'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
momentId: number
|
||||
replyTo?: MomentComment | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'commented': []
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const momentStore = useMomentStore()
|
||||
const toast = useToast()
|
||||
|
||||
const content = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
// 打开时重置
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
content.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!content.value.trim() || submitting.value) return
|
||||
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
await momentStore.addComment(props.momentId, {
|
||||
content: content.value.trim(),
|
||||
reply_to_comment_id: props.replyTo?.id,
|
||||
})
|
||||
toast.success('评论成功')
|
||||
emit('commented')
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
toast.error('评论失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.comment-input {
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
.input-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 32rpx;
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
|
||||
.header-title {
|
||||
font-size: 30rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.input-body {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.comment-textarea {
|
||||
width: 100%;
|
||||
min-height: 200rpx;
|
||||
padding: 20rpx;
|
||||
background: var(--bg-hover);
|
||||
border: 1rpx solid var(--border-color);
|
||||
border-radius: 16rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.input-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 20rpx;
|
||||
|
||||
.char-count {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
169
src/components/moment/MomentCommentList.vue
Normal file
169
src/components/moment/MomentCommentList.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<view class="comment-list" :class="{ dark: isDark }">
|
||||
<view
|
||||
v-for="comment in comments"
|
||||
:key="comment.id"
|
||||
class="comment-item"
|
||||
>
|
||||
<!-- 头像 -->
|
||||
<app-avatar
|
||||
:src="comment.user?.avatar"
|
||||
:name="comment.user?.name || ''"
|
||||
:size="72"
|
||||
radius="50%"
|
||||
/>
|
||||
|
||||
<!-- 评论内容 -->
|
||||
<view class="comment-content">
|
||||
<view class="comment-header">
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{ comment.user?.name }}</text>
|
||||
<view v-if="comment.reply_to_user" class="reply-info">
|
||||
<text class="reply-text">回复</text>
|
||||
<text class="reply-name">{{ comment.reply_to_user.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 删除按钮 -->
|
||||
<view
|
||||
v-if="canDelete(comment)"
|
||||
class="delete-btn"
|
||||
@click="$emit('delete', comment.id)"
|
||||
>
|
||||
<wd-icon name="delete" size="28rpx" color="var(--text-tertiary)" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 评论文本 -->
|
||||
<text class="comment-text">{{ comment.content }}</text>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<view class="comment-footer">
|
||||
<text class="comment-time">{{ formatTime(comment.created_at) }}</text>
|
||||
<text class="reply-btn" @click="$emit('reply', comment)">回复</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { MomentComment } from '@/types/moment'
|
||||
|
||||
const props = defineProps<{
|
||||
comments: MomentComment[]
|
||||
momentUserId: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
reply: [comment: MomentComment]
|
||||
delete: [commentId: number]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function canDelete(comment: MomentComment): boolean {
|
||||
const userId = authStore.user?.id
|
||||
return userId === comment.user_id || userId === props.momentUserId
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.comment-list {
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.reply-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
|
||||
.reply-text {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.reply-name {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 8rpx;
|
||||
margin: -8rpx;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.comment-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.reply-btn {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-link);
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
317
src/components/moment/MomentNotificationPanel.vue
Normal file
317
src/components/moment/MomentNotificationPanel.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<wd-popup
|
||||
v-model="modelValue"
|
||||
position="bottom"
|
||||
:safe-area-inset-bottom="true"
|
||||
custom-style="border-radius: 32rpx 32rpx 0 0; max-height: 80vh;"
|
||||
@close="handleClose"
|
||||
>
|
||||
<view class="notification-panel" :class="{ dark: isDark }">
|
||||
<!-- 头部 -->
|
||||
<view class="panel-header">
|
||||
<text class="header-title">消息通知</text>
|
||||
<view class="header-actions">
|
||||
<text
|
||||
v-if="momentStore.hasUnread"
|
||||
class="mark-read-btn"
|
||||
@click="markAllRead"
|
||||
>
|
||||
全部已读
|
||||
</text>
|
||||
<wd-icon name="close" size="40rpx" color="var(--text-secondary)" @click="handleClose" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<scroll-view
|
||||
class="notification-list"
|
||||
scroll-y
|
||||
@scrolltolower="loadMore"
|
||||
>
|
||||
<!-- 加载中 -->
|
||||
<view v-if="loading && notifications.length === 0" class="loading-state">
|
||||
<wd-loading />
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else-if="notifications.length === 0" class="empty-state">
|
||||
<wd-icon name="bell" size="80rpx" color="var(--text-tertiary)" />
|
||||
<text>暂无消息通知</text>
|
||||
</view>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<view v-else class="notifications">
|
||||
<view
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="notification-item"
|
||||
:class="{ unread: !notification.is_read }"
|
||||
@click="handleNotificationClick(notification)"
|
||||
>
|
||||
<!-- 头像 -->
|
||||
<app-avatar
|
||||
:src="notification.from_user?.avatar"
|
||||
:name="notification.from_user?.name || ''"
|
||||
:size="80"
|
||||
radius="50%"
|
||||
/>
|
||||
|
||||
<!-- 内容 -->
|
||||
<view class="notification-content">
|
||||
<view class="notification-header">
|
||||
<text class="from-name">{{ notification.from_user?.name }}</text>
|
||||
<text class="notification-type">{{ getNotificationText(notification.type) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 评论内容 -->
|
||||
<text v-if="notification.comment" class="comment-preview">
|
||||
{{ notification.comment.content }}
|
||||
</text>
|
||||
|
||||
<!-- 动态预览 -->
|
||||
<view v-if="notification.moment" class="moment-preview">
|
||||
<text>{{ notification.moment.content || '[图片/视频]' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 时间 -->
|
||||
<text class="notification-time">{{ formatTime(notification.created_at) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 未读标记 -->
|
||||
<view v-if="!notification.is_read" class="unread-dot" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<wd-loadmore v-if="notifications.length > 0" :state="loadState" @loadmore="loadMore" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<wd-toast />
|
||||
</wd-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useMomentStore } from '@/stores/moment'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { MomentNotification } from '@/types/moment'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const momentStore = useMomentStore()
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const loadState = ref<'loading' | 'finished' | 'error'>('loading')
|
||||
|
||||
const notifications = computed(() => momentStore.notifications)
|
||||
|
||||
// 通知类型文本
|
||||
const NotificationTypeText: Record<number, string> = {
|
||||
1: '赞了你',
|
||||
2: '评论了你',
|
||||
3: '回复了你',
|
||||
}
|
||||
|
||||
// 监听打开
|
||||
watch(() => props.modelValue, async (val) => {
|
||||
if (val) {
|
||||
await fetchNotifications(true)
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchNotifications(refresh = false) {
|
||||
loading.value = true
|
||||
loadState.value = 'loading'
|
||||
|
||||
try {
|
||||
await momentStore.fetchNotifications(refresh)
|
||||
loadState.value = momentStore.notifPagination.hasMore ? 'loading' : 'finished'
|
||||
} catch (e) {
|
||||
loadState.value = 'error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!loading.value && momentStore.notifPagination.hasMore) {
|
||||
await momentStore.fetchMoreNotifications()
|
||||
loadState.value = momentStore.notifPagination.hasMore ? 'loading' : 'finished'
|
||||
}
|
||||
}
|
||||
|
||||
function getNotificationText(type: number): string {
|
||||
return NotificationTypeText[type] || ''
|
||||
}
|
||||
|
||||
async function handleNotificationClick(notification: MomentNotification) {
|
||||
// 标记已读
|
||||
if (!notification.is_read) {
|
||||
await momentStore.markAsRead([notification.id])
|
||||
}
|
||||
handleClose()
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
try {
|
||||
await momentStore.markAllAsRead()
|
||||
toast.success('已全部标记为已读')
|
||||
} catch (e) {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notification-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-content);
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx;
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.mark-read-btn {
|
||||
font-size: 26rpx;
|
||||
color: var(--color-primary);
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
flex: 1;
|
||||
max-height: calc(80vh - 120rpx);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 40rpx;
|
||||
gap: 20rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
transition: background 0.2s;
|
||||
|
||||
&:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.unread {
|
||||
background: rgba(var(--color-primary-rgb), 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.from-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.notification-type {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.comment-preview {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12rpx;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.moment-preview {
|
||||
padding: 12rpx 16rpx;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 12rpx;
|
||||
|
||||
text {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.unread-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
343
src/components/moment/UserMomentsModal.vue
Normal file
343
src/components/moment/UserMomentsModal.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<wd-popup
|
||||
v-model="modelValue"
|
||||
position="bottom"
|
||||
:safe-area-inset-bottom="true"
|
||||
custom-style="border-radius: 32rpx 32rpx 0 0; max-height: 85vh;"
|
||||
@close="handleClose"
|
||||
>
|
||||
<view class="moments-modal" :class="{ dark: isDark }">
|
||||
<!-- 头部 -->
|
||||
<view class="modal-header">
|
||||
<view class="header-info">
|
||||
<app-avatar
|
||||
v-if="user"
|
||||
:src="user.avatar"
|
||||
:name="user.name || ''"
|
||||
:size="64"
|
||||
radius="50%"
|
||||
/>
|
||||
<view class="header-text">
|
||||
<text class="header-title">{{ user?.name || '用户' }}的朋友圈</text>
|
||||
<text class="header-subtitle">共 {{ totalCount }} 条动态</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-icon
|
||||
name="close"
|
||||
size="40rpx"
|
||||
color="var(--text-secondary)"
|
||||
@click="handleClose"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view v-if="loading && moments.length === 0" class="loading-state">
|
||||
<wd-loading />
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else-if="moments.length === 0" class="empty-state">
|
||||
<wd-icon name="camera" size="80rpx" color="var(--text-tertiary)" />
|
||||
<text>暂无朋友圈动态</text>
|
||||
</view>
|
||||
|
||||
<!-- 动态列表 -->
|
||||
<scroll-view
|
||||
v-else
|
||||
class="moments-list"
|
||||
scroll-y
|
||||
@scrolltolower="loadMore"
|
||||
>
|
||||
<view
|
||||
v-for="moment in moments"
|
||||
:key="moment.id"
|
||||
class="moment-item"
|
||||
>
|
||||
<!-- 时间 -->
|
||||
<text class="moment-time">{{ formatTime(moment.created_at) }}</text>
|
||||
|
||||
<!-- 文字内容 -->
|
||||
<view v-if="moment.content" class="moment-text">
|
||||
<text>{{ moment.content }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 图片 -->
|
||||
<view v-if="moment.media_type === 1" class="moment-images" :class="`images-${getImageCount(moment)}`">
|
||||
<wd-img
|
||||
v-for="(url, index) in parseMediaUrls(moment.media_urls)"
|
||||
:key="index"
|
||||
:src="resolveImageUrl(url)"
|
||||
:width="getImageSize(moment)"
|
||||
:height="getImageSize(moment)"
|
||||
mode="aspectFill"
|
||||
radius="12rpx"
|
||||
enable-preview
|
||||
:preview-src-list="parseMediaUrls(moment.media_urls).map(resolveImageUrl)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 视频 -->
|
||||
<view v-if="moment.media_type === 2" class="moment-video">
|
||||
<video
|
||||
:src="resolveImageUrl(parseMediaUrls(moment.media_urls)[0])"
|
||||
class="video-player"
|
||||
object-fit="cover"
|
||||
:show-fullscreen-btn="true"
|
||||
:show-play-btn="true"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 位置 -->
|
||||
<view v-if="moment.location" class="moment-location">
|
||||
<wd-icon name="location" size="24rpx" color="var(--color-primary)" />
|
||||
<text>{{ moment.location }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 互动数据 -->
|
||||
<view class="moment-stats">
|
||||
<view class="stat-item">
|
||||
<wd-icon name="heart" size="24rpx" :color="moment.is_liked ? '#ef4444' : 'var(--text-tertiary)'" />
|
||||
<text>{{ moment.like_count || 0 }}</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<wd-icon name="chat" size="24rpx" color="var(--text-tertiary)" />
|
||||
<text>{{ moment.comment_count || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<wd-loadmore :state="loadState" @loadmore="loadMore" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import * as momentApi from '@/api/modules/moment'
|
||||
import { parseMediaUrls } from '@/types/moment'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { Moment } from '@/types/moment'
|
||||
import type { User } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
user: User | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
// 状态
|
||||
const moments = ref<Moment[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const totalCount = ref(0)
|
||||
const hasMore = ref(true)
|
||||
const loadState = ref<'loading' | 'finished' | 'error'>('loading')
|
||||
|
||||
// 监听显示状态和用户变化
|
||||
watch(() => [props.modelValue, props.user], async ([show, user]) => {
|
||||
if (show && user && (user as User).id) {
|
||||
await loadMoments(true)
|
||||
} else {
|
||||
moments.value = []
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
totalCount.value = 0
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 加载用户朋友圈
|
||||
async function loadMoments(refresh = false) {
|
||||
if (!props.user?.id) return
|
||||
if (loading.value) return
|
||||
|
||||
if (refresh) {
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
if (!hasMore.value) return
|
||||
|
||||
loading.value = true
|
||||
loadState.value = 'loading'
|
||||
|
||||
try {
|
||||
const response = await momentApi.getUserMoments(props.user.id, page.value, pageSize)
|
||||
const newMoments = response.data || []
|
||||
|
||||
if (refresh) {
|
||||
moments.value = newMoments
|
||||
} else {
|
||||
moments.value = [...moments.value, ...newMoments]
|
||||
}
|
||||
|
||||
totalCount.value = response.total || moments.value.length
|
||||
hasMore.value = moments.value.length < totalCount.value
|
||||
loadState.value = hasMore.value ? 'loading' : 'finished'
|
||||
|
||||
if (!refresh && newMoments.length > 0) {
|
||||
page.value++
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load user moments:', e)
|
||||
loadState.value = 'error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
function loadMore() {
|
||||
if (!loading.value && hasMore.value) {
|
||||
page.value++
|
||||
loadMoments()
|
||||
}
|
||||
}
|
||||
|
||||
// 获取图片数量
|
||||
function getImageCount(moment: Moment): number {
|
||||
const urls = parseMediaUrls(moment.media_urls)
|
||||
return Math.min(urls.length, 9)
|
||||
}
|
||||
|
||||
// 获取图片尺寸
|
||||
function getImageSize(moment: Moment): string {
|
||||
const count = getImageCount(moment)
|
||||
if (count === 1) return '400rpx'
|
||||
if (count <= 4) return '200rpx'
|
||||
return '150rpx'
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.moments-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-content);
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 32rpx;
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(90deg, rgba(var(--color-primary-rgb), 0.1) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 40rpx;
|
||||
gap: 20rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.moments-list {
|
||||
flex: 1;
|
||||
max-height: calc(85vh - 150rpx);
|
||||
}
|
||||
|
||||
.moment-item {
|
||||
padding: 30rpx;
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
}
|
||||
|
||||
.moment-time {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 16rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.moment-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.moment-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.moment-video {
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
max-width: 500rpx;
|
||||
height: 400rpx;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.moment-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.moment-stats {
|
||||
display: flex;
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
166
src/components/moment/VisibilitySelector.vue
Normal file
166
src/components/moment/VisibilitySelector.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<wd-popup
|
||||
v-model="modelValue"
|
||||
position="bottom"
|
||||
:safe-area-inset-bottom="true"
|
||||
custom-style="border-radius: 24rpx 24rpx 0 0;"
|
||||
@close="handleClose"
|
||||
>
|
||||
<view class="visibility-selector" :class="{ dark: isDark }">
|
||||
<!-- 头部 -->
|
||||
<view class="selector-header">
|
||||
<text class="header-title">谁可以看</text>
|
||||
<wd-icon name="close" size="40rpx" color="var(--text-secondary)" @click="handleClose" />
|
||||
</view>
|
||||
|
||||
<!-- 选项列表 -->
|
||||
<view class="options-list">
|
||||
<view
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="option-item"
|
||||
:class="{ selected: visibility === option.value }"
|
||||
@click="handleSelect(option.value)"
|
||||
>
|
||||
<!-- 图标 -->
|
||||
<view class="option-icon" :class="`icon-${option.value}`">
|
||||
<wd-icon :name="option.icon" size="40rpx" :color="visibility === option.value ? '#fff' : 'var(--text-secondary)'" />
|
||||
</view>
|
||||
|
||||
<!-- 文本 -->
|
||||
<view class="option-content">
|
||||
<text class="option-label">{{ option.label }}</text>
|
||||
<text class="option-desc">{{ option.desc }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 选中标记 -->
|
||||
<view v-if="visibility === option.value" class="check-mark">
|
||||
<wd-icon name="check" size="32rpx" color="var(--color-primary)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
visibility: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'select': [visibility: number, userIds?: string[]]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const options = [
|
||||
{ value: 0, label: '公开', desc: '所有人可见', icon: 'globe' },
|
||||
{ value: 1, label: '仅好友可见', desc: '只有你的好友可以看到', icon: 'group' },
|
||||
{ value: 2, label: '部分好友可见', desc: '选择的好友可以看到', icon: 'check-circle' },
|
||||
{ value: 3, label: '部分好友不可见', desc: '选择的好友看不到', icon: 'close-circle' },
|
||||
]
|
||||
|
||||
function handleSelect(value: number) {
|
||||
// TODO: 对于部分可见/不可见,需要弹出好友选择器
|
||||
emit('select', value)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.visibility-selector {
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
.selector-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx;
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
|
||||
.header-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.options-list {
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 32rpx;
|
||||
gap: 24rpx;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: rgba(var(--color-primary-rgb), 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-hover);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
.selected & {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
&.icon-0 { .selected & { background: #10b981; } }
|
||||
&.icon-1 { .selected & { background: #3b82f6; } }
|
||||
&.icon-2 { .selected & { background: #8b5cf6; } }
|
||||
&.icon-3 { .selected & { background: #f97316; } }
|
||||
}
|
||||
|
||||
.option-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.check-mark {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
export { useTheme } from './useTheme'
|
||||
export { useWebRTC } from './useWebRTC'
|
||||
export { useGroupWebRTC } from './useGroupWebRTC'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
484
src/composables/useGroupWebRTC.ts
Normal file
484
src/composables/useGroupWebRTC.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { reactive, shallowRef, ref, computed } from 'vue'
|
||||
import * as messageApi from '@/api/modules/message'
|
||||
import { wsManager } from '@/api/websocket'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ChatMessage } from '@/types/api'
|
||||
|
||||
// --- 类型定义 ---
|
||||
export interface CallParticipant {
|
||||
userId: string
|
||||
name: string
|
||||
avatar: string
|
||||
stream?: MediaStream
|
||||
isMuted: boolean
|
||||
isCamOff: boolean
|
||||
status: 'connecting' | 'connected' | 'failed' | 'rejected'
|
||||
volume?: number
|
||||
}
|
||||
|
||||
export interface GroupCallState {
|
||||
incoming: boolean
|
||||
joined: boolean
|
||||
minimized: boolean
|
||||
roomId: string
|
||||
groupId: string
|
||||
initiatorId: string
|
||||
inviterName: string
|
||||
inviterAvatar?: string
|
||||
type: 'audio' | 'video'
|
||||
duration: number
|
||||
startTime: number | null
|
||||
isSelfMuted: boolean
|
||||
isSelfCamOff: boolean
|
||||
}
|
||||
|
||||
interface SignalPayload {
|
||||
action: 'invite' | 'join' | 'offer' | 'answer' | 'candidate' | 'leave' | 'sync_state' | 'reject'
|
||||
callRoomId: string
|
||||
senderId: string
|
||||
senderName?: string
|
||||
senderAvatar?: string
|
||||
targetId?: string
|
||||
data?: any
|
||||
state?: { muted: boolean; camOff: boolean }
|
||||
participantIds?: string[]
|
||||
type?: 'audio' | 'video'
|
||||
}
|
||||
|
||||
// --- 全局单例状态 ---
|
||||
const callState = reactive<GroupCallState>({
|
||||
incoming: false,
|
||||
joined: false,
|
||||
minimized: false,
|
||||
roomId: '',
|
||||
groupId: '',
|
||||
initiatorId: '',
|
||||
inviterName: '',
|
||||
type: 'video',
|
||||
duration: 0,
|
||||
startTime: null,
|
||||
isSelfMuted: false,
|
||||
isSelfCamOff: false
|
||||
})
|
||||
|
||||
const localStream = shallowRef<MediaStream | null>(null)
|
||||
const participants = ref<CallParticipant[]>([])
|
||||
const peerConnections = new Map<string, RTCPeerConnection>()
|
||||
|
||||
// Perfect Negotiation 状态控制
|
||||
const makingOffer = new Map<string, boolean>()
|
||||
const ignoreOffer = new Map<string, boolean>()
|
||||
const pendingCandidates = new Map<string, RTCIceCandidateInit[]>()
|
||||
|
||||
let durationTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const iceServers = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
]
|
||||
}
|
||||
|
||||
export function useGroupWebRTC() {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isActive = computed(() => callState.joined || callState.incoming)
|
||||
|
||||
function initListener() {
|
||||
wsManager.offSignal(handleSignalMessage)
|
||||
wsManager.onSignal(handleSignalMessage)
|
||||
}
|
||||
|
||||
// --- 状态重置 ---
|
||||
function resetState() {
|
||||
stopTimer()
|
||||
peerConnections.forEach(pc => pc.close())
|
||||
peerConnections.clear()
|
||||
makingOffer.clear()
|
||||
ignoreOffer.clear()
|
||||
pendingCandidates.clear()
|
||||
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach(t => t.stop())
|
||||
localStream.value = null
|
||||
}
|
||||
participants.value = []
|
||||
|
||||
callState.incoming = false
|
||||
callState.joined = false
|
||||
callState.minimized = false
|
||||
callState.roomId = ''
|
||||
callState.groupId = ''
|
||||
callState.duration = 0
|
||||
callState.initiatorId = ''
|
||||
callState.inviterName = ''
|
||||
}
|
||||
|
||||
async function initLocalMedia(videoEnabled: boolean) {
|
||||
try {
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach(t => t.stop())
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: videoEnabled ? { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' } : false,
|
||||
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
||||
})
|
||||
localStream.value = stream
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
// APP端需要使用原生插件或其他方式获取媒体流
|
||||
uni.showToast({ title: 'APP端群通话暂不支持', icon: 'none' })
|
||||
throw new Error('APP端暂不支持群通话')
|
||||
// #endif
|
||||
|
||||
callState.isSelfMuted = false
|
||||
callState.isSelfCamOff = false
|
||||
return localStream.value
|
||||
} catch (error) {
|
||||
console.error('获取媒体失败', error)
|
||||
uni.showToast({ title: '无法获取摄像头或麦克风权限', icon: 'none' })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// --- 核心信令处理 ---
|
||||
async function handleSignalMessage(msg: ChatMessage) {
|
||||
const myId = authStore.user?.id
|
||||
if (!myId) return
|
||||
|
||||
try {
|
||||
const content: SignalPayload = JSON.parse(msg.content || '{}')
|
||||
if (content.senderId === myId) return
|
||||
|
||||
// 1. 处理邀请 (Invite)
|
||||
if (content.action === 'invite') {
|
||||
if (!content.participantIds || !content.callRoomId) return
|
||||
if (callState.joined) return // 忙线自动忽略
|
||||
|
||||
callState.incoming = true
|
||||
callState.roomId = content.callRoomId
|
||||
callState.groupId = msg.room_id || ''
|
||||
callState.type = content.type || 'video'
|
||||
callState.initiatorId = content.senderId
|
||||
callState.inviterName = content.senderName || '群成员'
|
||||
callState.inviterAvatar = content.senderAvatar
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Banner 状态更新
|
||||
if (content.action === 'join' && msg.room_id) {
|
||||
if (callState.roomId && content.callRoomId !== callState.roomId) return
|
||||
if (!callState.joined && !callState.incoming) {
|
||||
callState.roomId = content.callRoomId
|
||||
callState.groupId = msg.room_id
|
||||
}
|
||||
}
|
||||
|
||||
if (content.callRoomId !== callState.roomId) return
|
||||
|
||||
switch (content.action) {
|
||||
case 'join':
|
||||
addParticipant(content.senderId, content.senderName || '成员', content.senderAvatar || '', 'connecting')
|
||||
if (callState.joined) {
|
||||
getPeerConnection(content.senderId)
|
||||
}
|
||||
break
|
||||
|
||||
case 'offer':
|
||||
if (content.targetId === myId && callState.joined) {
|
||||
addParticipant(content.senderId, content.senderName || '成员', content.senderAvatar || '', 'connecting')
|
||||
await handleOffer(content.senderId, content.data)
|
||||
}
|
||||
break
|
||||
|
||||
case 'answer':
|
||||
if (content.targetId === myId && callState.joined) {
|
||||
await handleAnswer(content.senderId, content.data)
|
||||
}
|
||||
break
|
||||
|
||||
case 'candidate':
|
||||
if (content.targetId === myId && callState.joined) {
|
||||
await handleCandidate(content.senderId, content.data)
|
||||
}
|
||||
break
|
||||
|
||||
case 'leave':
|
||||
removeParticipant(content.senderId)
|
||||
if (participants.value.length === 0 && !callState.joined) {
|
||||
callState.roomId = ''
|
||||
}
|
||||
break
|
||||
|
||||
case 'reject':
|
||||
const pIndex = participants.value.findIndex(p => p.userId === content.senderId)
|
||||
if (pIndex !== -1) {
|
||||
participants.value[pIndex].status = 'rejected'
|
||||
uni.showToast({ title: `${content.senderName || '对方'} 拒绝了通话`, icon: 'none' })
|
||||
const pc = peerConnections.get(content.senderId)
|
||||
if (pc) { pc.close(); peerConnections.delete(content.senderId) }
|
||||
}
|
||||
break
|
||||
|
||||
case 'sync_state':
|
||||
updateParticipantState(content.senderId, content.state)
|
||||
break
|
||||
}
|
||||
} catch (e) { console.error('Signal error', e) }
|
||||
}
|
||||
|
||||
// --- WebRTC 连接管理 (Perfect Negotiation) ---
|
||||
function getPeerConnection(targetUserId: string): RTCPeerConnection {
|
||||
if (peerConnections.has(targetUserId)) return peerConnections.get(targetUserId)!
|
||||
|
||||
const pc = new RTCPeerConnection(iceServers)
|
||||
makingOffer.set(targetUserId, false)
|
||||
ignoreOffer.set(targetUserId, false)
|
||||
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach(t => pc.addTrack(t, localStream.value!))
|
||||
}
|
||||
|
||||
pc.onnegotiationneeded = async () => {
|
||||
try {
|
||||
makingOffer.set(targetUserId, true)
|
||||
await pc.setLocalDescription()
|
||||
await sendSignal('offer', { targetId: targetUserId, data: pc.localDescription })
|
||||
} catch (err) {
|
||||
console.error('Negotiation failed:', err)
|
||||
} finally {
|
||||
makingOffer.set(targetUserId, false)
|
||||
}
|
||||
}
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) sendSignal('candidate', { targetId: targetUserId, data: event.candidate })
|
||||
}
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
if (event.streams[0]) updateParticipantStream(targetUserId, event.streams[0])
|
||||
}
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (pc.connectionState === 'connected') updateParticipantStatus(targetUserId, 'connected')
|
||||
else if (pc.connectionState === 'failed') updateParticipantStatus(targetUserId, 'failed')
|
||||
}
|
||||
|
||||
peerConnections.set(targetUserId, pc)
|
||||
return pc
|
||||
}
|
||||
|
||||
async function handleOffer(senderId: string, offerSdp: RTCSessionDescriptionInit) {
|
||||
const pc = getPeerConnection(senderId)
|
||||
const myId = authStore.user?.id || ''
|
||||
const polite = myId < senderId
|
||||
const offerCollision = (makingOffer.get(senderId) || pc.signalingState !== 'stable')
|
||||
|
||||
ignoreOffer.set(senderId, !polite && offerCollision)
|
||||
if (ignoreOffer.get(senderId)) return
|
||||
|
||||
if (offerCollision) {
|
||||
await pc.setLocalDescription({ type: 'rollback' })
|
||||
}
|
||||
|
||||
try {
|
||||
await pc.setRemoteDescription(offerSdp)
|
||||
await pc.setLocalDescription()
|
||||
await sendSignal('answer', { targetId: senderId, data: pc.localDescription })
|
||||
await flushPendingCandidates(senderId, pc)
|
||||
} catch (e) { console.error('Offer error', e) }
|
||||
}
|
||||
|
||||
async function handleAnswer(senderId: string, answerSdp: RTCSessionDescriptionInit) {
|
||||
const pc = getPeerConnection(senderId)
|
||||
if (ignoreOffer.get(senderId)) return
|
||||
try {
|
||||
if (pc.signalingState === 'have-local-offer') {
|
||||
await pc.setRemoteDescription(answerSdp)
|
||||
await flushPendingCandidates(senderId, pc)
|
||||
}
|
||||
} catch (e) { console.error('Answer error', e) }
|
||||
}
|
||||
|
||||
async function handleCandidate(senderId: string, candidate: RTCIceCandidateInit) {
|
||||
const pc = getPeerConnection(senderId)
|
||||
if (ignoreOffer.get(senderId)) return
|
||||
try {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(candidate))
|
||||
} catch (e) {
|
||||
if (!pc.remoteDescription) {
|
||||
if (!pendingCandidates.has(senderId)) pendingCandidates.set(senderId, [])
|
||||
pendingCandidates.get(senderId)?.push(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function flushPendingCandidates(userId: string, pc: RTCPeerConnection) {
|
||||
const candidates = pendingCandidates.get(userId) || []
|
||||
for (const c of candidates) await pc.addIceCandidate(c).catch(() => {})
|
||||
pendingCandidates.delete(userId)
|
||||
}
|
||||
|
||||
async function sendSignal(action: SignalPayload['action'], payload: Partial<SignalPayload>) {
|
||||
const myId = authStore.user?.id
|
||||
if (!myId || !callState.groupId) return
|
||||
|
||||
const fullPayload: SignalPayload = {
|
||||
action,
|
||||
callRoomId: callState.roomId,
|
||||
senderId: myId,
|
||||
senderName: authStore.user?.name || '我',
|
||||
senderAvatar: authStore.user?.avatar || '',
|
||||
...payload
|
||||
}
|
||||
|
||||
let receiverId = ''
|
||||
if (['offer', 'answer', 'candidate'].includes(action)) receiverId = payload.targetId || ''
|
||||
|
||||
const message = {
|
||||
room_id: callState.groupId,
|
||||
receiver_user_id: receiverId,
|
||||
message_type: 6,
|
||||
content: JSON.stringify(fullPayload),
|
||||
call_status: action as any,
|
||||
}
|
||||
await messageApi.sendMessage(message)
|
||||
}
|
||||
|
||||
// --- 操作方法 ---
|
||||
async function startGroupCall(groupId: string, selectedUserIds: string[], type: 'audio' | 'video') {
|
||||
resetState()
|
||||
|
||||
callState.groupId = groupId
|
||||
callState.type = type
|
||||
callState.roomId = `group_call_${groupId}_${Date.now()}`
|
||||
callState.initiatorId = authStore.user?.id || ''
|
||||
callState.minimized = false
|
||||
callState.joined = true
|
||||
|
||||
try {
|
||||
await initLocalMedia(type === 'video')
|
||||
initListener()
|
||||
startTimer()
|
||||
|
||||
await sendSignal('invite', { participantIds: selectedUserIds, type })
|
||||
|
||||
selectedUserIds.forEach(uid => {
|
||||
if (uid !== callState.initiatorId) addParticipant(uid, '呼叫中...', '', 'connecting')
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Start call error', e)
|
||||
if (e instanceof Error && (e.name === 'NotAllowedError' || e.name === 'NotFoundError')) {
|
||||
uni.showToast({ title: '无法启动通话:请检查权限', icon: 'none' })
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptInvite() {
|
||||
callState.incoming = false
|
||||
callState.joined = true
|
||||
try {
|
||||
await initLocalMedia(callState.type === 'video')
|
||||
startTimer()
|
||||
await sendSignal('join', {})
|
||||
} catch (e) {
|
||||
console.error('Accept call error', e)
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
|
||||
function rejectInvite() {
|
||||
sendSignal('reject', {})
|
||||
resetState()
|
||||
}
|
||||
|
||||
async function joinCurrentCall() {
|
||||
if (!callState.roomId) return
|
||||
await acceptInvite()
|
||||
}
|
||||
|
||||
function leaveCall() {
|
||||
if (callState.joined) sendSignal('leave', {})
|
||||
resetState()
|
||||
}
|
||||
|
||||
// --- 辅助函数 ---
|
||||
function addParticipant(userId: string, name: string, avatar: string, status: CallParticipant['status']) {
|
||||
const idx = participants.value.findIndex(p => p.userId === userId)
|
||||
if (idx === -1) participants.value.push({ userId, name, avatar, status, isMuted: false, isCamOff: false })
|
||||
else participants.value[idx].status = status
|
||||
}
|
||||
|
||||
function removeParticipant(userId: string) {
|
||||
const idx = participants.value.findIndex(p => p.userId === userId)
|
||||
if (idx > -1) participants.value.splice(idx, 1)
|
||||
const pc = peerConnections.get(userId)
|
||||
if (pc) { pc.close(); peerConnections.delete(userId) }
|
||||
}
|
||||
|
||||
function updateParticipantStream(userId: string, stream: MediaStream) {
|
||||
const p = participants.value.find(p => p.userId === userId)
|
||||
if (p) { p.stream = stream; p.status = 'connected' }
|
||||
}
|
||||
|
||||
function updateParticipantStatus(userId: string, status: CallParticipant['status']) {
|
||||
const p = participants.value.find(p => p.userId === userId)
|
||||
if (p) p.status = status
|
||||
}
|
||||
|
||||
function updateParticipantState(userId: string, state: any) {
|
||||
if (!state) return
|
||||
const p = participants.value.find(p => p.userId === userId)
|
||||
if (p) { p.isMuted = state.muted; p.isCamOff = state.camOff }
|
||||
}
|
||||
|
||||
function toggleSelfMute() {
|
||||
callState.isSelfMuted = !callState.isSelfMuted
|
||||
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !callState.isSelfMuted)
|
||||
sendSignal('sync_state', { state: { muted: callState.isSelfMuted, camOff: callState.isSelfCamOff } })
|
||||
}
|
||||
|
||||
function toggleSelfCamera() {
|
||||
callState.isSelfCamOff = !callState.isSelfCamOff
|
||||
if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !callState.isSelfCamOff)
|
||||
sendSignal('sync_state', { state: { muted: callState.isSelfMuted, camOff: callState.isSelfCamOff } })
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
callState.startTime = Date.now()
|
||||
durationTimer = setInterval(() => {
|
||||
if (callState.startTime) callState.duration = Math.floor((Date.now() - callState.startTime) / 1000)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (durationTimer) clearInterval(durationTimer)
|
||||
durationTimer = null
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
|
||||
const s = (seconds % 60).toString().padStart(2, '0')
|
||||
return `${m}:${s}`
|
||||
}
|
||||
|
||||
return {
|
||||
callState,
|
||||
localStream,
|
||||
participants,
|
||||
isActive,
|
||||
startGroupCall,
|
||||
acceptInvite,
|
||||
rejectInvite,
|
||||
joinCurrentCall,
|
||||
leaveCall,
|
||||
toggleSelfMute,
|
||||
toggleSelfCamera,
|
||||
formatDuration,
|
||||
initListener
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* 主题切换组合式函数
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const isDark = ref(false)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
// 用于 wd-config-provider 的 theme 属性
|
||||
const theme = computed<'light' | 'dark'>(() => isDark.value ? 'dark' : 'light')
|
||||
|
||||
export function useTheme() {
|
||||
/**
|
||||
* 初始化主题
|
||||
@@ -52,11 +55,24 @@ export function useTheme() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置主题
|
||||
* 主题切换回调(用于 wd-switch @change)
|
||||
*/
|
||||
function setTheme(dark: boolean) {
|
||||
function onThemeChange(value: { value: boolean }) {
|
||||
isDark.value = value.value
|
||||
uni.setStorageSync('nl_im_theme', value.value ? 'dark' : 'light')
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置主题
|
||||
* @param dark 是否深色模式
|
||||
* @param persist 是否持久化到存储(默认 true)
|
||||
*/
|
||||
function setTheme(dark: boolean, persist = true) {
|
||||
isDark.value = dark
|
||||
if (persist) {
|
||||
uni.setStorageSync('nl_im_theme', dark ? 'dark' : 'light')
|
||||
}
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
@@ -64,18 +80,35 @@ export function useTheme() {
|
||||
* 应用主题
|
||||
*/
|
||||
function applyTheme() {
|
||||
// H5 端设置 html class
|
||||
// #ifdef H5
|
||||
if (typeof document !== 'undefined') {
|
||||
// H5 端设置 page 元素 class(不使用条件编译,直接检测环境)
|
||||
if (typeof document !== 'undefined' && typeof window !== 'undefined') {
|
||||
// 同时设置 html 和所有 page 元素
|
||||
if (isDark.value) {
|
||||
document.documentElement.classList.add('dark')
|
||||
// UniApp H5 中 page 是 uni-page-body 的父元素
|
||||
document.querySelectorAll('uni-page-body').forEach((el) => {
|
||||
el.parentElement?.classList.add('dark')
|
||||
})
|
||||
// 查找所有可能的 page 元素
|
||||
document.querySelectorAll('uni-page, .uni-page').forEach((el) => {
|
||||
el.classList.add('dark')
|
||||
})
|
||||
// 直接给 body 也加上,确保全局生效
|
||||
document.body?.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
document.querySelectorAll('uni-page-body').forEach((el) => {
|
||||
el.parentElement?.classList.remove('dark')
|
||||
})
|
||||
document.querySelectorAll('uni-page, .uni-page').forEach((el) => {
|
||||
el.classList.remove('dark')
|
||||
})
|
||||
document.body?.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 设置导航栏颜色
|
||||
// 设置导航栏颜色(延迟执行,确保页面已加载)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
uni.setNavigationBarColor({
|
||||
frontColor: isDark.value ? '#ffffff' : '#000000',
|
||||
@@ -83,31 +116,49 @@ export function useTheme() {
|
||||
animation: {
|
||||
duration: 200,
|
||||
timingFunc: 'easeIn'
|
||||
},
|
||||
fail: () => {
|
||||
// 静默忽略 "page not found" 错误
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// 设置 TabBar 样式
|
||||
// 设置 TabBar 样式(延迟执行,确保页面已加载)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
uni.setTabBarStyle({
|
||||
color: '#999999',
|
||||
selectedColor: '#07c160',
|
||||
backgroundColor: isDark.value ? '#1a1a1a' : '#f7f7f7',
|
||||
borderStyle: isDark.value ? 'black' : 'white'
|
||||
borderStyle: isDark.value ? 'black' : 'white',
|
||||
fail: () => {
|
||||
// 静默忽略 "not TabBar page" 错误
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
isDark,
|
||||
initTheme,
|
||||
toggleTheme,
|
||||
onThemeChange,
|
||||
setTheme
|
||||
}
|
||||
}
|
||||
|
||||
export default useTheme
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
453
src/composables/useWebRTC.ts
Normal file
453
src/composables/useWebRTC.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* WebRTC 通话组合式函数 - UniApp 适配版
|
||||
* 主要支持 H5 平台,APP/小程序需要平台特定实现
|
||||
*/
|
||||
import { reactive, shallowRef, ref } from 'vue'
|
||||
import * as messageApi from '@/api/modules/message'
|
||||
import { wsManager } from '@/api/websocket'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import type { ChatMessage, Contact } from '@/types/api'
|
||||
import type { CallStatus } from '@/types/message'
|
||||
|
||||
export interface CallState {
|
||||
active: boolean
|
||||
minimized: boolean
|
||||
type: 'audio' | 'video'
|
||||
status: 'idle' | 'outgoing' | 'incoming' | 'connected'
|
||||
statusText: string
|
||||
id: string | null
|
||||
muted: boolean
|
||||
remoteMuted: boolean
|
||||
camOff: boolean
|
||||
remoteCamOff: boolean
|
||||
duration: number
|
||||
startTime: number | null
|
||||
callerName?: string
|
||||
callerAvatar?: string
|
||||
}
|
||||
|
||||
export function useWebRTC(
|
||||
userId: string,
|
||||
onIncomingCall?: (senderUserId: string) => void,
|
||||
getRoomId?: (receiverUserId?: string) => string
|
||||
) {
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const call = reactive<CallState>({
|
||||
active: false,
|
||||
minimized: false,
|
||||
type: 'video',
|
||||
status: 'idle',
|
||||
statusText: '',
|
||||
id: null,
|
||||
muted: false,
|
||||
remoteMuted: false,
|
||||
camOff: false,
|
||||
remoteCamOff: false,
|
||||
duration: 0,
|
||||
startTime: null,
|
||||
})
|
||||
|
||||
const isCaller = ref(false)
|
||||
const localStream = shallowRef<MediaStream | null>(null)
|
||||
const remoteStream = shallowRef<MediaStream | null>(null)
|
||||
let durationTimer: ReturnType<typeof setInterval> | null = null
|
||||
let pc: RTCPeerConnection | null = null
|
||||
const pendingCandidates: RTCIceCandidate[] = []
|
||||
let currentReceiverUserId = ''
|
||||
let currentRoomId = ''
|
||||
|
||||
// 音频播放(使用内置音频方法)
|
||||
let audioContext: UniApp.InnerAudioContext | null = null
|
||||
|
||||
function playRingtone() {
|
||||
stopRingtone()
|
||||
// #ifdef H5
|
||||
// H5 使用简单的提示音
|
||||
try {
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext
|
||||
const ctx = new AudioContext()
|
||||
const oscillator = ctx.createOscillator()
|
||||
oscillator.type = 'sine'
|
||||
oscillator.frequency.value = 440
|
||||
oscillator.connect(ctx.destination)
|
||||
oscillator.start()
|
||||
setTimeout(() => oscillator.stop(), 200)
|
||||
} catch (e) {}
|
||||
// #endif
|
||||
}
|
||||
|
||||
function stopRingtone() {
|
||||
if (audioContext) {
|
||||
audioContext.stop()
|
||||
audioContext.destroy()
|
||||
audioContext = null
|
||||
}
|
||||
}
|
||||
|
||||
function getSafeRoomId(targetUserId?: string): string | null {
|
||||
if (currentRoomId) return currentRoomId
|
||||
if (targetUserId && getRoomId) {
|
||||
const rid = getRoomId(targetUserId)
|
||||
if (rid) return rid
|
||||
}
|
||||
if (targetUserId && userId) {
|
||||
return [userId, targetUserId].sort().join('_')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 信令处理
|
||||
async function handleSignaling(message: ChatMessage) {
|
||||
try {
|
||||
const content = message.content ? JSON.parse(message.content) : {}
|
||||
|
||||
// 忽略群聊信令
|
||||
if (content.callRoomId || content.participantIds) {
|
||||
return
|
||||
}
|
||||
|
||||
const signal = message.call_status as any
|
||||
const extra = message.extra ? (typeof message.extra === 'string' ? JSON.parse(message.extra) : message.extra) : {}
|
||||
|
||||
if (extra.type) call.type = extra.type
|
||||
|
||||
if (signal === 'sync_state') {
|
||||
if (content.action === 'cam-toggle') call.remoteCamOff = content.value
|
||||
else if (content.action === 'mic-toggle') call.remoteMuted = content.value
|
||||
return
|
||||
}
|
||||
|
||||
if (signal === 'invite') {
|
||||
if (call.active) return
|
||||
isCaller.value = false
|
||||
currentReceiverUserId = message.sender_user_id
|
||||
if (message.room_id) currentRoomId = message.room_id
|
||||
call.id = message.call_id
|
||||
call.active = true
|
||||
call.minimized = false
|
||||
call.status = 'incoming'
|
||||
call.statusText = `邀请你${call.type === 'video' ? '视频' : '语音'}通话`
|
||||
|
||||
// 获取来电者信息
|
||||
const contact = chatStore.contacts.find(c => c.contact_user_id === message.sender_user_id)
|
||||
if (contact?.user) {
|
||||
call.callerName = contact.remark_name || contact.user.name
|
||||
call.callerAvatar = contact.user.avatar
|
||||
}
|
||||
|
||||
playRingtone()
|
||||
if (onIncomingCall) onIncomingCall(message.sender_user_id)
|
||||
|
||||
} else if (signal === 'accepted') {
|
||||
stopRingtone()
|
||||
call.status = 'connected'
|
||||
call.statusText = '通话中'
|
||||
startCallTimer()
|
||||
if (!pc) await createPC()
|
||||
const offer = await pc!.createOffer()
|
||||
await pc!.setLocalDescription(offer)
|
||||
sendSignal('offer', offer)
|
||||
|
||||
} else if (signal === 'offer') {
|
||||
stopRingtone()
|
||||
if (!pc) {
|
||||
await initMedia(call.type === 'video')
|
||||
await createPC()
|
||||
}
|
||||
await pc!.setRemoteDescription(content)
|
||||
processPendingCandidates()
|
||||
const answer = await pc!.createAnswer()
|
||||
await pc!.setLocalDescription(answer)
|
||||
sendSignal('answer', answer, message.sender_user_id)
|
||||
call.status = 'connected'
|
||||
call.statusText = '通话中'
|
||||
startCallTimer()
|
||||
|
||||
} else if (signal === 'answer') {
|
||||
if (pc) {
|
||||
await pc.setRemoteDescription(content)
|
||||
processPendingCandidates()
|
||||
}
|
||||
|
||||
} else if (signal === 'candidate') {
|
||||
if (pc && pc.remoteDescription) await pc.addIceCandidate(content)
|
||||
else pendingCandidates.push(content)
|
||||
|
||||
} else if (['hangup', 'ended', 'answered_elsewhere', 'reject'].includes(signal)) {
|
||||
closeCall()
|
||||
if (signal === 'answered_elsewhere') {
|
||||
uni.showToast({ title: '已在其他设备接听', icon: 'none' })
|
||||
} else if (signal === 'reject') {
|
||||
uni.showToast({ title: '对方已拒绝', icon: 'none' })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling signaling:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化媒体设备
|
||||
async function initMedia(videoEnabled: boolean): Promise<void> {
|
||||
// #ifdef H5
|
||||
try {
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach(t => t.stop())
|
||||
localStream.value = null
|
||||
}
|
||||
const constraints: MediaStreamConstraints = {
|
||||
video: videoEnabled ? { width: { ideal: 640 }, height: { ideal: 480 } } : false,
|
||||
audio: { echoCancellation: true, noiseSuppression: true },
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
localStream.value = stream
|
||||
} catch (error: any) {
|
||||
throw new Error('无法获取设备权限')
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
throw new Error('当前平台暂不支持音视频通话')
|
||||
// #endif
|
||||
}
|
||||
|
||||
// 创建 RTCPeerConnection
|
||||
async function createPC(): Promise<void> {
|
||||
// #ifdef H5
|
||||
const servers = [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
if (pc) { pc.close(); pc = null }
|
||||
pc = new RTCPeerConnection({ iceServers: servers })
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc?.iceConnectionState === 'failed') {
|
||||
call.statusText = '连接失败'
|
||||
endCall()
|
||||
} else if (pc?.iceConnectionState === 'connected') {
|
||||
call.statusText = '通话中'
|
||||
}
|
||||
}
|
||||
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach((track) => pc!.addTrack(track, localStream.value!))
|
||||
}
|
||||
|
||||
pc.ontrack = (e) => {
|
||||
if (e.streams && e.streams[0]) {
|
||||
remoteStream.value = e.streams[0]
|
||||
}
|
||||
}
|
||||
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate && call.id) sendSignal('candidate', e.candidate)
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
// 发送信令
|
||||
function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) {
|
||||
if (!call.id) return
|
||||
const targetUserId = receiverUserId || currentReceiverUserId
|
||||
const roomId = getSafeRoomId(targetUserId)
|
||||
if (!roomId) return
|
||||
const payload = {
|
||||
sender_client_id: wsManager.getClientId() || '',
|
||||
receiver_user_id: targetUserId,
|
||||
room_id: roomId,
|
||||
message_type: 6,
|
||||
content: JSON.stringify(data || {}),
|
||||
call_id: call.id,
|
||||
call_status: status,
|
||||
extra: JSON.stringify({ type: call.type }),
|
||||
}
|
||||
messageApi.sendMessage(payload).catch(console.error)
|
||||
}
|
||||
|
||||
// 发送状态同步
|
||||
function sendSyncState(type: 'cam-toggle' | 'mic-toggle', value: boolean) {
|
||||
if (call.status !== 'connected') return
|
||||
const roomId = getSafeRoomId(currentReceiverUserId)
|
||||
if (!roomId) return
|
||||
const payload = {
|
||||
sender_client_id: wsManager.getClientId() || '',
|
||||
receiver_user_id: currentReceiverUserId,
|
||||
room_id: roomId,
|
||||
message_type: 6,
|
||||
content: JSON.stringify({ action: type, value }),
|
||||
call_id: call.id,
|
||||
call_status: 'sync_state' as any,
|
||||
extra: JSON.stringify({ type: call.type })
|
||||
}
|
||||
messageApi.sendMessage(payload).catch(console.error)
|
||||
}
|
||||
|
||||
// 发起通话
|
||||
async function startCall(type: 'audio' | 'video', receiverUserId: string, roomId?: string, contact?: Contact) {
|
||||
if (roomId) currentRoomId = roomId
|
||||
else if (contact?.room_id) currentRoomId = contact.room_id
|
||||
else if (getRoomId) {
|
||||
const found = getRoomId(receiverUserId)
|
||||
if (found) currentRoomId = found
|
||||
}
|
||||
|
||||
if (!currentRoomId) {
|
||||
uni.showToast({ title: '无法建立通话连接', icon: 'none' })
|
||||
return false
|
||||
}
|
||||
|
||||
isCaller.value = true
|
||||
currentReceiverUserId = receiverUserId
|
||||
call.type = type
|
||||
call.id = Date.now().toString()
|
||||
call.active = true
|
||||
call.minimized = false
|
||||
call.status = 'outgoing'
|
||||
call.statusText = '正在呼叫...'
|
||||
call.duration = 0
|
||||
call.camOff = false
|
||||
call.remoteCamOff = false
|
||||
call.remoteMuted = false
|
||||
remoteStream.value = null
|
||||
|
||||
try {
|
||||
await initMedia(type === 'video')
|
||||
await createPC()
|
||||
playRingtone()
|
||||
sendSignal('invite', undefined, receiverUserId)
|
||||
return true
|
||||
} catch (error: any) {
|
||||
uni.showToast({ title: error.message || '无法启动通话', icon: 'none' })
|
||||
closeCall()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 接听来电
|
||||
async function acceptCall(senderUserId?: string) {
|
||||
isCaller.value = false
|
||||
stopRingtone()
|
||||
if (senderUserId) currentReceiverUserId = senderUserId
|
||||
call.status = 'connected'
|
||||
call.statusText = '正在连接...'
|
||||
call.camOff = false
|
||||
call.remoteCamOff = false
|
||||
|
||||
try {
|
||||
await initMedia(call.type === 'video')
|
||||
await createPC()
|
||||
sendSignal('accepted', undefined, currentReceiverUserId)
|
||||
} catch (error: any) {
|
||||
uni.showToast({ title: error.message || '接听失败', icon: 'none' })
|
||||
endCall()
|
||||
}
|
||||
}
|
||||
|
||||
// 拒绝来电
|
||||
function rejectCall() {
|
||||
stopRingtone()
|
||||
sendSignal('reject')
|
||||
closeCall()
|
||||
}
|
||||
|
||||
// 结束通话
|
||||
function endCall() {
|
||||
stopRingtone()
|
||||
sendSignal('hangup')
|
||||
closeCall()
|
||||
}
|
||||
|
||||
// 关闭通话(清理资源)
|
||||
function closeCall() {
|
||||
stopRingtone()
|
||||
currentRoomId = ''
|
||||
call.active = false
|
||||
call.status = 'idle'
|
||||
call.statusText = ''
|
||||
call.id = null
|
||||
call.callerName = undefined
|
||||
call.callerAvatar = undefined
|
||||
stopCallTimer()
|
||||
|
||||
// #ifdef H5
|
||||
if (pc) {
|
||||
pc.close()
|
||||
pc = null
|
||||
}
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach(t => t.stop())
|
||||
localStream.value = null
|
||||
}
|
||||
remoteStream.value = null
|
||||
// #endif
|
||||
}
|
||||
|
||||
async function processPendingCandidates() {
|
||||
while (pendingCandidates.length > 0) {
|
||||
const c = pendingCandidates.shift()
|
||||
if (c && pc) await pc.addIceCandidate(c).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// 通话计时
|
||||
function startCallTimer() {
|
||||
stopCallTimer()
|
||||
call.startTime = Date.now()
|
||||
durationTimer = setInterval(() => {
|
||||
if (call.startTime) call.duration = Math.floor((Date.now() - call.startTime) / 1000)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopCallTimer() {
|
||||
if (durationTimer) { clearInterval(durationTimer); durationTimer = null }
|
||||
call.startTime = null
|
||||
call.duration = 0
|
||||
}
|
||||
|
||||
// 格式化通话时长
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
|
||||
const s = (seconds % 60).toString().padStart(2, '0')
|
||||
const h = Math.floor(seconds / 3600)
|
||||
return h > 0 ? `${h.toString().padStart(2, '0')}:${m}:${s}` : `${m}:${s}`
|
||||
}
|
||||
|
||||
// 切换静音
|
||||
function toggleMute() {
|
||||
call.muted = !call.muted
|
||||
sendSyncState('mic-toggle', call.muted)
|
||||
// #ifdef H5
|
||||
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted)
|
||||
// #endif
|
||||
}
|
||||
|
||||
// 切换摄像头
|
||||
function toggleCamera() {
|
||||
call.camOff = !call.camOff
|
||||
sendSyncState('cam-toggle', call.camOff)
|
||||
// #ifdef H5
|
||||
if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff)
|
||||
// #endif
|
||||
}
|
||||
|
||||
// 最小化/恢复窗口
|
||||
function toggleMinimize() {
|
||||
call.minimized = !call.minimized
|
||||
}
|
||||
|
||||
return {
|
||||
call,
|
||||
localStream,
|
||||
remoteStream,
|
||||
startCall,
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
endCall,
|
||||
handleSignaling,
|
||||
toggleMute,
|
||||
toggleCamera,
|
||||
toggleMinimize,
|
||||
formatDuration
|
||||
}
|
||||
}
|
||||
|
||||
export default useWebRTC
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^wd-(.*)": "wot-design-uni/components/wd-$1/wd-$1.vue"
|
||||
"^wd-(.*)": "wot-design-uni/components/wd-$1/wd-$1.vue",
|
||||
"^app-nav-bar$": "@/components/common/AppNavBar.vue",
|
||||
"^app-tab-bar$": "@/components/common/AppTabBar.vue",
|
||||
"^app-avatar$": "@/components/common/AppAvatar.vue"
|
||||
}
|
||||
},
|
||||
"pages": [
|
||||
@@ -16,6 +19,7 @@
|
||||
{
|
||||
"path": "pages/chat/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "聊天",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
@@ -30,24 +34,28 @@
|
||||
{
|
||||
"path": "pages/contact/detail",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "好友详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/add",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "添加好友"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/requests",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "新朋友"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/groups",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "群聊"
|
||||
}
|
||||
},
|
||||
@@ -61,45 +69,59 @@
|
||||
{
|
||||
"path": "pages/moment/detail",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "动态详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/moment/publish",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "发布动态"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/moment/notify",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "消息通知"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "个人资料"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/edit",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "编辑资料"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/settings/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "设置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/settings/theme",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "深色模式"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/search/index",
|
||||
"style": {
|
||||
@@ -110,24 +132,28 @@
|
||||
{
|
||||
"path": "pages/group/info",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "群信息"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/create",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "创建群聊"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/members",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "群成员"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/notify",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "群通知"
|
||||
}
|
||||
}
|
||||
@@ -139,31 +165,5 @@
|
||||
"backgroundColor": "#ededed",
|
||||
"backgroundTextStyle": "dark"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#999999",
|
||||
"selectedColor": "#07c160",
|
||||
"backgroundColor": "#f7f7f7",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/index/index",
|
||||
"text": "消息",
|
||||
"iconPath": "static/icons/chat.png",
|
||||
"selectedIconPath": "static/icons/chat-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/contact/index",
|
||||
"text": "联系人",
|
||||
"iconPath": "static/icons/contact.png",
|
||||
"selectedIconPath": "static/icons/contact-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/moment/index",
|
||||
"text": "朋友圈",
|
||||
"iconPath": "static/icons/moment.png",
|
||||
"selectedIconPath": "static/icons/moment-active.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"subPackages": []
|
||||
}
|
||||
|
||||
@@ -1,31 +1,53 @@
|
||||
<template>
|
||||
<view class="chat-page">
|
||||
<view class="chat-page" :class="{ dark: isDark }">
|
||||
<!-- 导航栏 -->
|
||||
<wd-navbar
|
||||
:title="chatName"
|
||||
:title="displayName"
|
||||
left-arrow
|
||||
fixed
|
||||
placeholder
|
||||
@click-left="goBack"
|
||||
>
|
||||
<template #right>
|
||||
<view class="navbar-actions">
|
||||
<wd-icon name="phone" size="44rpx" @click="startAudioCall" />
|
||||
<wd-icon name="video" size="44rpx" @click="startVideoCall" />
|
||||
<wd-icon name="more" size="44rpx" @click="showChatInfo" />
|
||||
</view>
|
||||
</template>
|
||||
</wd-navbar>
|
||||
|
||||
<!-- 通话窗口 -->
|
||||
<call-window
|
||||
:call="webrtc.call"
|
||||
:local-stream="webrtc.localStream.value"
|
||||
:remote-stream="webrtc.remoteStream.value"
|
||||
:format-duration="webrtc.formatDuration"
|
||||
@accept="webrtc.acceptCall()"
|
||||
@reject="webrtc.rejectCall()"
|
||||
@end="webrtc.endCall()"
|
||||
@toggle-mute="webrtc.toggleMute()"
|
||||
@toggle-camera="webrtc.toggleCamera()"
|
||||
@toggle-minimize="webrtc.toggleMinimize()"
|
||||
/>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view
|
||||
class="message-list"
|
||||
scroll-y
|
||||
:scroll-into-view="scrollToId"
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="loadMoreMessages"
|
||||
:scroll-top="scrollTop"
|
||||
:scroll-with-animation="scrollWithAnimation"
|
||||
:upper-threshold="100"
|
||||
@scrolltoupper="onScrollToUpper"
|
||||
>
|
||||
<!-- 加载提示 -->
|
||||
<view v-if="hasMore" class="load-more">
|
||||
<view v-if="hasMore" class="load-more" @click="loadMoreMessages">
|
||||
<wd-loading v-if="loadingMore" />
|
||||
<text v-else>下拉加载更多消息</text>
|
||||
<text v-else>点击或滑到顶部加载更多</text>
|
||||
</view>
|
||||
<view v-else-if="messages.length > 0" class="load-more">
|
||||
<text>没有更多消息了</text>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
@@ -48,17 +70,13 @@
|
||||
<!-- 普通消息 -->
|
||||
<view v-else class="message-bubble-wrap" :class="{ 'is-self': msg.isSelf }">
|
||||
<!-- 头像 -->
|
||||
<view v-if="!msg.isSelf" class="message-avatar">
|
||||
<wd-img
|
||||
v-if="targetUser?.avatar"
|
||||
:src="targetUser.avatar"
|
||||
width="72rpx"
|
||||
height="72rpx"
|
||||
<view v-if="!msg.isSelf" class="message-avatar" @click="onAvatarClick(msg)">
|
||||
<app-avatar
|
||||
:src="getMessageSenderAvatar(msg)"
|
||||
:name="getMessageSenderName(msg)"
|
||||
:size="72"
|
||||
radius="8rpx"
|
||||
/>
|
||||
<view v-else class="avatar-placeholder">
|
||||
{{ targetUser?.name?.charAt(0) || '?' }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
@@ -107,16 +125,13 @@
|
||||
|
||||
<!-- 自己的头像 -->
|
||||
<view v-if="msg.isSelf" class="message-avatar">
|
||||
<wd-img
|
||||
v-if="currentUser?.avatar"
|
||||
:src="currentUser.avatar"
|
||||
width="72rpx"
|
||||
height="72rpx"
|
||||
<app-avatar
|
||||
:src="currentUser?.avatar"
|
||||
:name="currentUser?.name || '我'"
|
||||
:size="72"
|
||||
radius="8rpx"
|
||||
bg-color="#10aeff"
|
||||
/>
|
||||
<view v-else class="avatar-placeholder avatar-self">
|
||||
{{ currentUser?.name?.charAt(0) || '我' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -166,6 +181,10 @@
|
||||
<view class="more-icon"><wd-icon name="picture" size="48rpx" /></view>
|
||||
<text>图片</text>
|
||||
</view>
|
||||
<view class="more-item" @click="chooseVideo">
|
||||
<view class="more-icon"><wd-icon name="video" size="48rpx" /></view>
|
||||
<text>视频</text>
|
||||
</view>
|
||||
<view class="more-item" @click="shootCamera">
|
||||
<view class="more-icon"><wd-icon name="camera" size="48rpx" /></view>
|
||||
<text>拍摄</text>
|
||||
@@ -174,6 +193,14 @@
|
||||
<view class="more-icon"><wd-icon name="folder" size="48rpx" /></view>
|
||||
<text>文件</text>
|
||||
</view>
|
||||
<view class="more-item" @click="onMoreAudioCall">
|
||||
<view class="more-icon"><wd-icon name="phone" size="48rpx" /></view>
|
||||
<text>语音通话</text>
|
||||
</view>
|
||||
<view class="more-item" @click="onMoreVideoCall">
|
||||
<view class="more-icon"><wd-icon name="video-camera" size="48rpx" /></view>
|
||||
<text>视频通话</text>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
@@ -187,26 +214,50 @@ import { onLoad } from '@dcloudio/uni-app'
|
||||
import { useAuthStore, useChatStore, useConversationStore } from '@/stores'
|
||||
import { wsManager } from '@/api/websocket'
|
||||
import * as messageApi from '@/api/modules/message'
|
||||
import { formatMessageTime, formatSize } from '@/utils/format'
|
||||
import { isSystemMessage as checkSystemMessage } from '@/utils/message'
|
||||
import * as attachmentApi from '@/api/modules/attachment'
|
||||
import { formatMessageTime, formatSize, generateColor } from '@/utils/format'
|
||||
import { isSystemOrNotifyMessage as checkSystemMessage } from '@/utils/message'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import type { ChatMessage } from '@/types/api'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useWebRTC } from '@/composables/useWebRTC'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import CallWindow from '@/components/call/CallWindow.vue'
|
||||
import type { ChatMessage, Attachment } from '@/types/api'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const chatStore = useChatStore()
|
||||
const conversationStore = useConversationStore()
|
||||
const toast = useToast()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
// WebRTC 通话
|
||||
const webrtc = useWebRTC(
|
||||
authStore.user?.id || '',
|
||||
(senderUserId) => {
|
||||
// 来电回调 - 可以在这里添加额外处理
|
||||
console.log('来电:', senderUserId)
|
||||
},
|
||||
(receiverUserId) => {
|
||||
// 获取 roomId 回调
|
||||
if (roomId.value) return roomId.value
|
||||
const contact = chatStore.contacts.find(c => c.contact_user_id === receiverUserId)
|
||||
return contact?.room_id || ''
|
||||
}
|
||||
)
|
||||
|
||||
// 路由参数
|
||||
const roomId = ref('')
|
||||
const targetId = ref('')
|
||||
const chatName = ref('')
|
||||
const targetAvatar = ref('')
|
||||
|
||||
// 状态
|
||||
const inputText = ref('')
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const scrollToId = ref('')
|
||||
const refreshing = ref(false)
|
||||
const scrollTop = ref(0)
|
||||
const scrollWithAnimation = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const showMore = ref(false)
|
||||
@@ -216,10 +267,30 @@ const page = ref(1)
|
||||
|
||||
// 计算属性
|
||||
const currentUser = computed(() => authStore.user)
|
||||
const displayName = computed(() => targetUser.value?.name || chatName.value || '聊天')
|
||||
const targetUser = computed(() => {
|
||||
// 从联系人中获取目标用户信息
|
||||
// 优先从联系人中获取目标用户信息
|
||||
const contact = chatStore.contacts.find(c => c.contact_user_id === targetId.value)
|
||||
return contact?.user
|
||||
if (contact?.user) {
|
||||
return contact.user
|
||||
}
|
||||
// 如果联系人中没有,从会话列表中获取
|
||||
const conv = conversationStore.conversations.find(
|
||||
c => c.target_id === targetId.value || c.room_id === roomId.value
|
||||
)
|
||||
if (conv) {
|
||||
return {
|
||||
id: conv.target_id,
|
||||
name: conv.name || chatName.value,
|
||||
avatar: conv.avatar || targetAvatar.value
|
||||
}
|
||||
}
|
||||
// 最后使用 URL 参数
|
||||
return {
|
||||
id: targetId.value,
|
||||
name: chatName.value,
|
||||
avatar: targetAvatar.value
|
||||
}
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
@@ -227,9 +298,22 @@ onLoad((options: any) => {
|
||||
roomId.value = options?.roomId || ''
|
||||
targetId.value = options?.targetId || ''
|
||||
chatName.value = decodeURIComponent(options?.name || '聊天')
|
||||
targetAvatar.value = decodeURIComponent(options?.avatar || '')
|
||||
|
||||
loadMessages()
|
||||
setupWebSocket()
|
||||
|
||||
// 如果带有 callType 参数,自动发起通话
|
||||
const callType = options?.callType
|
||||
if (callType && targetId.value) {
|
||||
setTimeout(() => {
|
||||
if (callType === 'audio') {
|
||||
startAudioCall()
|
||||
} else if (callType === 'video') {
|
||||
startVideoCall()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -237,10 +321,21 @@ onMounted(() => {
|
||||
if (targetId.value) {
|
||||
conversationStore.clearUnread(targetId.value)
|
||||
}
|
||||
// 确保滚动到底部
|
||||
setTimeout(() => {
|
||||
scrollToBottom(false)
|
||||
}, 300)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
wsManager.offMessage(handleNewMessage)
|
||||
wsManager.offSignal(handleSignal)
|
||||
// 清理音频播放
|
||||
stopAudio()
|
||||
// 结束通话
|
||||
if (webrtc.call.active) {
|
||||
webrtc.endCall()
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
@@ -250,71 +345,147 @@ async function loadMessages() {
|
||||
try {
|
||||
const cached = chatStore.getRoomMessages(roomId.value)
|
||||
if (cached.length > 0) {
|
||||
messages.value = cached.map(m => ({
|
||||
messages.value = cached.map((m) => ({
|
||||
...m,
|
||||
isSelf: m.sender_user_id === currentUser.value?.id
|
||||
isSelf: m.sender_user_id === currentUser.value?.id,
|
||||
extra: typeof m.extra === 'string' ? JSON.parse(m.extra || '{}') : m.extra
|
||||
}))
|
||||
scrollToBottom()
|
||||
scrollToBottom(false)
|
||||
return
|
||||
}
|
||||
|
||||
const res = await messageApi.getMessages(roomId.value, 1, 50)
|
||||
messages.value = (res.data || []).reverse().map((m: ChatMessage) => ({
|
||||
...m,
|
||||
isSelf: m.sender_user_id === currentUser.value?.id
|
||||
isSelf: m.sender_user_id === currentUser.value?.id,
|
||||
extra: typeof m.extra === 'string' ? JSON.parse(m.extra || '{}') : m.extra
|
||||
}))
|
||||
chatStore.setRoomMessages(roomId.value, messages.value)
|
||||
hasMore.value = res.data.length >= 50
|
||||
scrollToBottom()
|
||||
scrollToBottom(false)
|
||||
} catch (error) {
|
||||
console.error('加载消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到顶部时加载更多消息
|
||||
function onScrollToUpper() {
|
||||
if (!loadingMore.value && hasMore.value) {
|
||||
loadMoreMessages()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreMessages() {
|
||||
if (loadingMore.value || !hasMore.value) {
|
||||
refreshing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loadingMore.value = true
|
||||
page.value++
|
||||
|
||||
// 记录第一条消息的 ID,用于保持滚动位置
|
||||
const firstMsgId = messages.value.length > 0 ? messages.value[0].id : null
|
||||
|
||||
try {
|
||||
const res = await messageApi.getMessages(roomId.value, page.value, 50)
|
||||
const newMessages = (res.data || []).reverse().map((m: ChatMessage) => ({
|
||||
...m,
|
||||
isSelf: m.sender_user_id === currentUser.value?.id
|
||||
isSelf: m.sender_user_id === currentUser.value?.id,
|
||||
extra: typeof m.extra === 'string' ? JSON.parse(m.extra || '{}') : m.extra
|
||||
}))
|
||||
messages.value = [...newMessages, ...messages.value]
|
||||
hasMore.value = res.data.length >= 50
|
||||
|
||||
// 保持滚动位置 - 滚动到之前的第一条消息
|
||||
if (firstMsgId) {
|
||||
nextTick(() => {
|
||||
scrollWithAnimation.value = false
|
||||
scrollToId.value = `msg-${firstMsgId}`
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
page.value--
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setupWebSocket() {
|
||||
wsManager.onMessage(handleNewMessage)
|
||||
wsManager.onSignal(handleSignal)
|
||||
}
|
||||
|
||||
// 处理 WebRTC 信令
|
||||
function handleSignal(message: ChatMessage) {
|
||||
webrtc.handleSignaling(message)
|
||||
}
|
||||
|
||||
// 发起语音通话
|
||||
function startAudioCall() {
|
||||
if (!targetId.value) {
|
||||
toast.show('无法发起通话')
|
||||
return
|
||||
}
|
||||
webrtc.startCall('audio', targetId.value, roomId.value)
|
||||
}
|
||||
|
||||
// 发起视频通话
|
||||
function startVideoCall() {
|
||||
if (!targetId.value) {
|
||||
toast.show('无法发起通话')
|
||||
return
|
||||
}
|
||||
webrtc.startCall('video', targetId.value, roomId.value)
|
||||
}
|
||||
|
||||
// 从更多面板发起语音通话
|
||||
function onMoreAudioCall() {
|
||||
showMore.value = false
|
||||
startAudioCall()
|
||||
}
|
||||
|
||||
// 从更多面板发起视频通话
|
||||
function onMoreVideoCall() {
|
||||
showMore.value = false
|
||||
startVideoCall()
|
||||
}
|
||||
|
||||
function handleNewMessage(msg: ChatMessage) {
|
||||
if (msg.room_id !== roomId.value) return
|
||||
// 忽略信令消息
|
||||
if (msg.message_type === 6) return
|
||||
|
||||
const newMsg = {
|
||||
// 解析 extra 字段(与 nl-im-vue-ts 对齐)
|
||||
const parsedExtra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra
|
||||
|
||||
const newMsg: ChatMessage = {
|
||||
...msg,
|
||||
isSelf: msg.sender_user_id === currentUser.value?.id
|
||||
isSelf: msg.sender_user_id === currentUser.value?.id,
|
||||
extra: parsedExtra
|
||||
}
|
||||
|
||||
// 更新会话列表(无论是否当前房间的消息都要更新)
|
||||
const isCurrentChat = msg.room_id === roomId.value
|
||||
conversationStore.handleMessageUpdate(newMsg, newMsg.isSelf || false, isCurrentChat)
|
||||
|
||||
// 不是当前房间的消息,不添加到消息列表
|
||||
if (!isCurrentChat) return
|
||||
|
||||
// 避免重复添加(本地已发送的消息)
|
||||
const exists = messages.value.some(
|
||||
(m) => m.id === newMsg.id || (m.isSelf && m.content === newMsg.content && Math.abs(new Date(m.created_at).getTime() - new Date(newMsg.created_at).getTime()) < 2000)
|
||||
)
|
||||
|
||||
if (!exists) {
|
||||
messages.value.push(newMsg)
|
||||
chatStore.addMessage(roomId.value, newMsg)
|
||||
scrollToBottom()
|
||||
scrollToBottom(true)
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
function scrollToBottom(smooth = true) {
|
||||
nextTick(() => {
|
||||
if (messages.value.length > 0) {
|
||||
scrollWithAnimation.value = smooth
|
||||
scrollToId.value = `msg-${messages.value[messages.value.length - 1].id}`
|
||||
}
|
||||
})
|
||||
@@ -326,13 +497,42 @@ async function sendTextMessage() {
|
||||
const content = inputText.value.trim()
|
||||
inputText.value = ''
|
||||
|
||||
// 构建消息结构(与 nl-im-vue-ts 对齐)
|
||||
const message: ChatMessage = {
|
||||
id: Date.now(),
|
||||
room_id: roomId.value,
|
||||
sender_user_id: currentUser.value!.id,
|
||||
receiver_user_id: targetId.value || '',
|
||||
message_type: 0,
|
||||
content,
|
||||
duration: 0,
|
||||
extra: {},
|
||||
created_at: new Date().toISOString(),
|
||||
isSelf: true
|
||||
}
|
||||
|
||||
// 先添加到本地列表
|
||||
messages.value.push(message)
|
||||
chatStore.addMessage(roomId.value, message)
|
||||
// 更新会话列表最后一条消息
|
||||
conversationStore.handleMessageUpdate(message, true, true)
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
await messageApi.sendMessage({
|
||||
sender_client_id: wsManager.getClientId() || '',
|
||||
receiver_user_id: targetId.value || '',
|
||||
room_id: roomId.value,
|
||||
message_type: 0,
|
||||
content
|
||||
content,
|
||||
extra: JSON.stringify({})
|
||||
})
|
||||
} catch {
|
||||
// 发送失败,移除本地消息
|
||||
const index = messages.value.findIndex((m) => m.id === message.id)
|
||||
if (index > -1) {
|
||||
messages.value.splice(index, 1)
|
||||
}
|
||||
toast.error('发送失败')
|
||||
inputText.value = content
|
||||
}
|
||||
@@ -370,7 +570,8 @@ function chooseFile() {
|
||||
count: 1,
|
||||
type: 'file',
|
||||
success: async (res: any) => {
|
||||
await uploadAndSend(res.tempFiles[0].path, 8)
|
||||
const file = res.tempFiles[0]
|
||||
await uploadAndSend(file.path, 8, file.name)
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
@@ -383,28 +584,221 @@ function chooseFile() {
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
// H5 端使用 input[type=file]
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '*/*'
|
||||
input.onchange = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
// 读取文件为临时路径
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
// 创建一个临时 blob URL
|
||||
const blobUrl = URL.createObjectURL(file)
|
||||
await uploadAndSend(blobUrl, 8, file.name)
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
// #endif
|
||||
}
|
||||
|
||||
async function uploadAndSend(filePath: string, messageType: number) {
|
||||
async function uploadAndSend(filePath: string, messageType: number, fileName?: string) {
|
||||
toast.loading('上传中...')
|
||||
try {
|
||||
// TODO: 实现文件上传
|
||||
// 确定上传类型
|
||||
const uploadType = messageType === 1 ? 'image' : messageType === 3 ? 'video' : 'file'
|
||||
|
||||
// 上传文件
|
||||
const attachment = await attachmentApi.uploadAttachment(filePath, uploadType)
|
||||
toast.close()
|
||||
toast.show('文件上传功能开发中')
|
||||
} catch {
|
||||
|
||||
// 获取文件信息
|
||||
const fileInfo = await getFileInfo(filePath)
|
||||
|
||||
// 构建 extra 数据
|
||||
const extra: Record<string, any> = {
|
||||
url: attachment.file_url,
|
||||
name: fileName || attachment.file_name || fileInfo.name,
|
||||
size: attachment.file_size || fileInfo.size,
|
||||
attachment_id: attachment.id
|
||||
}
|
||||
|
||||
// 图片/视频可能有宽高
|
||||
if (attachment.width) extra.width = attachment.width
|
||||
if (attachment.height) extra.height = attachment.height
|
||||
if (attachment.duration) extra.duration = attachment.duration
|
||||
|
||||
// 构建消息
|
||||
const message: ChatMessage = {
|
||||
id: Date.now(),
|
||||
room_id: roomId.value,
|
||||
sender_user_id: currentUser.value!.id,
|
||||
receiver_user_id: targetId.value || '',
|
||||
message_type: messageType,
|
||||
content: attachment.file_url,
|
||||
duration: attachment.duration || 0,
|
||||
extra,
|
||||
created_at: new Date().toISOString(),
|
||||
isSelf: true
|
||||
}
|
||||
|
||||
// 先添加到本地列表
|
||||
messages.value.push(message)
|
||||
chatStore.addMessage(roomId.value, message)
|
||||
// 更新会话列表最后一条消息
|
||||
conversationStore.handleMessageUpdate(message, true, true)
|
||||
scrollToBottom(true)
|
||||
|
||||
// 发送消息
|
||||
await messageApi.sendMessage({
|
||||
sender_client_id: wsManager.getClientId() || '',
|
||||
receiver_user_id: targetId.value || '',
|
||||
room_id: roomId.value,
|
||||
message_type: messageType,
|
||||
content: attachment.file_url,
|
||||
duration: attachment.duration || 0,
|
||||
extra: JSON.stringify(extra)
|
||||
})
|
||||
|
||||
toast.success('发送成功')
|
||||
} catch (error: any) {
|
||||
toast.close()
|
||||
toast.error('上传失败')
|
||||
toast.error(error.message || '发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取本地文件信息
|
||||
function getFileInfo(filePath: string): Promise<{ name: string; size: number }> {
|
||||
return new Promise((resolve) => {
|
||||
// 从路径提取文件名
|
||||
const pathParts = filePath.split('/')
|
||||
const name = pathParts[pathParts.length - 1] || '未知文件'
|
||||
|
||||
// #ifdef H5
|
||||
resolve({ name, size: 0 })
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
uni.getFileInfo({
|
||||
filePath,
|
||||
success: (res) => {
|
||||
resolve({ name, size: res.size || 0 })
|
||||
},
|
||||
fail: () => {
|
||||
resolve({ name, size: 0 })
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
})
|
||||
}
|
||||
|
||||
// 音频播放实例
|
||||
let audioContext: UniApp.InnerAudioContext | null = null
|
||||
|
||||
function playAudio(msg: ChatMessage) {
|
||||
// TODO: 播放语音
|
||||
toast.show('语音播放功能开发中')
|
||||
const audioUrl = getMediaUrl(msg)
|
||||
if (!audioUrl) {
|
||||
toast.error('无法获取音频地址')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在播放同一条消息,则停止
|
||||
if (playingAudioId.value === msg.id) {
|
||||
stopAudio()
|
||||
return
|
||||
}
|
||||
|
||||
// 停止之前播放的音频
|
||||
stopAudio()
|
||||
|
||||
// 创建音频实例
|
||||
audioContext = uni.createInnerAudioContext()
|
||||
audioContext.src = audioUrl
|
||||
audioContext.autoplay = true
|
||||
|
||||
playingAudioId.value = msg.id
|
||||
|
||||
audioContext.onEnded(() => {
|
||||
playingAudioId.value = null
|
||||
})
|
||||
|
||||
audioContext.onError((err) => {
|
||||
console.error('Audio play error:', err)
|
||||
toast.error('播放失败')
|
||||
playingAudioId.value = null
|
||||
})
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
if (audioContext) {
|
||||
audioContext.stop()
|
||||
audioContext.destroy()
|
||||
audioContext = null
|
||||
}
|
||||
playingAudioId.value = null
|
||||
}
|
||||
|
||||
function openFile(msg: ChatMessage) {
|
||||
// TODO: 打开文件
|
||||
toast.show('文件预览功能开发中')
|
||||
const fileUrl = getMediaUrl(msg)
|
||||
const fileName = getFileName(msg)
|
||||
|
||||
if (!fileUrl) {
|
||||
toast.error('无法获取文件地址')
|
||||
return
|
||||
}
|
||||
|
||||
// 在H5端直接打开新标签页
|
||||
// #ifdef H5
|
||||
window.open(fileUrl, '_blank')
|
||||
return
|
||||
// #endif
|
||||
|
||||
// 在APP/小程序端尝试下载并预览
|
||||
// #ifndef H5
|
||||
toast.loading('正在打开文件...')
|
||||
|
||||
uni.downloadFile({
|
||||
url: fileUrl,
|
||||
success: (res) => {
|
||||
toast.close()
|
||||
if (res.statusCode === 200) {
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
showMenu: true,
|
||||
success: () => {},
|
||||
fail: (err) => {
|
||||
console.error('Open document failed:', err)
|
||||
toast.error('无法打开此类型文件')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
toast.error('下载文件失败')
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
toast.close()
|
||||
console.error('Download file failed:', err)
|
||||
toast.error('下载文件失败')
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
// 选择视频
|
||||
function chooseVideo() {
|
||||
showMore.value = false
|
||||
uni.chooseVideo({
|
||||
sourceType: ['album', 'camera'],
|
||||
maxDuration: 60,
|
||||
success: async (res) => {
|
||||
await uploadAndSend(res.tempFilePath, 3, res.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
@@ -438,15 +832,18 @@ function getBubbleClass(msg: ChatMessage): string {
|
||||
}
|
||||
|
||||
function getMediaUrl(msg: ChatMessage): string {
|
||||
let url = ''
|
||||
if (typeof msg.extra === 'string') {
|
||||
try {
|
||||
const extra = JSON.parse(msg.extra)
|
||||
return extra.url || msg.content
|
||||
url = extra.url || msg.content
|
||||
} catch {
|
||||
return msg.content
|
||||
url = msg.content
|
||||
}
|
||||
} else {
|
||||
url = msg.extra?.url || msg.content
|
||||
}
|
||||
return msg.extra?.url || msg.content
|
||||
return resolveImageUrl(url)
|
||||
}
|
||||
|
||||
function getFileName(msg: ChatMessage): string {
|
||||
@@ -472,6 +869,29 @@ function getFileSize(msg: ChatMessage): number {
|
||||
}
|
||||
return msg.extra?.size || 0
|
||||
}
|
||||
|
||||
// 获取消息发送者头像
|
||||
function getMessageSenderAvatar(msg: ChatMessage): string {
|
||||
// 单聊直接返回目标用户头像
|
||||
return targetUser.value?.avatar || ''
|
||||
}
|
||||
|
||||
// 获取消息发送者名称
|
||||
function getMessageSenderName(msg: ChatMessage): string {
|
||||
return targetUser.value?.name || chatName.value || '未知'
|
||||
}
|
||||
|
||||
// 点击头像进入详情
|
||||
function onAvatarClick(msg: ChatMessage) {
|
||||
const userId = msg.sender_user_id
|
||||
if (!userId) return
|
||||
|
||||
// 跳转到用户详情页
|
||||
// 对于单聊,使用 targetId;对于群聊,使用消息发送者 ID
|
||||
uni.navigateTo({
|
||||
url: `/pages/contact/detail?userId=${userId}`
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -482,10 +902,22 @@ function getFileSize(msg: ChatMessage): number {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// 导航栏操作按钮
|
||||
.navbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
// 消息列表
|
||||
.message-list {
|
||||
flex: 1;
|
||||
position: fixed;
|
||||
top: calc(88rpx + var(--status-bar-height, 0));
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||
padding: 20rpx;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.load-more {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="add-page">
|
||||
<view class="add-page" :class="{ dark: isDark }">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="添加好友" />
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<view class="search-section">
|
||||
<wd-search
|
||||
@@ -26,16 +29,12 @@
|
||||
:key="item.id"
|
||||
class="result-item"
|
||||
>
|
||||
<wd-img
|
||||
v-if="item.avatar"
|
||||
<app-avatar
|
||||
:src="item.avatar"
|
||||
width="96rpx"
|
||||
height="96rpx"
|
||||
:name="item.name"
|
||||
:size="96"
|
||||
radius="8rpx"
|
||||
/>
|
||||
<view v-else class="avatar-placeholder" :style="{ background: generateColor(item.name || '') }">
|
||||
{{ item.name?.charAt(0) || '?' }}
|
||||
</view>
|
||||
|
||||
<view class="user-info">
|
||||
<text class="name">{{ item.name }}</text>
|
||||
@@ -65,10 +64,13 @@ import { ref } from 'vue'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import { useToast, useMessage } from 'wot-design-uni'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { User } from '@/types/api'
|
||||
|
||||
const toast = useToast()
|
||||
const message = useMessage()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const keyword = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="detail-page">
|
||||
<view class="detail-page" :class="{ dark: isDark }">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="好友详情" />
|
||||
|
||||
<view v-if="loading" class="loading-state">
|
||||
<wd-loading />
|
||||
<text>加载中...</text>
|
||||
@@ -8,16 +11,12 @@
|
||||
<template v-else-if="contact">
|
||||
<!-- 用户信息 -->
|
||||
<view class="header-section">
|
||||
<wd-img
|
||||
v-if="contact.user?.avatar"
|
||||
:src="contact.user.avatar"
|
||||
width="140rpx"
|
||||
height="140rpx"
|
||||
<app-avatar
|
||||
:src="contact.user?.avatar"
|
||||
:name="contact.remark_name || contact.user?.name"
|
||||
:size="140"
|
||||
radius="12rpx"
|
||||
/>
|
||||
<view v-else class="avatar-placeholder" :style="{ background: generateColor(contact.user?.name || '') }">
|
||||
{{ contact.user?.name?.charAt(0) || '?' }}
|
||||
</view>
|
||||
<view class="user-info">
|
||||
<text class="name">{{ contact.remark_name || contact.user?.name || '未知' }}</text>
|
||||
<text class="id">ID: {{ contact.user?.id || '-' }}</text>
|
||||
@@ -52,7 +51,20 @@
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="actions-section">
|
||||
<wd-button type="primary" block @click="goChat">发消息</wd-button>
|
||||
<view class="action-row">
|
||||
<wd-button type="primary" class="flex-btn" @click="goChat">
|
||||
<wd-icon name="chat" size="36rpx" />
|
||||
<text>发消息</text>
|
||||
</wd-button>
|
||||
<wd-button type="info" class="flex-btn" @click="startAudioCall">
|
||||
<wd-icon name="phone" size="36rpx" />
|
||||
<text>语音</text>
|
||||
</wd-button>
|
||||
<wd-button type="success" class="flex-btn" @click="startVideoCall">
|
||||
<wd-icon name="video" size="36rpx" />
|
||||
<text>视频</text>
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-button type="error" block plain @click="deleteFriend">删除好友</wd-button>
|
||||
</view>
|
||||
</template>
|
||||
@@ -67,23 +79,31 @@ import { ref, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
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'
|
||||
|
||||
const toast = useToast()
|
||||
const messageBox = useMessage()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const loading = ref(true)
|
||||
const contact = ref<Contact | null>(null)
|
||||
const contactId = ref('')
|
||||
const userId = ref('')
|
||||
|
||||
onLoad((options: any) => {
|
||||
contactId.value = options?.id || ''
|
||||
userId.value = options?.userId || ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (contactId.value) {
|
||||
await loadContact()
|
||||
} else if (userId.value) {
|
||||
await loadContactByUserId()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -99,10 +119,45 @@ async function loadContact() {
|
||||
}
|
||||
}
|
||||
|
||||
// 通过用户 ID 查找联系人
|
||||
async function loadContactByUserId() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 获取所有联系人,找到对应的用户
|
||||
const contacts = await contactApi.getContacts()
|
||||
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() || '')
|
||||
} else {
|
||||
toast.warning('未找到该联系人')
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载联系人失败:', error)
|
||||
toast.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goChat() {
|
||||
if (!contact.value) return
|
||||
uni.navigateTo({
|
||||
url: `/pages/chat/index?id=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}`
|
||||
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`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -238,5 +293,18 @@ async function deleteFriend() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.flex-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,6 +21,36 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 功能入口行 -->
|
||||
<view class="function-row">
|
||||
<view class="function-item" @click="goFriendRequests">
|
||||
<view class="function-icon icon-friend">
|
||||
<wd-icon name="user-add" size="36rpx" color="#fff" />
|
||||
</view>
|
||||
<text class="function-text">新朋友</text>
|
||||
<wd-badge v-if="friendRequestCount > 0" :value="friendRequestCount" class="function-badge" />
|
||||
</view>
|
||||
<view class="function-item" @click="goGroupNotify">
|
||||
<view class="function-icon icon-notify">
|
||||
<wd-icon name="bell" size="36rpx" color="#fff" />
|
||||
</view>
|
||||
<text class="function-text">群通知</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
<text>{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<scroll-view
|
||||
class="content-scroll"
|
||||
@@ -29,92 +59,221 @@
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<!-- 功能入口 -->
|
||||
<view class="function-section">
|
||||
<wd-cell
|
||||
title="新朋友"
|
||||
is-link
|
||||
@click="goFriendRequests"
|
||||
>
|
||||
<template #icon>
|
||||
<view class="function-icon icon-friend">
|
||||
<wd-icon name="user-add" size="40rpx" color="#fff" />
|
||||
</view>
|
||||
</template>
|
||||
<template #value>
|
||||
<wd-badge v-if="friendRequestCount > 0" :value="friendRequestCount" />
|
||||
</template>
|
||||
</wd-cell>
|
||||
<wd-cell
|
||||
title="群聊"
|
||||
is-link
|
||||
@click="goGroups"
|
||||
>
|
||||
<template #icon>
|
||||
<view class="function-icon icon-group">
|
||||
<wd-icon name="people" size="40rpx" color="#fff" />
|
||||
</view>
|
||||
</template>
|
||||
</wd-cell>
|
||||
<wd-cell
|
||||
title="群通知"
|
||||
is-link
|
||||
@click="goGroupNotify"
|
||||
>
|
||||
<template #icon>
|
||||
<view class="function-icon icon-notify">
|
||||
<wd-icon name="bell" size="40rpx" color="#fff" />
|
||||
</view>
|
||||
</template>
|
||||
</wd-cell>
|
||||
</view>
|
||||
|
||||
<!-- 联系人列表 -->
|
||||
<view class="contact-section">
|
||||
<!-- 加载状态 -->
|
||||
<view v-if="loading" class="loading-state">
|
||||
<wd-loading />
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<wd-status-tip v-else-if="contacts.length === 0" tip="暂无联系人" />
|
||||
|
||||
<!-- 分组 Tab -->
|
||||
<template v-else-if="activeTab === 'groups'">
|
||||
<wd-status-tip v-if="contactGroups.length === 0 && ungroupedContacts.length === 0" tip="暂无联系人" />
|
||||
<template v-else>
|
||||
<!-- 按字母分组 -->
|
||||
<!-- 未分组 -->
|
||||
<view v-if="ungroupedContacts.length > 0" class="contact-group">
|
||||
<view class="group-header" @click="toggleCollapse(0)">
|
||||
<wd-icon
|
||||
:name="collapsedIds.includes(0) ? 'arrow-right' : 'arrow-down'"
|
||||
size="28rpx"
|
||||
/>
|
||||
<text class="group-name">未分组</text>
|
||||
<text class="group-count">({{ ungroupedContacts.length }})</text>
|
||||
</view>
|
||||
<view v-if="!collapsedIds.includes(0)" class="group-contacts">
|
||||
<view
|
||||
v-for="group in groupedContacts"
|
||||
v-for="contact in ungroupedContacts"
|
||||
:key="contact.id"
|
||||
class="contact-item"
|
||||
@click="goContactDetail(contact)"
|
||||
>
|
||||
<app-avatar
|
||||
:src="contact.user?.avatar"
|
||||
:name="contact.remark_name || contact.user?.name"
|
||||
:size="80"
|
||||
radius="8rpx"
|
||||
/>
|
||||
<view class="contact-info">
|
||||
<text class="contact-name">{{ contact.remark_name || contact.user?.name || '未知' }}</text>
|
||||
<text class="contact-desc">{{ contact.user?.desc || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 自定义分组 -->
|
||||
<view
|
||||
v-for="group in contactGroups"
|
||||
:key="group.id"
|
||||
class="contact-group"
|
||||
>
|
||||
<view class="group-header" @click="toggleCollapse(group.id)">
|
||||
<wd-icon
|
||||
:name="collapsedIds.includes(group.id) ? 'arrow-right' : 'arrow-down'"
|
||||
size="28rpx"
|
||||
/>
|
||||
<text class="group-name">{{ group.group_name }}</text>
|
||||
<text class="group-count">({{ getGroupContacts(group.id).length }})</text>
|
||||
</view>
|
||||
<view v-if="!collapsedIds.includes(group.id)" class="group-contacts">
|
||||
<view
|
||||
v-for="contact in getGroupContacts(group.id)"
|
||||
:key="contact.id"
|
||||
class="contact-item"
|
||||
@click="goContactDetail(contact)"
|
||||
>
|
||||
<app-avatar
|
||||
:src="contact.user?.avatar"
|
||||
:name="contact.remark_name || contact.user?.name"
|
||||
:size="80"
|
||||
radius="8rpx"
|
||||
/>
|
||||
<view class="contact-info">
|
||||
<text class="contact-name">{{ contact.remark_name || contact.user?.name || '未知' }}</text>
|
||||
<text class="contact-desc">{{ contact.user?.desc || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 好友 Tab (按字母分组) -->
|
||||
<template v-else-if="activeTab === 'friends'">
|
||||
<wd-status-tip v-if="contacts.length === 0" tip="暂无联系人" />
|
||||
<template v-else>
|
||||
<view
|
||||
v-for="group in alphabetGroupedContacts"
|
||||
:key="group.letter"
|
||||
class="contact-group"
|
||||
>
|
||||
<view class="group-header">{{ group.letter }}</view>
|
||||
<wd-cell
|
||||
<view class="group-header letter-header">
|
||||
<text class="group-name">{{ group.letter }}</text>
|
||||
</view>
|
||||
<view class="group-contacts">
|
||||
<view
|
||||
v-for="contact in group.contacts"
|
||||
:key="contact.id"
|
||||
:title="contact.remark_name || contact.user?.name || '未知'"
|
||||
:label="contact.user?.desc"
|
||||
is-link
|
||||
class="contact-item"
|
||||
@click="goContactDetail(contact)"
|
||||
>
|
||||
<template #icon>
|
||||
<wd-img
|
||||
v-if="contact.user?.avatar"
|
||||
:src="contact.user.avatar"
|
||||
width="80rpx"
|
||||
height="80rpx"
|
||||
<app-avatar
|
||||
:src="contact.user?.avatar"
|
||||
:name="contact.remark_name || contact.user?.name"
|
||||
:size="80"
|
||||
radius="8rpx"
|
||||
custom-style="margin-right: 24rpx;"
|
||||
/>
|
||||
<view v-else class="avatar-placeholder" :style="{ background: generateColor(contact.user?.name || '') }">
|
||||
{{ contact.user?.name?.charAt(0) || '?' }}
|
||||
<view class="contact-info">
|
||||
<text class="contact-name">{{ contact.remark_name || contact.user?.name || '未知' }}</text>
|
||||
<text class="contact-desc">{{ contact.user?.desc || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</wd-cell>
|
||||
</template>
|
||||
|
||||
<!-- 群聊 Tab -->
|
||||
<template v-else-if="activeTab === 'chats'">
|
||||
<wd-status-tip v-if="groupChats.length === 0" tip="暂无群聊" />
|
||||
<template v-else>
|
||||
<!-- 我创建的群 -->
|
||||
<view v-if="createdGroups.length > 0" class="contact-group">
|
||||
<view class="group-header" @click="toggleCollapse('created')">
|
||||
<wd-icon
|
||||
:name="collapsedIds.includes('created') ? 'arrow-right' : 'arrow-down'"
|
||||
size="28rpx"
|
||||
/>
|
||||
<text class="group-name">我创建的群</text>
|
||||
<text class="group-count">({{ createdGroups.length }})</text>
|
||||
</view>
|
||||
<view v-if="!collapsedIds.includes('created')" class="group-contacts">
|
||||
<view
|
||||
v-for="group in createdGroups"
|
||||
:key="group.room_id"
|
||||
class="contact-item"
|
||||
@click="goGroupChat(group)"
|
||||
>
|
||||
<app-avatar
|
||||
:src="group.room_avatar"
|
||||
:name="group.room_name || '群聊'"
|
||||
:size="80"
|
||||
radius="8rpx"
|
||||
/>
|
||||
<view class="contact-info">
|
||||
<text class="contact-name">{{ group.room_name || '未命名群聊' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我管理的群 -->
|
||||
<view v-if="managedGroups.length > 0" class="contact-group">
|
||||
<view class="group-header" @click="toggleCollapse('managed')">
|
||||
<wd-icon
|
||||
:name="collapsedIds.includes('managed') ? 'arrow-right' : 'arrow-down'"
|
||||
size="28rpx"
|
||||
/>
|
||||
<text class="group-name">我管理的群</text>
|
||||
<text class="group-count">({{ managedGroups.length }})</text>
|
||||
</view>
|
||||
<view v-if="!collapsedIds.includes('managed')" class="group-contacts">
|
||||
<view
|
||||
v-for="group in managedGroups"
|
||||
:key="group.room_id"
|
||||
class="contact-item"
|
||||
@click="goGroupChat(group)"
|
||||
>
|
||||
<app-avatar
|
||||
:src="group.room_avatar"
|
||||
:name="group.room_name || '群聊'"
|
||||
:size="80"
|
||||
radius="8rpx"
|
||||
/>
|
||||
<view class="contact-info">
|
||||
<text class="contact-name">{{ group.room_name || '未命名群聊' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我加入的群 -->
|
||||
<view v-if="joinedGroups.length > 0" class="contact-group">
|
||||
<view class="group-header" @click="toggleCollapse('joined')">
|
||||
<wd-icon
|
||||
:name="collapsedIds.includes('joined') ? 'arrow-right' : 'arrow-down'"
|
||||
size="28rpx"
|
||||
/>
|
||||
<text class="group-name">我加入的群</text>
|
||||
<text class="group-count">({{ joinedGroups.length }})</text>
|
||||
</view>
|
||||
<view v-if="!collapsedIds.includes('joined')" class="group-contacts">
|
||||
<view
|
||||
v-for="group in joinedGroups"
|
||||
:key="group.room_id"
|
||||
class="contact-item"
|
||||
@click="goGroupChat(group)"
|
||||
>
|
||||
<app-avatar
|
||||
:src="group.room_avatar"
|
||||
:name="group.room_name || '群聊'"
|
||||
:size="80"
|
||||
radius="8rpx"
|
||||
/>
|
||||
<view class="contact-info">
|
||||
<text class="contact-name">{{ group.room_name || '未命名群聊' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 底部安全区 -->
|
||||
<view class="bottom-safe-area" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 右侧字母索引 -->
|
||||
<view class="letter-index">
|
||||
<!-- 右侧字母索引 (仅在好友 Tab 显示) -->
|
||||
<view v-if="activeTab === 'friends'" class="letter-index">
|
||||
<text
|
||||
v-for="letter in letters"
|
||||
:key="letter"
|
||||
@@ -126,6 +285,9 @@
|
||||
</view>
|
||||
|
||||
<wd-toast />
|
||||
|
||||
<!-- 自定义 TabBar -->
|
||||
<app-tab-bar current="contacts" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -135,27 +297,67 @@ import { onShow } from '@dcloudio/uni-app'
|
||||
import { useContactStore, useChatStore } from '@/stores'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import type { Contact } from '@/types/api'
|
||||
import * as roomApi from '@/api/modules/room'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import AppTabBar from '@/components/common/AppTabBar.vue'
|
||||
import type { Contact, ContactGroup } from '@/types/api'
|
||||
|
||||
interface GroupChat {
|
||||
room_id: string
|
||||
room_type: string
|
||||
room_name: string
|
||||
room_avatar: string
|
||||
owner_id: string
|
||||
creator_id: string
|
||||
category: 'joined' | 'created' | 'managed'
|
||||
role: number
|
||||
last_message_time?: string
|
||||
last_message?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const contactStore = useContactStore()
|
||||
const chatStore = useChatStore()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
// Tab 配置
|
||||
const tabs = [
|
||||
{ key: 'groups', label: '分组' },
|
||||
{ key: 'friends', label: '好友' },
|
||||
{ key: 'chats', label: '群聊' }
|
||||
]
|
||||
|
||||
// 状态
|
||||
const activeTab = ref('groups')
|
||||
const searchKeyword = ref('')
|
||||
const refreshing = ref(false)
|
||||
const loading = ref(false)
|
||||
const collapsedIds = ref<(number | string)[]>([])
|
||||
const contactGroups = ref<ContactGroup[]>([])
|
||||
const groupChats = ref<GroupChat[]>([])
|
||||
|
||||
// 计算属性
|
||||
const contacts = computed(() => chatStore.contacts)
|
||||
const friendRequestCount = computed(() => contactStore.friendRequests.filter(r => r.status === 'pending').length)
|
||||
|
||||
// 未分组联系人
|
||||
const ungroupedContacts = computed(() => {
|
||||
return contacts.value.filter(c => !c.group_id || c.group_id === 0)
|
||||
})
|
||||
|
||||
// 获取指定分组的联系人
|
||||
function getGroupContacts(groupId: number): Contact[] {
|
||||
return contacts.value.filter(c => c.group_id === groupId)
|
||||
}
|
||||
|
||||
// 字母列表
|
||||
const letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '#']
|
||||
|
||||
// 按字母分组的联系人
|
||||
const groupedContacts = computed(() => {
|
||||
const alphabetGroupedContacts = computed(() => {
|
||||
const groups: Record<string, Contact[]> = {}
|
||||
|
||||
contacts.value.forEach(contact => {
|
||||
@@ -181,6 +383,11 @@ const groupedContacts = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
// 群聊分组
|
||||
const createdGroups = computed(() => groupChats.value.filter(g => g.category === 'created'))
|
||||
const managedGroups = computed(() => groupChats.value.filter(g => g.category === 'managed'))
|
||||
const joinedGroups = computed(() => groupChats.value.filter(g => g.category === 'joined'))
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
@@ -194,10 +401,18 @@ onShow(() => {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await contactApi.getContacts()
|
||||
chatStore.setContacts(list)
|
||||
// 并行加载联系人、分组和群聊
|
||||
const [contactList, groups, chats] = await Promise.all([
|
||||
contactApi.getContacts(),
|
||||
contactApi.getGroups(),
|
||||
roomApi.getUserGroups()
|
||||
])
|
||||
|
||||
chatStore.setContacts(contactList)
|
||||
contactGroups.value = groups
|
||||
groupChats.value = chats || []
|
||||
} catch (error) {
|
||||
console.error('加载联系人失败:', error)
|
||||
console.error('加载数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -219,6 +434,15 @@ async function onRefresh() {
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
function toggleCollapse(id: number | string) {
|
||||
const idx = collapsedIds.value.indexOf(id)
|
||||
if (idx === -1) {
|
||||
collapsedIds.value.push(id)
|
||||
} else {
|
||||
collapsedIds.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function goSearch() {
|
||||
uni.navigateTo({ url: '/pages/search/index' })
|
||||
}
|
||||
@@ -231,10 +455,6 @@ function goFriendRequests() {
|
||||
uni.navigateTo({ url: '/pages/contact/requests' })
|
||||
}
|
||||
|
||||
function goGroups() {
|
||||
uni.navigateTo({ url: '/pages/contact/groups' })
|
||||
}
|
||||
|
||||
function goGroupNotify() {
|
||||
uni.navigateTo({ url: '/pages/group/notify' })
|
||||
}
|
||||
@@ -243,6 +463,12 @@ function goContactDetail(contact: Contact) {
|
||||
uni.navigateTo({ url: `/pages/contact/detail?id=${contact.id}` })
|
||||
}
|
||||
|
||||
function goGroupChat(group: GroupChat) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/chat/index?roomId=${group.room_id}&name=${encodeURIComponent(group.room_name || '群聊')}`
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToLetter(letter: string) {
|
||||
// TODO: 实现滚动到指定字母
|
||||
console.log('Scroll to:', letter)
|
||||
@@ -295,54 +521,147 @@ function scrollToLetter(letter: string) {
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
|
||||
// 内容滚动区域
|
||||
.content-scroll {
|
||||
// 功能入口行
|
||||
.function-row {
|
||||
position: fixed;
|
||||
top: calc(88rpx + 80rpx + var(--status-bar-height, 0));
|
||||
left: 0;
|
||||
right: 40rpx;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 98;
|
||||
display: flex;
|
||||
padding: 20rpx 30rpx;
|
||||
background: var(--bg-content);
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
gap: 40rpx;
|
||||
}
|
||||
|
||||
// 功能入口
|
||||
.function-section {
|
||||
background: var(--bg-content);
|
||||
margin-bottom: 20rpx;
|
||||
.function-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.function-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8rpx;
|
||||
margin-right: 24rpx;
|
||||
|
||||
&.icon-friend {
|
||||
background: #fa9d3b;
|
||||
}
|
||||
|
||||
&.icon-group {
|
||||
background: #07c160;
|
||||
}
|
||||
|
||||
&.icon-notify {
|
||||
background: #10aeff;
|
||||
}
|
||||
}
|
||||
|
||||
// 联系人列表
|
||||
.contact-section {
|
||||
background: var(--bg-content);
|
||||
.function-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.function-badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
left: 40rpx;
|
||||
}
|
||||
|
||||
// Tab 切换
|
||||
.tab-bar {
|
||||
position: fixed;
|
||||
top: calc(88rpx + 80rpx + 96rpx + var(--status-bar-height, 0));
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 97;
|
||||
display: flex;
|
||||
background: var(--bg-content);
|
||||
border-bottom: 1rpx solid var(--divider-color);
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60rpx;
|
||||
height: 4rpx;
|
||||
background: var(--color-primary);
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内容滚动区域
|
||||
.content-scroll {
|
||||
position: fixed;
|
||||
top: calc(88rpx + 80rpx + 96rpx + 80rpx + 20rpx + var(--status-bar-height, 0));
|
||||
left: 0;
|
||||
right: 40rpx;
|
||||
bottom: calc(100rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
// 联系人分组
|
||||
.contact-group {
|
||||
background: var(--bg-content);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
padding: 12rpx 30rpx;
|
||||
font-size: 26rpx;
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 30rpx;
|
||||
gap: 12rpx;
|
||||
|
||||
&.letter-header {
|
||||
background: var(--bg-page);
|
||||
padding: 12rpx 30rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.group-contacts {
|
||||
padding-left: 16rpx;
|
||||
}
|
||||
|
||||
// 联系人项
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 30rpx;
|
||||
gap: 20rpx;
|
||||
|
||||
&:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,10 +672,37 @@ function scrollToLetter(letter: string) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8rpx;
|
||||
margin-right: 24rpx;
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
|
||||
&.avatar-group {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contact-desc {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 字母索引
|
||||
@@ -392,4 +738,9 @@ function scrollToLetter(letter: string) {
|
||||
color: var(--text-tertiary);
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
// 底部安全区
|
||||
.bottom-safe-area {
|
||||
height: calc(40rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="requests-page">
|
||||
<view class="requests-page" :class="{ dark: isDark }">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="新朋友" />
|
||||
|
||||
<view v-if="loading" class="loading-state">
|
||||
<wd-loading />
|
||||
<text>加载中...</text>
|
||||
@@ -13,16 +16,12 @@
|
||||
:key="item.id"
|
||||
class="request-item"
|
||||
>
|
||||
<wd-img
|
||||
v-if="item.from_user?.avatar"
|
||||
:src="item.from_user.avatar"
|
||||
width="96rpx"
|
||||
height="96rpx"
|
||||
<app-avatar
|
||||
:src="item.from_user?.avatar"
|
||||
:name="item.from_user?.name"
|
||||
:size="96"
|
||||
radius="8rpx"
|
||||
/>
|
||||
<view v-else class="avatar-placeholder" :style="{ background: generateColor(item.from_user?.name || '') }">
|
||||
{{ item.from_user?.name?.charAt(0) || '?' }}
|
||||
</view>
|
||||
|
||||
<view class="request-info">
|
||||
<text class="name">{{ item.from_user?.name || '未知用户' }}</text>
|
||||
@@ -50,11 +49,15 @@ import { ref, onMounted } from 'vue'
|
||||
import { useContactStore } from '@/stores'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { FriendRequest } from '@/types/api'
|
||||
|
||||
const contactStore = useContactStore()
|
||||
const toast = useToast()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const loading = ref(true)
|
||||
const requests = ref<FriendRequest[]>([])
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="create-page">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="创建群聊" />
|
||||
|
||||
<!-- 群名称 -->
|
||||
<wd-cell-group>
|
||||
<wd-input
|
||||
@@ -34,16 +37,12 @@
|
||||
:model-value="selectedIds.includes(contact.contact_user_id)"
|
||||
@change="toggleSelect(contact)"
|
||||
/>
|
||||
<wd-img
|
||||
v-if="contact.user?.avatar"
|
||||
:src="contact.user.avatar"
|
||||
width="72rpx"
|
||||
height="72rpx"
|
||||
<app-avatar
|
||||
:src="contact.user?.avatar"
|
||||
:name="contact.remark_name || contact.user?.name"
|
||||
:size="72"
|
||||
radius="8rpx"
|
||||
/>
|
||||
<view v-else class="avatar-placeholder" :style="{ background: generateColor(contact.user?.name || '') }">
|
||||
{{ contact.user?.name?.charAt(0) || '?' }}
|
||||
</view>
|
||||
<text class="name">{{ contact.remark_name || contact.user?.name || '未知' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
@@ -72,6 +71,7 @@ import * as contactApi from '@/api/modules/contact'
|
||||
import * as roomApi from '@/api/modules/room'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { Contact } from '@/types/api'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="members-page">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="群成员" />
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<view class="search-section">
|
||||
<wd-search
|
||||
@@ -20,18 +23,16 @@
|
||||
:key="member.user_id"
|
||||
:title="member.nickname || member.user?.name || '未知'"
|
||||
is-link
|
||||
@click="goMemberDetail(member)"
|
||||
>
|
||||
<template #icon>
|
||||
<wd-img
|
||||
v-if="member.user?.avatar"
|
||||
:src="member.user.avatar"
|
||||
width="80rpx"
|
||||
height="80rpx"
|
||||
<view class="member-avatar-wrap">
|
||||
<app-avatar
|
||||
:src="member.user?.avatar"
|
||||
:name="member.nickname || member.user?.name"
|
||||
:size="80"
|
||||
radius="8rpx"
|
||||
custom-style="margin-right: 24rpx;"
|
||||
/>
|
||||
<view v-else class="avatar-placeholder" :style="{ background: generateColor(member.user?.name || '') }">
|
||||
{{ member.user?.name?.charAt(0) || '?' }}
|
||||
</view>
|
||||
</template>
|
||||
<template #value>
|
||||
@@ -48,6 +49,8 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import * as roomApi from '@/api/modules/room'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { GroupMember } from '@/types/api'
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -86,6 +89,11 @@ async function loadMembers() {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goMemberDetail(member: GroupMember) {
|
||||
// 跳转到成员详情页
|
||||
uni.navigateTo({ url: `/pages/contact/detail?userId=${member.user_id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -108,16 +116,7 @@ async function loadMembers() {
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8rpx;
|
||||
.member-avatar-wrap {
|
||||
margin-right: 24rpx;
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="notify-page">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="群通知" />
|
||||
|
||||
<view v-if="loading" class="loading-state">
|
||||
<wd-loading />
|
||||
<text>加载中...</text>
|
||||
|
||||
@@ -5,20 +5,16 @@
|
||||
<view class="navbar-content">
|
||||
<!-- 左侧头像 -->
|
||||
<view class="navbar-left" @click="openDrawer">
|
||||
<wd-img
|
||||
v-if="user?.avatar"
|
||||
:src="user.avatar"
|
||||
width="64rpx"
|
||||
height="64rpx"
|
||||
<app-avatar
|
||||
:src="user?.avatar"
|
||||
:name="user?.name"
|
||||
:size="64"
|
||||
round
|
||||
/>
|
||||
<view v-else class="avatar-placeholder avatar-sm">
|
||||
{{ user?.name?.charAt(0) || '?' }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 标题 -->
|
||||
<view class="navbar-title">消息</view>
|
||||
<view class="navbar-title">{{ user?.name || '消息' }}</view>
|
||||
|
||||
<!-- 右侧加号 -->
|
||||
<view class="navbar-right">
|
||||
@@ -61,24 +57,14 @@
|
||||
<view class="conversation-item" @click="goChat(item)">
|
||||
<!-- 头像 -->
|
||||
<view class="avatar-wrap">
|
||||
<wd-img
|
||||
v-if="item.avatar"
|
||||
<app-avatar
|
||||
:src="item.avatar"
|
||||
width="96rpx"
|
||||
height="96rpx"
|
||||
:name="item.name"
|
||||
:size="96"
|
||||
radius="8rpx"
|
||||
:badge="item.is_muted ? 0 : item.unread_count"
|
||||
:dot="item.is_muted && item.unread_count > 0"
|
||||
/>
|
||||
<view v-else class="avatar-placeholder avatar-md" :style="{ background: generateColor(item.name || '') }">
|
||||
{{ item.name?.charAt(0) || '?' }}
|
||||
</view>
|
||||
<!-- 未读数 -->
|
||||
<wd-badge
|
||||
v-if="item.unread_count > 0 && !item.is_muted"
|
||||
:value="item.unread_count"
|
||||
:max="99"
|
||||
class="unread-badge"
|
||||
/>
|
||||
<view v-else-if="item.unread_count > 0 && item.is_muted" class="unread-dot" />
|
||||
</view>
|
||||
|
||||
<!-- 内容 -->
|
||||
@@ -120,21 +106,18 @@
|
||||
<wd-popup
|
||||
v-model="showDrawer"
|
||||
position="left"
|
||||
custom-style="width: 70%; height: 100%;"
|
||||
custom-style="width: 70%; height: 100%; z-index: 1000;"
|
||||
:z-index="1000"
|
||||
>
|
||||
<view class="drawer-content">
|
||||
<!-- 用户信息 -->
|
||||
<view class="drawer-header">
|
||||
<wd-img
|
||||
v-if="user?.avatar"
|
||||
:src="user.avatar"
|
||||
width="120rpx"
|
||||
height="120rpx"
|
||||
<app-avatar
|
||||
:src="user?.avatar"
|
||||
:name="user?.name"
|
||||
:size="120"
|
||||
round
|
||||
/>
|
||||
<view v-else class="avatar-placeholder avatar-lg">
|
||||
{{ user?.name?.charAt(0) || '?' }}
|
||||
</view>
|
||||
<text class="drawer-name">{{ user?.name || '未登录' }}</text>
|
||||
<text class="drawer-desc">{{ user?.desc || '这个人很懒,什么都没写' }}</text>
|
||||
</view>
|
||||
@@ -143,11 +126,7 @@
|
||||
<view class="drawer-menu">
|
||||
<wd-cell title="个人资料" icon="user" is-link @click="goProfile" />
|
||||
<wd-cell title="设置" icon="setting" is-link @click="goSettings" />
|
||||
<wd-cell title="深色模式" icon="picture" center>
|
||||
<template #value>
|
||||
<wd-switch v-model="isDark" @change="toggleTheme" />
|
||||
</template>
|
||||
</wd-cell>
|
||||
<wd-cell title="深色模式" icon="picture" :value="themeText" is-link @click="goTheme" />
|
||||
</view>
|
||||
|
||||
<!-- 退出登录 -->
|
||||
@@ -160,6 +139,9 @@
|
||||
<!-- Toast 组件 -->
|
||||
<wd-toast />
|
||||
<wd-message-box />
|
||||
|
||||
<!-- 自定义 TabBar -->
|
||||
<app-tab-bar current="messages" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -169,12 +151,26 @@ import { onShow } from '@dcloudio/uni-app'
|
||||
import { useAuthStore, useConversationStore } from '@/stores'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { formatMessageTime, generateColor } from '@/utils/format'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
import { useToast, useMessage } from 'wot-design-uni'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import AppTabBar from '@/components/common/AppTabBar.vue'
|
||||
import type { Conversation } from '@/types/conversation'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const conversationStore = useConversationStore()
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
// 主题显示文本
|
||||
const themeText = computed(() => {
|
||||
const mode = uni.getStorageSync('nl_im_theme_mode') || 'system'
|
||||
const texts: Record<string, string> = {
|
||||
system: '跟随系统',
|
||||
light: '浅色模式',
|
||||
dark: '深色模式'
|
||||
}
|
||||
return texts[mode] || '跟随系统'
|
||||
})
|
||||
const toast = useToast()
|
||||
const messageBox = useMessage()
|
||||
|
||||
@@ -227,7 +223,7 @@ function goSearch() {
|
||||
function goChat(item: Conversation) {
|
||||
conversationStore.clearUnread(item.target_id)
|
||||
uni.navigateTo({
|
||||
url: `/pages/chat/index?roomId=${item.room_id || ''}&targetId=${item.target_id}&name=${encodeURIComponent(item.name || '')}`
|
||||
url: `/pages/chat/index?roomId=${item.room_id || ''}&targetId=${item.target_id}&name=${encodeURIComponent(item.name || '')}&avatar=${encodeURIComponent(item.avatar || '')}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -284,6 +280,11 @@ function goSettings() {
|
||||
uni.navigateTo({ url: '/pages/settings/index' })
|
||||
}
|
||||
|
||||
function goTheme() {
|
||||
showDrawer.value = false
|
||||
uni.navigateTo({ url: '/pages/settings/theme' })
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await messageBox.confirm({
|
||||
@@ -354,7 +355,7 @@ async function logout() {
|
||||
top: calc(88rpx + 80rpx + var(--status-bar-height, 0));
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
bottom: calc(100rpx + env(safe-area-inset-bottom));
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
@@ -492,13 +493,15 @@ async function logout() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-page);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 80rpx 30rpx 40rpx;
|
||||
padding: 40rpx 30rpx 40rpx;
|
||||
padding-top: calc(40rpx + var(--status-bar-height, 44px));
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="login-page">
|
||||
<view class="login-page" :class="{ dark: isDark }">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="登录" :left-arrow="false" />
|
||||
|
||||
<!-- Logo -->
|
||||
<view class="logo-section">
|
||||
<view class="logo">
|
||||
@@ -75,10 +78,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { useToast, useMessage } from 'wot-design-uni'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
const message = useMessage()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
@@ -107,7 +112,7 @@ async function handleLogin() {
|
||||
})
|
||||
toast.success('登录成功')
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
}, 1000)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '登录失败')
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="detail-page">
|
||||
<view class="detail-page" :class="{ dark: isDark }">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="动态详情" />
|
||||
|
||||
<view v-if="loading" class="loading">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
@@ -122,9 +125,12 @@ import { ref, onMounted } from 'vue'
|
||||
import { useMomentStore } from '@/stores'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import { parseMediaUrls } from '@/types/moment'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { Moment } from '@/types/moment'
|
||||
|
||||
const momentStore = useMomentStore()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const loading = ref(true)
|
||||
const moment = ref<Moment | null>(null)
|
||||
@@ -185,7 +191,7 @@ async function sendComment() {
|
||||
<style lang="scss" scoped>
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
background: #ededed;
|
||||
background: var(--bg-page);
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
@@ -194,11 +200,11 @@ async function sendComment() {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 60rpx;
|
||||
color: #999;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.moment-content {
|
||||
background: #fff;
|
||||
background: var(--bg-content);
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
@@ -217,7 +223,7 @@ async function sendComment() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #07c160;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
@@ -226,14 +232,14 @@ async function sendComment() {
|
||||
.name {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
color: #576b95;
|
||||
color: var(--text-link);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
}
|
||||
@@ -241,7 +247,7 @@ async function sendComment() {
|
||||
|
||||
.content {
|
||||
font-size: 32rpx;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
@@ -280,7 +286,7 @@ async function sendComment() {
|
||||
|
||||
text {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.liked {
|
||||
color: #fa5151;
|
||||
@@ -291,19 +297,19 @@ async function sendComment() {
|
||||
}
|
||||
|
||||
.comments-section {
|
||||
background: #fff;
|
||||
background: var(--bg-content);
|
||||
padding: 30rpx;
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.empty-comments {
|
||||
text-align: center;
|
||||
padding: 40rpx;
|
||||
color: #999;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +328,7 @@ async function sendComment() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #07c160;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
@@ -337,21 +343,21 @@ async function sendComment() {
|
||||
|
||||
.name {
|
||||
font-size: 28rpx;
|
||||
color: #576b95;
|
||||
color: var(--text-link);
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
|
||||
.reply-to {
|
||||
color: #576b95;
|
||||
color: var(--text-link);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,16 +372,17 @@ async function sendComment() {
|
||||
align-items: center;
|
||||
padding: 16rpx 20rpx;
|
||||
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
background: var(--bg-content);
|
||||
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: #f5f5f5;
|
||||
background: var(--bg-page);
|
||||
border-radius: 36rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
@@ -383,7 +390,7 @@ async function sendComment() {
|
||||
padding: 0 32rpx;
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
background: #07c160;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
border-radius: 36rpx;
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<view class="user-info">
|
||||
<wd-img
|
||||
v-if="user?.avatar"
|
||||
:src="user.avatar"
|
||||
:src="resolveImageUrl(user.avatar)"
|
||||
width="120rpx"
|
||||
height="120rpx"
|
||||
radius="8rpx"
|
||||
@@ -59,7 +59,7 @@
|
||||
<view class="moment-avatar">
|
||||
<wd-img
|
||||
v-if="moment.user?.avatar"
|
||||
:src="moment.user.avatar"
|
||||
:src="resolveImageUrl(moment.user.avatar)"
|
||||
width="80rpx"
|
||||
height="80rpx"
|
||||
radius="8rpx"
|
||||
@@ -84,20 +84,20 @@
|
||||
<wd-img
|
||||
v-for="(url, index) in parseMediaUrls(moment.media_urls)"
|
||||
:key="index"
|
||||
:src="url"
|
||||
:src="resolveImageUrl(url)"
|
||||
:width="getImageSize(moment)"
|
||||
:height="getImageSize(moment)"
|
||||
mode="aspectFill"
|
||||
radius="8rpx"
|
||||
enable-preview
|
||||
:preview-src-list="parseMediaUrls(moment.media_urls)"
|
||||
:preview-src-list="parseMediaUrls(moment.media_urls).map(resolveImageUrl)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 视频 -->
|
||||
<view v-if="moment.media_type === 2" class="moment-video">
|
||||
<video
|
||||
:src="parseMediaUrls(moment.media_urls)[0]"
|
||||
:src="resolveImageUrl(parseMediaUrls(moment.media_urls)[0])"
|
||||
class="video-player"
|
||||
object-fit="cover"
|
||||
:show-fullscreen-btn="true"
|
||||
@@ -160,6 +160,9 @@
|
||||
|
||||
<wd-toast />
|
||||
<wd-message-box />
|
||||
|
||||
<!-- 自定义 TabBar -->
|
||||
<app-tab-bar current="moments" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -169,8 +172,10 @@ import { onShow } from '@dcloudio/uni-app'
|
||||
import { useAuthStore, useMomentStore } from '@/stores'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { formatTime, generateColor } from '@/utils/format'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
import { parseMediaUrls } from '@/types/moment'
|
||||
import { useToast, useMessage } from 'wot-design-uni'
|
||||
import AppTabBar from '@/components/common/AppTabBar.vue'
|
||||
import type { Moment } from '@/types/moment'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
@@ -362,7 +367,7 @@ function getImageSize(moment: Moment): string {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
bottom: calc(100rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
// 封面区域
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="notify-page">
|
||||
<view class="notify-page" :class="{ dark: isDark }">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="消息通知" />
|
||||
|
||||
<view v-if="loading && notifications.length === 0" class="loading-state">
|
||||
<wd-loading />
|
||||
<text>加载中...</text>
|
||||
@@ -67,10 +70,13 @@ import { useMomentStore } from '@/stores'
|
||||
import { formatTime, generateColor } from '@/utils/format'
|
||||
import { parseMediaUrls } from '@/types/moment'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { MomentNotification } from '@/types/moment'
|
||||
|
||||
const momentStore = useMomentStore()
|
||||
const toast = useToast()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const loadState = ref<'loading' | 'finished' | 'error'>('loading')
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="publish-page">
|
||||
<view class="publish-page" :class="{ dark: isDark }">
|
||||
<!-- 导航栏 -->
|
||||
<wd-navbar
|
||||
title="发布动态"
|
||||
@@ -88,9 +88,11 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMomentStore } from '@/stores'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const momentStore = useMomentStore()
|
||||
const toast = useToast()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
// 表单数据
|
||||
const content = ref('')
|
||||
@@ -311,3 +313,10 @@ async function publish() {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="edit-page">
|
||||
<view class="edit-page" :class="{ dark: isDark }">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="编辑资料" />
|
||||
|
||||
<view class="form-item">
|
||||
<input
|
||||
v-model="value"
|
||||
@@ -15,7 +18,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const field = ref('')
|
||||
const value = ref('')
|
||||
const placeholder = ref('')
|
||||
@@ -65,7 +70,7 @@ function save() {
|
||||
<style lang="scss" scoped>
|
||||
.edit-page {
|
||||
padding: 30rpx;
|
||||
background: #fff;
|
||||
background: var(--bg-content);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -75,9 +80,10 @@ function save() {
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 24rpx;
|
||||
background: #f5f5f5;
|
||||
background: var(--bg-page);
|
||||
border-radius: 12rpx;
|
||||
font-size: 32rpx;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.counter {
|
||||
@@ -85,13 +91,13 @@ function save() {
|
||||
right: 24rpx;
|
||||
bottom: 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
margin-top: 60rpx;
|
||||
background: #07c160;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
border-radius: 12rpx;
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<template>
|
||||
<view class="profile-page">
|
||||
<view class="profile-page" :class="{ dark: isDark }">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="个人资料" />
|
||||
|
||||
<!-- 头像区域 -->
|
||||
<view class="header-section">
|
||||
<view class="avatar-wrap" @click="changeAvatar">
|
||||
<wd-img
|
||||
v-if="user?.avatar"
|
||||
:src="user.avatar"
|
||||
width="160rpx"
|
||||
height="160rpx"
|
||||
<app-avatar
|
||||
:src="user?.avatar"
|
||||
:name="user?.name"
|
||||
:size="160"
|
||||
round
|
||||
/>
|
||||
<view v-else class="avatar-placeholder">
|
||||
{{ user?.name?.charAt(0) || '?' }}
|
||||
</view>
|
||||
<view class="change-tip">点击更换</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -39,9 +38,13 @@
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="search-page">
|
||||
<view class="search-page" :class="{ dark: isDark }">
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-header safe-area-top">
|
||||
<wd-search
|
||||
@@ -50,16 +50,13 @@
|
||||
@click="goDetail(item)"
|
||||
>
|
||||
<template #icon>
|
||||
<wd-img
|
||||
v-if="item.avatar"
|
||||
<view class="result-avatar">
|
||||
<app-avatar
|
||||
:src="item.avatar"
|
||||
width="80rpx"
|
||||
height="80rpx"
|
||||
:name="item.name"
|
||||
:size="80"
|
||||
radius="8rpx"
|
||||
custom-style="margin-right: 24rpx;"
|
||||
/>
|
||||
<view v-else class="avatar-placeholder" :style="{ background: generateColor(item.name || '') }">
|
||||
{{ item.name?.charAt(0) || '?' }}
|
||||
</view>
|
||||
</template>
|
||||
</wd-cell>
|
||||
@@ -72,8 +69,11 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import { generateColor } from '@/utils/format'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
import type { User } from '@/types/api'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const keyword = ref('')
|
||||
const loading = ref(false)
|
||||
const searched = ref(false)
|
||||
@@ -214,16 +214,7 @@ function goDetail(item: User) {
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8rpx;
|
||||
.result-avatar {
|
||||
margin-right: 24rpx;
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="settings-page">
|
||||
<view class="settings-page" :class="{ dark: isDark }">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="设置" />
|
||||
|
||||
<wd-cell-group title="账号设置">
|
||||
<wd-cell title="个人资料" icon="user" is-link @click="goProfile" />
|
||||
<wd-cell title="账号与安全" icon="shield" is-link @click="goSecurity" />
|
||||
@@ -8,11 +11,7 @@
|
||||
<wd-cell-group title="通用设置">
|
||||
<wd-cell title="消息通知" icon="bell" is-link @click="goNotification" />
|
||||
<wd-cell title="隐私设置" icon="eye-close" is-link @click="goPrivacy" />
|
||||
<wd-cell title="深色模式" icon="picture" center>
|
||||
<template #value>
|
||||
<wd-switch v-model="isDark" @change="toggleTheme" />
|
||||
</template>
|
||||
</wd-cell>
|
||||
<wd-cell title="深色模式" icon="picture" :value="themeText" is-link @click="goTheme" />
|
||||
<wd-cell title="清除缓存" icon="delete" :value="cacheSize" is-link @click="clearCache" />
|
||||
</wd-cell-group>
|
||||
|
||||
@@ -33,18 +32,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useToast, useMessage } from 'wot-design-uni'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const { isDark } = useTheme()
|
||||
const toast = useToast()
|
||||
const messageBox = useMessage()
|
||||
|
||||
const cacheSize = ref('0 KB')
|
||||
|
||||
// 主题显示文本
|
||||
const themeText = computed(() => {
|
||||
const mode = uni.getStorageSync('nl_im_theme_mode') || 'system'
|
||||
const texts: Record<string, string> = {
|
||||
system: '跟随系统',
|
||||
light: '浅色模式',
|
||||
dark: '深色模式'
|
||||
}
|
||||
return texts[mode] || '跟随系统'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
calculateCacheSize()
|
||||
})
|
||||
@@ -79,6 +89,10 @@ function goPrivacy() {
|
||||
toast.show('隐私设置功能开发中')
|
||||
}
|
||||
|
||||
function goTheme() {
|
||||
uni.navigateTo({ url: '/pages/settings/theme' })
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
try {
|
||||
await messageBox.confirm({
|
||||
|
||||
127
src/pages/settings/theme.vue
Normal file
127
src/pages/settings/theme.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<view class="theme-page" :class="{ dark: isDark }">
|
||||
<!-- 导航栏 -->
|
||||
<app-nav-bar title="深色模式" />
|
||||
|
||||
<wd-cell-group title="选择主题模式">
|
||||
<wd-cell
|
||||
title="跟随系统"
|
||||
center
|
||||
clickable
|
||||
@click="selectTheme('system')"
|
||||
>
|
||||
<template #icon>
|
||||
<wd-icon name="setting" size="40rpx" class="cell-icon" />
|
||||
</template>
|
||||
<template #value>
|
||||
<wd-icon v-if="currentTheme === 'system'" name="check" size="40rpx" color="var(--color-primary)" />
|
||||
</template>
|
||||
</wd-cell>
|
||||
<wd-cell
|
||||
title="浅色模式"
|
||||
center
|
||||
clickable
|
||||
@click="selectTheme('light')"
|
||||
>
|
||||
<template #icon>
|
||||
<wd-icon name="sun" size="40rpx" class="cell-icon" />
|
||||
</template>
|
||||
<template #value>
|
||||
<wd-icon v-if="currentTheme === 'light'" name="check" size="40rpx" color="var(--color-primary)" />
|
||||
</template>
|
||||
</wd-cell>
|
||||
<wd-cell
|
||||
title="深色模式"
|
||||
center
|
||||
clickable
|
||||
@click="selectTheme('dark')"
|
||||
>
|
||||
<template #icon>
|
||||
<wd-icon name="moon" size="40rpx" class="cell-icon" />
|
||||
</template>
|
||||
<template #value>
|
||||
<wd-icon v-if="currentTheme === 'dark'" name="check" size="40rpx" color="var(--color-primary)" />
|
||||
</template>
|
||||
</wd-cell>
|
||||
</wd-cell-group>
|
||||
|
||||
<view class="tips">
|
||||
<text>「跟随系统」会根据系统设置自动切换深色/浅色模式</text>
|
||||
</view>
|
||||
|
||||
<wd-toast />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
|
||||
const { isDark, setTheme: applyTheme } = useTheme()
|
||||
const toast = useToast()
|
||||
|
||||
// 当前选中的主题模式
|
||||
const currentTheme = ref<'system' | 'light' | 'dark'>('system')
|
||||
|
||||
onMounted(() => {
|
||||
// 从本地存储读取当前主题设置
|
||||
const saved = uni.getStorageSync('nl_im_theme_mode')
|
||||
if (saved === 'light' || saved === 'dark' || saved === 'system') {
|
||||
currentTheme.value = saved
|
||||
}
|
||||
})
|
||||
|
||||
function selectTheme(mode: 'system' | 'light' | 'dark') {
|
||||
console.log('selectTheme called:', mode)
|
||||
currentTheme.value = mode
|
||||
uni.setStorageSync('nl_im_theme_mode', mode)
|
||||
|
||||
// 应用主题
|
||||
if (mode === 'system') {
|
||||
// 跟随系统 - 清除手动设置,不持久化
|
||||
uni.removeStorageSync('nl_im_theme')
|
||||
let systemDark = false
|
||||
try {
|
||||
systemDark = uni.getSystemInfoSync().theme === 'dark'
|
||||
} catch (e) {
|
||||
console.log('获取系统主题失败:', e)
|
||||
}
|
||||
console.log('系统深色模式:', systemDark)
|
||||
applyTheme(systemDark, false) // 不持久化
|
||||
} else {
|
||||
console.log('设置主题:', mode === 'dark' ? '深色' : '浅色')
|
||||
applyTheme(mode === 'dark', true) // 持久化
|
||||
}
|
||||
|
||||
const tips = {
|
||||
system: '已设置为跟随系统',
|
||||
light: '已切换为浅色模式',
|
||||
dark: '已切换为深色模式'
|
||||
}
|
||||
toast.success(tips[mode])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.theme-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.cell-icon {
|
||||
margin-right: 16rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tips {
|
||||
padding: 30rpx;
|
||||
|
||||
text {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
placeholder
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
placeholder
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
placeholder
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
placeholder
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
placeholder
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
placeholder
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
placeholder
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -179,3 +179,10 @@
|
||||
box-shadow: 0 4rpx 20rpx rgba(7, 193, 96, 0.4);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -61,8 +61,11 @@ page {
|
||||
--switch-off: #e5e5e5;
|
||||
}
|
||||
|
||||
// 暗色主题
|
||||
page.dark {
|
||||
// 暗色主题 (支持多种选择器以兼容 H5)
|
||||
page.dark,
|
||||
.dark,
|
||||
html.dark,
|
||||
body.dark {
|
||||
// 背景色
|
||||
--bg-page: #111111;
|
||||
--bg-content: #1e1e1e;
|
||||
@@ -99,3 +102,10 @@ page.dark {
|
||||
// 开关
|
||||
--switch-off: #444444;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
36
src/utils/image.ts
Normal file
36
src/utils/image.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 图片 URL 处理工具函数
|
||||
*/
|
||||
|
||||
// 图片资源基础域名
|
||||
const IMAGE_BASE_URL = 'http://127.0.0.1:12080'
|
||||
|
||||
/**
|
||||
* 解析图片 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
|
||||
}
|
||||
|
||||
// 其他情况直接返回
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片基础 URL
|
||||
*/
|
||||
export function getImageBaseUrl(): string {
|
||||
return IMAGE_BASE_URL
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './storage'
|
||||
export * from './format'
|
||||
export * from './message'
|
||||
export * from './platform'
|
||||
export * from './image'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import Components from '@uni-helper/vite-plugin-uni-components'
|
||||
import { WotResolver } from '@uni-helper/vite-plugin-uni-components/resolvers'
|
||||
import UniKuRoot from '@uni-ku/root'
|
||||
import Uni from '@uni-helper/plugin-uni'
|
||||
|
||||
export default defineConfig({
|
||||
@@ -17,6 +18,8 @@ export default defineConfig({
|
||||
dts: true,
|
||||
resolvers: [WotResolver()]
|
||||
}),
|
||||
// https://github.com/niceyou/uni-ku-root - 全局共享 ConfigProvider
|
||||
UniKuRoot(),
|
||||
// https://uni-helper.js.org/plugin-uni
|
||||
Uni(),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user