初始化

This commit is contained in:
2025-12-09 12:49:28 +08:00
parent 2c3e9b248d
commit 5031d694ac
67 changed files with 8431 additions and 630 deletions

16
components.d.ts vendored
View File

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

View File

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

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

View File

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

View File

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

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

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

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

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

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

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

View File

@@ -105,3 +105,10 @@ const bubbleClass = computed(() => {
}
}
</style>

View File

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

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

View File

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

View File

@@ -1,7 +1,7 @@
<template>
<view class="app-empty">
<wd-status-tip :image="image" :tip="description" />
<slot />
<slot />
</view>
</template>

View File

@@ -48,3 +48,10 @@ withDefaults(defineProps<Props>(), {
color: var(--text-tertiary);
}
</style>

View File

@@ -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" />
</template>
<template v-else-if="showAvatar">
<wd-img
v-if="avatarUrl"
:src="avatarUrl"
width="64rpx"
height="64rpx"
round
/>
<view v-else class="avatar-placeholder">
{{ avatarText }}
</view>
</template>
</slot>
</view>
<!-- 标题 -->
<view class="navbar-title">
<slot name="title">
<text class="title-text">{{ title }}</text>
</slot>
</view>
<!-- 右侧 -->
<view class="navbar-right" @click="onRightClick">
<slot name="right" />
</view>
</view>
</view>
<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-if="$slots.title" #title>
<slot name="title" />
</template>
<template v-if="$slots.right" #right>
<slot name="right" />
</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>

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

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

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

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

View File

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

View File

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

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

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

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

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

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

View File

@@ -1 +1,10 @@
export { useTheme } from './useTheme'
export { useWebRTC } from './useWebRTC'
export { useGroupWebRTC } from './useGroupWebRTC'

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

View File

@@ -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
uni.setStorageSync('nl_im_theme', dark ? 'dark' : 'light')
if (persist) {
uni.setStorageSync('nl_im_theme', dark ? 'dark' : 'light')
}
applyTheme()
}
@@ -64,50 +80,85 @@ 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
// 设置导航栏颜色
try {
uni.setNavigationBarColor({
frontColor: isDark.value ? '#ffffff' : '#000000',
backgroundColor: isDark.value ? '#1a1a1a' : '#ededed',
animation: {
duration: 200,
timingFunc: 'easeIn'
}
})
} catch {
// 忽略错误
}
// 设置导航栏颜色(延迟执行,确保页面已加载)
setTimeout(() => {
try {
uni.setNavigationBarColor({
frontColor: isDark.value ? '#ffffff' : '#000000',
backgroundColor: isDark.value ? '#1a1a1a' : '#ededed',
animation: {
duration: 200,
timingFunc: 'easeIn'
},
fail: () => {
// 静默忽略 "page not found" 错误
}
})
} catch {
// 忽略错误
}
}, 100)
// 设置 TabBar 样式
try {
uni.setTabBarStyle({
color: '#999999',
selectedColor: '#07c160',
backgroundColor: isDark.value ? '#1a1a1a' : '#f7f7f7',
borderStyle: isDark.value ? 'black' : 'white'
})
} catch {
// 忽略错误
}
// 设置 TabBar 样式(延迟执行,确保页面已加载)
setTimeout(() => {
try {
uni.setTabBarStyle({
color: '#999999',
selectedColor: '#07c160',
backgroundColor: isDark.value ? '#1a1a1a' : '#f7f7f7',
borderStyle: isDark.value ? 'black' : 'white',
fail: () => {
// 静默忽略 "not TabBar page" 错误
}
})
} catch {
// 忽略错误
}
}, 100)
}
return {
theme,
isDark,
initTheme,
toggleTheme,
onThemeChange,
setTheme
}
}
export default useTheme

View 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

View File

@@ -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": []
}

View File

@@ -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>
<wd-icon name="more" size="44rpx" @click="showChatInfo" />
<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(true)
}
messages.value.push(newMsg)
chatStore.addMessage(roomId.value, newMsg)
scrollToBottom()
}
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 {

View File

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

View File

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

View File

@@ -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 v-if="loading" class="loading-state">
<wd-loading />
<text>加载中...</text>
</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="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 groupedContacts"
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
v-for="contact in group.contacts"
:key="contact.id"
:title="contact.remark_name || contact.user?.name || '未知'"
:label="contact.user?.desc"
is-link
@click="goContactDetail(contact)"
>
<template #icon>
<wd-img
v-if="contact.user?.avatar"
:src="contact.user.avatar"
width="80rpx"
height="80rpx"
<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"
class="contact-item"
@click="goContactDetail(contact)"
>
<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>
</template>
</wd-cell>
</view>
</view>
</view>
</template>
</view>
</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>
</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 {
.group-header {
padding: 12rpx 30rpx;
font-size: 26rpx;
color: var(--text-tertiary);
background: var(--bg-content);
margin-bottom: 16rpx;
}
.group-header {
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>

View File

@@ -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[]>([])

View File

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

View File

@@ -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"
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 class="member-avatar-wrap">
<app-avatar
:src="member.user?.avatar"
:name="member.nickname || member.user?.name"
:size="80"
radius="8rpx"
/>
</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>

View File

@@ -1,5 +1,8 @@
<template>
<view class="notify-page">
<!-- 导航栏 -->
<app-nav-bar title="群通知" />
<view v-if="loading" class="loading-state">
<wd-loading />
<text>加载中...</text>

View File

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

View File

@@ -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 || '登录失败')

View File

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

View File

@@ -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));
}
// 封面区域

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
:src="item.avatar"
width="80rpx"
height="80rpx"
radius="8rpx"
custom-style="margin-right: 24rpx;"
/>
<view v-else class="avatar-placeholder" :style="{ background: generateColor(item.name || '') }">
{{ item.name?.charAt(0) || '?' }}
<view class="result-avatar">
<app-avatar
:src="item.avatar"
:name="item.name"
:size="80"
radius="8rpx"
/>
</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>

View File

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

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

View File

@@ -1 +1,7 @@
placeholder
placeholder

View File

@@ -1 +1,7 @@
placeholder
placeholder

View File

@@ -1 +1,7 @@
placeholder
placeholder

View File

@@ -1 +1,7 @@
placeholder
placeholder

View File

@@ -1 +1,7 @@
placeholder
placeholder

View File

@@ -1 +1,7 @@
placeholder
placeholder

View File

@@ -1 +1,7 @@
placeholder
placeholder

View File

@@ -179,3 +179,10 @@
box-shadow: 0 4rpx 20rpx rgba(7, 193, 96, 0.4);
z-index: 99;
}

View File

@@ -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
View 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 ''
// 已经是完整 URLhttp/https/data URI
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
return url
}
// 仅处理 /upload/ 开头的路径
if (url.startsWith('/uploads/')) {
return IMAGE_BASE_URL + url
}
// 其他情况直接返回
return url
}
/**
* 获取图片基础 URL
*/
export function getImageBaseUrl(): string {
return IMAGE_BASE_URL
}

View File

@@ -5,3 +5,4 @@ export * from './storage'
export * from './format'
export * from './message'
export * from './platform'
export * from './image'

View File

@@ -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(),
],