This commit is contained in:
2025-12-11 14:46:00 +08:00
parent 771cd97031
commit 80d5c1448f
34 changed files with 6480 additions and 1981 deletions

3
components.d.ts vendored
View File

@@ -8,6 +8,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AppAvatar: typeof import('./src/components/common/AppAvatar.vue')['default']
AppDrawer: typeof import('./src/components/common/AppDrawer.vue')['default']
AppEmpty: typeof import('./src/components/common/AppEmpty.vue')['default']
AppFooter: typeof import('./src/components/AppFooter.vue')['default']
AppLoading: typeof import('./src/components/common/AppLoading.vue')['default']
@@ -15,6 +16,7 @@ declare module 'vue' {
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']
GlobalCallProvider: typeof import('./src/components/call/GlobalCallProvider.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']
@@ -28,6 +30,7 @@ declare module 'vue' {
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']
PlusMenu: typeof import('./src/components/common/PlusMenu.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']

View File

@@ -3,11 +3,13 @@ import { onLaunch, onShow } from '@dcloudio/uni-app'
import { useTheme } from '@/composables/useTheme'
import { useAuthStore, useConversationStore } from '@/stores'
import { wsManager } from '@/api/websocket'
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
import type { ChatMessage } from '@/types/api'
const { initTheme } = useTheme()
const authStore = useAuthStore()
const conversationStore = useConversationStore()
const groupWebRTC = useGroupWebRTC()
// 全局消息处理器
function handleGlobalMessage(message: ChatMessage) {
@@ -28,6 +30,8 @@ async function initWebSocket() {
await wsManager.connect(userId)
// 注册全局消息处理器
wsManager.onMessage(handleGlobalMessage)
// 初始化群通话信令监听器
groupWebRTC.initListener()
console.log('✅ WebSocket 全局初始化成功')
} catch (error) {
console.error('WebSocket 初始化失败:', error)

View File

@@ -1,27 +1,36 @@
<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 v-if="call.minimized" class="minimized-bar" @click="$emit('toggleMinimize')">
<view class="pulse-dot"></view>
<svg class="mini-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path v-if="call.type === 'video'" d="M23 7l-7 5 7 5V7z M16 5H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/>
<path v-else d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
<text class="mini-duration">{{ formatDuration(call.duration) }}</text>
</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 class="blur-bg">
<image v-if="call.callerAvatar" :src="resolveImageUrl(call.callerAvatar)" class="blur-img" mode="aspectFill" />
<view class="blur-overlay"></view>
</view>
<!-- 最小化按钮 (右上角) -->
<view class="minimize-btn" @click="$emit('toggleMinimize')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 14 10 14 10 20"/>
<polyline points="20 10 14 10 14 4"/>
<line x1="14" y1="10" x2="21" y2="3"/>
<line x1="3" y1="21" x2="10" y2="14"/>
</svg>
</view>
<!-- 视频区域 -->
<view v-if="call.type === 'video'" class="video-area">
<!-- 远程视频 -->
<video
v-if="remoteStream"
ref="remoteVideoRef"
@@ -30,11 +39,14 @@
playsinline
/>
<view v-else class="video-placeholder">
<app-avatar :src="call.callerAvatar" :name="call.callerName" :size="160" />
<text class="name">{{ call.callerName || '对方' }}</text>
<view class="avatar-section">
<view class="avatar-ring ring-1"></view>
<view class="avatar-ring ring-2"></view>
<app-avatar :src="call.callerAvatar" :name="call.callerName" :size="240" round />
</view>
<text class="caller-name">{{ call.callerName || '对方' }}</text>
</view>
<!-- 本地视频(小窗口) -->
<video
v-if="localStream && !call.camOff"
ref="localVideoRef"
@@ -45,41 +57,93 @@
/>
</view>
<!-- 语音通话 - 头像显示 -->
<!-- 语音通话 / 视频占位 -->
<view v-else class="audio-area">
<app-avatar :src="call.callerAvatar" :name="call.callerName" :size="200" />
<view class="avatar-section">
<view class="avatar-ring ring-ping"></view>
<view class="avatar-container">
<app-avatar :src="call.callerAvatar" :name="call.callerName" :size="256" round />
</view>
</view>
<text class="caller-name">{{ call.callerName || '对方' }}</text>
<text class="call-status-text">{{ call.statusText }}</text>
<text v-if="call.status === 'connected'" class="call-duration">{{ formatDuration(call.duration) }}</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 class="action-item" @click="$emit('reject')">
<view class="control-btn decline">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M23 7l-7 5 7 5V7z M16 5H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/>
</svg>
</view>
<text class="action-label">拒绝</text>
</view>
<view class="control-btn accept" @click="$emit('accept')">
<wd-icon name="phone" size="48rpx" color="#fff" />
<view class="action-item" @click="$emit('accept')">
<view class="control-btn accept">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
</view>
<text class="action-label">接听</text>
</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 class="action-item" @click="$emit('toggleMute')">
<view class="control-btn" :class="{ active: call.muted }">
<svg v-if="call.muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</view>
<text class="action-label">{{ call.muted ? '已静音' : '静音' }}</text>
</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 v-if="call.type === 'video'" class="action-item" @click="$emit('toggleCamera')">
<view class="control-btn" :class="{ active: call.camOff }">
<svg v-if="call.camOff" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
</view>
<text class="action-label">{{ call.camOff ? '已关闭' : '摄像头' }}</text>
</view>
<view class="control-btn hangup" @click="$emit('end')">
<wd-icon name="phone" size="48rpx" color="#fff" />
<view class="action-item" @click="$emit('end')">
<view class="control-btn hangup">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M23 7l-7 5 7 5V7z M16 5H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/>
</svg>
</view>
<text class="action-label">挂断</text>
</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 class="action-item" @click="$emit('end')">
<view class="control-btn hangup">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M23 7l-7 5 7 5V7z M16 5H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/>
</svg>
</view>
<text class="action-label">挂断</text>
</view>
</template>
</view>
@@ -98,6 +162,7 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import AppAvatar from '@/components/common/AppAvatar.vue'
import { resolveImageUrl } from '@/utils/image'
import type { CallState } from '@/composables/useWebRTC'
interface Props {
@@ -118,11 +183,9 @@ defineEmits<{
(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(() => {
@@ -133,7 +196,6 @@ watch(() => props.localStream, (stream) => {
// #endif
}, { immediate: true })
// 监听 remoteStream 变化,设置 srcObject
watch(() => props.remoteStream, (stream) => {
// #ifdef H5
nextTick(() => {
@@ -143,10 +205,6 @@ watch(() => props.remoteStream, (stream) => {
})
// #endif
}, { immediate: true })
function toggleMinimize() {
// 注意:这里需要通过 emit 触发,因为状态在父组件管理
}
</script>
<style lang="scss" scoped>
@@ -157,30 +215,54 @@ function toggleMinimize() {
right: 0;
bottom: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.95);
background: #111;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&.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);
top: calc(var(--status-bar-height, 0) + 240rpx);
right: 32rpx;
left: auto;
bottom: auto;
width: 200rpx;
height: 280rpx;
border-radius: 32rpx;
border: 2rpx solid rgba(255, 255, 255, 0.15);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.4);
overflow: hidden;
}
}
.minimized-bar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
padding: 0 24rpx;
color: #fff;
background: #2c2c2e;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
cursor: pointer;
.call-duration {
font-size: 28rpx;
.pulse-dot {
position: absolute;
bottom: 16rpx;
width: 8rpx;
height: 8rpx;
background: #10b981;
border-radius: 50%;
animation: pulse 2s infinite;
}
.mini-icon {
width: 64rpx;
height: 64rpx;
color: #10b981;
}
.mini-duration {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
font-family: monospace;
}
}
@@ -188,37 +270,61 @@ function toggleMinimize() {
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}
.header {
.blur-bg {
position: absolute;
inset: 0;
overflow: hidden;
.blur-img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.4;
filter: blur(40rpx);
transform: scale(1.1);
}
.blur-overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, transparent 30%, rgba(0,0,0,0.9) 100%);
}
}
.minimize-btn {
position: absolute;
top: calc(28rpx + var(--status-bar-height, 0));
right: 40rpx;
z-index: 20;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
padding-top: calc(40rpx + var(--status-bar-height, 0));
position: relative;
transition: all 0.15s;
.status-text {
font-size: 32rpx;
svg {
width: 40rpx;
height: 40rpx;
color: #fff;
}
.duration {
margin-left: 20rpx;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.7);
}
.minimize-btn {
position: absolute;
right: 40rpx;
color: #fff;
&:active {
transform: scale(0.9);
background: rgba(255, 255, 255, 0.2);
}
}
.video-area {
flex: 1;
position: relative;
z-index: 5;
.remote-video {
width: 100%;
@@ -226,88 +332,169 @@ function toggleMinimize() {
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;
border-radius: 24rpx;
object-fit: cover;
border: 4rpx solid rgba(255, 255, 255, 0.3);
border: 4rpx solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.3);
transform: scaleX(-1);
}
}
.audio-area {
.video-placeholder, .audio-area {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 30rpx;
position: relative;
z-index: 5;
padding-top: 200rpx;
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.caller-name {
font-size: 40rpx;
color: #fff;
font-weight: 600;
.avatar-section {
position: relative;
margin-bottom: 48rpx;
.avatar-ring {
position: absolute;
border-radius: 50%;
border: 2rpx solid rgba(255, 255, 255, 0.1);
&.ring-ping {
inset: -4rpx;
border: 2rpx solid rgba(255, 255, 255, 0.2);
animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
}
.avatar-container {
border: 4rpx solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
padding: 4rpx;
box-shadow: 0 40rpx 100rpx rgba(0, 0, 0, 0.4);
}
}
.caller-name {
font-size: 60rpx;
font-weight: 700;
color: #fff;
letter-spacing: -1rpx;
margin-bottom: 16rpx;
}
.call-status-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.6);
font-weight: 500;
letter-spacing: 0.5rpx;
}
.call-duration {
font-size: 40rpx;
font-family: monospace;
color: rgba(255, 255, 255, 0.9);
margin-top: 32rpx;
}
.controls {
position: relative;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
gap: 60rpx;
gap: 80rpx;
padding: 60rpx;
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
padding-bottom: calc(128rpx + env(safe-area-inset-bottom));
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.control-btn {
width: 100rpx;
height: 100rpx;
width: 128rpx;
height: 128rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
border: 2rpx solid rgba(255, 255, 255, 0.1);
color: #fff;
transition: all 0.3s;
svg {
width: 56rpx;
height: 56rpx;
}
&:active { transform: scale(0.95); }
&.active {
background: rgba(255, 255, 255, 0.4);
background: #fff;
color: #000;
border-color: #fff;
}
&.accept {
background: #07c160;
width: 160rpx;
height: 160rpx;
background: #10b981;
border: none;
box-shadow: 0 0 80rpx rgba(16, 185, 129, 0.4);
svg {
width: 72rpx;
height: 72rpx;
}
&:active { background: #059669; }
}
&.decline,
&.hangup {
background: #fa5151;
transform: rotate(135deg);
&.decline {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.1);
&:active { background: #ef4444; }
}
&.hangup {
width: 160rpx;
height: 160rpx;
background: #ef4444;
border: none;
border-radius: 50%;
box-shadow: 0 8rpx 40rpx rgba(239, 68, 68, 0.3);
svg {
width: 72rpx;
height: 72rpx;
}
&:active { background: #dc2626; }
}
}
.action-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
}
.call-unsupported {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
z-index: 9999;
background: var(--bg-page);
display: flex;
@@ -321,5 +508,19 @@ function toggleMinimize() {
color: var(--text-secondary);
}
}
</style>
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes ping {
0% { transform: scale(1); opacity: 0.3; }
75%, 100% { transform: scale(1.3); opacity: 0; }
}
@keyframes fadeInUp {
from { transform: translateY(40rpx); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
</style>

View File

@@ -9,14 +9,19 @@
<view class="call-icon">
<view class="pulse-ring"></view>
<view class="icon-inner">
<wd-icon name="video" size="32rpx" color="#fff" />
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
</view>
</view>
<view class="call-info">
<view class="call-title">
<text class="title-text">群通话进行中</text>
<text class="participant-count">{{ participantsCount }}人</text>
<text class="title-text">通话进行中</text>
<view class="participant-count">
<text>{{ participantsCount }}人</text>
</view>
</view>
<!-- 头像堆叠 -->
@@ -25,13 +30,13 @@
v-for="(user, index) in previewParticipants"
:key="user.userId"
class="stacked-avatar"
:style="{ marginLeft: index > 0 ? '-16rpx' : '0' }"
:style="{ marginLeft: index > 0 ? '-24rpx' : '0', zIndex: 10 - index }"
>
<app-avatar
:src="user.avatar"
:name="user.name"
:size="40"
radius="50%"
round
/>
</view>
<view
@@ -44,14 +49,9 @@
</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 class="join-btn" @click="handleJoin">
<text>加入</text>
</view>
</view>
</template>
@@ -86,15 +86,32 @@ function handleJoin() {
<style lang="scss" scoped>
.group-call-banner {
margin: 16rpx 24rpx;
margin: 24rpx 32rpx;
padding: 24rpx;
background: var(--bg-content);
border: 1rpx solid var(--divider-color);
border-radius: 24rpx;
background: #fff;
border: 1rpx solid rgba(0, 0, 0, 0.05);
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06);
animation: slideDown 0.3s ease-out;
&.dark {
background: #292524;
border-color: rgba(255, 255, 255, 0.05);
}
}
@keyframes slideDown {
from {
transform: translateY(-20rpx);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.banner-left {
@@ -115,7 +132,7 @@ function handleJoin() {
background: #10b981;
border-radius: 50%;
opacity: 0.2;
animation: pulse 2s infinite;
animation: pulse-ring 2s infinite;
}
.icon-inner {
@@ -123,47 +140,82 @@ function handleJoin() {
width: 100%;
height: 100%;
border-radius: 50%;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
background: #10b981;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(16, 185, 129, 0.2);
overflow: hidden;
// 内部渐变光效
&::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.2) 0%, transparent 100%);
}
svg {
width: 40rpx;
height: 40rpx;
position: relative;
z-index: 1;
}
}
}
@keyframes pulse-ring {
0%, 100% { transform: scale(1); opacity: 0.2; }
50% { transform: scale(1.15); opacity: 0.35; }
}
.call-info {
display: flex;
flex-direction: column;
gap: 12rpx;
gap: 8rpx;
}
.call-title {
display: flex;
align-items: center;
gap: 12rpx;
gap: 16rpx;
.title-text {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
font-weight: 700;
color: #1f2937;
letter-spacing: -0.5rpx;
.dark & { color: #f5f5f4; }
}
.participant-count {
padding: 4rpx 12rpx;
background: rgba(16, 185, 129, 0.2);
background: rgba(16, 185, 129, 0.1);
color: #10b981;
font-size: 20rpx;
font-weight: 600;
border-radius: 8rpx;
font-family: monospace;
border: 1rpx solid rgba(16, 185, 129, 0.15);
.dark & {
background: rgba(16, 185, 129, 0.15);
border-color: rgba(16, 185, 129, 0.25);
}
}
}
.avatar-stack {
display: flex;
align-items: center;
padding-left: 24rpx;
.stacked-avatar {
border: 2rpx solid var(--bg-content);
border: 4rpx solid #fff;
border-radius: 50%;
position: relative;
.dark & { border-color: #292524; }
}
.more-count {
@@ -171,19 +223,44 @@ function handleJoin() {
height: 40rpx;
margin-left: -16rpx;
border-radius: 50%;
background: var(--bg-hover);
color: var(--text-secondary);
font-size: 18rpx;
background: #f3f4f6;
color: #6b7280;
font-size: 16rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid var(--bg-content);
border: 4rpx solid #fff;
position: relative;
z-index: 0;
.dark & {
background: #44403c;
color: #a8a29e;
border-color: #292524;
}
}
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 0.2; }
50% { transform: scale(1.1); opacity: 0.4; }
.join-btn {
display: flex;
align-items: center;
justify-content: center;
background: #10b981;
padding: 16rpx 32rpx;
border-radius: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(16, 185, 129, 0.2);
transition: all 0.15s;
text {
font-size: 24rpx;
font-weight: 700;
color: #fff;
}
&:active {
transform: scale(0.95);
background: #059669;
}
}
</style>

View File

@@ -1,30 +1,33 @@
<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="bg-overlay"></view>
<!-- 主要内容 -->
<view class="content">
<!-- 头像区域 -->
<view class="avatar-section">
<view class="avatar-ring ring-1"></view>
<view class="avatar-ring ring-2"></view>
<view class="avatar-ripple ripple-1"></view>
<view class="avatar-ripple ripple-2"></view>
<app-avatar
:src="callState.inviterAvatar"
:name="callState.inviterName"
:size="200"
radius="50%"
:size="280"
round
class="main-avatar"
/>
<view class="call-type-badge">
<wd-icon
:name="callState.type === 'video' ? 'video' : 'phone'"
size="32rpx"
color="#fff"
/>
<svg v-if="callState.type === 'video'" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
</view>
</view>
@@ -44,7 +47,11 @@
<!-- 拒绝 -->
<view class="action-item" @click="handleReject">
<view class="action-btn reject-btn">
<wd-icon name="close" size="48rpx" color="#fff" />
<!-- phone-off icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</view>
<text class="action-label">拒绝</text>
</view>
@@ -52,11 +59,10 @@
<!-- 接听 -->
<view class="action-item" @click="handleAccept">
<view class="action-btn accept-btn">
<wd-icon
:name="callState.type === 'video' ? 'video' : 'phone'"
size="48rpx"
color="#fff"
/>
<!-- phone icon filled style -->
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
</view>
<text class="action-label">接听</text>
</view>
@@ -84,12 +90,9 @@ function handleReject() {
<style lang="scss" scoped>
.incoming-call-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
z-index: 10000;
background: rgba(15, 23, 42, 0.95);
background: #0f172a;
display: flex;
flex-direction: column;
align-items: center;
@@ -105,79 +108,99 @@ function handleReject() {
.glow {
position: absolute;
width: 600rpx;
height: 600rpx;
width: 1000rpx;
height: 1000rpx;
border-radius: 50%;
filter: blur(100px);
animation: pulse 6s ease-in-out infinite;
filter: blur(160rpx);
opacity: 0.4;
animation: blob 8s infinite ease-in-out;
}
.glow-1 {
top: 20%;
left: 20%;
background: rgba(139, 92, 246, 0.2);
top: -20%;
left: -20%;
background: #8b5cf6;
}
.glow-2 {
bottom: 20%;
right: 20%;
background: rgba(59, 130, 246, 0.2);
animation-delay: 1s;
bottom: -20%;
right: -20%;
background: #3b82f6;
animation-direction: reverse;
}
}
.bg-overlay {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(120rpx);
}
@keyframes blob {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(60rpx, -100rpx) scale(1.1); }
66% { transform: translate(-40rpx, 40rpx) scale(0.9); }
}
.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);
margin-top: -160rpx;
animation: popIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.avatar-section {
position: relative;
margin-bottom: 48rpx;
margin-bottom: 64rpx;
.avatar-ring {
.avatar-ripple {
position: absolute;
border: 2rpx solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border: 2rpx solid rgba(255, 255, 255, 0.1);
animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
inset: -20rpx;
animation: ripple 2s infinite linear;
&.ring-1 {
inset: -20rpx;
}
&.ring-2 {
inset: -40rpx;
&.ripple-2 {
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);
border: 12rpx solid rgba(255, 255, 255, 0.1);
box-shadow: 0 40rpx 100rpx rgba(0, 0, 0, 0.4);
border-radius: 50%;
position: relative;
z-index: 10;
}
.call-type-badge {
position: absolute;
bottom: 0;
right: 0;
width: 80rpx;
height: 80rpx;
bottom: 4rpx;
right: 4rpx;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
background: #10b981;
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);
border: 8rpx solid #0f172a;
box-shadow: 0 8rpx 24rpx rgba(16, 185, 129, 0.4);
z-index: 20;
svg {
width: 32rpx;
height: 32rpx;
}
}
}
.inviter-name {
font-size: 48rpx;
font-size: 60rpx;
font-weight: 700;
color: #fff;
margin-bottom: 16rpx;
@@ -188,9 +211,9 @@ function handleReject() {
display: flex;
align-items: center;
gap: 16rpx;
font-size: 30rpx;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 80rpx;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 128rpx;
}
.typing-dots {
@@ -200,66 +223,90 @@ function handleReject() {
.dot {
width: 8rpx;
height: 8rpx;
background: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.6);
border-radius: 50%;
animation: bounce 1.4s infinite;
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
&:nth-child(2) { animation-delay: 0.15s; }
&:nth-child(3) { animation-delay: 0.3s; }
}
}
.actions {
display: flex;
align-items: center;
gap: 120rpx;
gap: 128rpx;
width: 100%;
justify-content: center;
padding: 0 80rpx;
padding-bottom: env(safe-area-inset-bottom);
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
gap: 24rpx;
}
.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);
svg {
width: 56rpx;
height: 56rpx;
}
&:active { transform: scale(0.95); }
}
.reject-btn {
background: rgba(239, 68, 68, 0.2);
border: 2rpx solid rgba(239, 68, 68, 0.3);
width: 128rpx;
height: 128rpx;
background: rgba(255, 255, 255, 0.1);
border: 2rpx solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
&:active {
background: #ef4444;
&:hover {
background: rgba(239, 68, 68, 0.2);
}
&:active {
background: #ef4444;
}
}
.accept-btn {
width: 140rpx;
height: 140rpx;
width: 160rpx;
height: 160rpx;
background: #10b981;
box-shadow: 0 10rpx 40rpx rgba(16, 185, 129, 0.4);
animation: pulse-glow 2s infinite;
box-shadow: 0 0 80rpx rgba(16, 185, 129, 0.4);
color: #fff;
&:active {
background: #059669;
svg {
width: 72rpx;
height: 72rpx;
animation: pulse 2s infinite;
}
&:hover {
background: #10b981;
transform: scale(1.05);
}
&:active {
background: #059669;
transform: scale(0.95);
}
}
.action-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
color: rgba(255, 255, 255, 0.5);
}
@keyframes fadeIn {
@@ -267,29 +314,23 @@ function handleReject() {
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(40rpx); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
@keyframes popIn {
0% { transform: scale(0.8); opacity: 0; }
100% { transform: scale(1); 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 ripple {
0% { transform: scale(1); opacity: 0.4; }
100% { transform: scale(2.5); opacity: 0; }
}
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-10rpx); }
40% { transform: translateY(-8rpx); }
}
@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); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
</style>

View File

@@ -1,47 +1,46 @@
<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 v-if="modelValue" class="modal-container" :class="{ dark: isDark }">
<!-- 遮罩 -->
<view
class="modal-mask"
:class="{ 'mask-active': showContent }"
@click="handleClose"
/>
<!-- 弹窗内容 -->
<view class="call-modal" :class="{ 'modal-active': showContent }">
<!-- 头部 -->
<view class="modal-header">
<view class="header-info">
<text class="header-title">发起群通话</text>
<text class="header-subtitle">选择成员加入通话 ({{ selectedIds.size }}/{{ members.length }})</text>
<text class="header-subtitle">选择成员加入通话</text>
</view>
<view class="close-btn" @click="handleClose">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</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 class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="text" v-model="searchQuery" placeholder="搜索成员" />
</view>
</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)" />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<text class="empty-text">未找到成员</text>
</view>
@@ -52,21 +51,21 @@
class="member-item"
@click="toggleSelection(member.id || member.user_id || '')"
>
<!-- 头像 -->
<view class="avatar-wrap">
<view class="avatar-wrap" :class="{ selected: selectedIds.has(member.id || member.user_id || '') }">
<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 || '') }"
:size="112"
round
/>
<!-- 选中标记 -->
<view
v-if="selectedIds.has(member.id || member.user_id || '')"
class="check-badge"
>
<wd-icon name="check" size="20rpx" color="#fff" />
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
</view>
<!-- 在线状态 -->
<view
@@ -74,7 +73,6 @@
class="online-dot"
></view>
</view>
<!-- 名字 -->
<text
class="member-name"
:class="{ 'name-selected': selectedIds.has(member.id || member.user_id || '') }"
@@ -87,34 +85,32 @@
<!-- 底部操作栏 -->
<view class="modal-footer">
<text class="selected-count">
已选择 <text class="count-num">{{ selectedIds.size }}</text> 人
</text>
<view class="selected-info">
<text>已选择</text>
<text class="count-num">{{ selectedIds.size }}</text>
<text>人</text>
</view>
<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 class="action-btn audio-btn" :class="{ disabled: selectedIds.size === 0 }" @click="emitStartCall('audio')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
</view>
<view class="action-btn video-btn" :class="{ disabled: selectedIds.size === 0 }" @click="emitStartCall('video')">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
<text>开始</text>
</view>
</view>
</view>
</view>
</wd-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import { useTheme } from '@/composables/useTheme'
import AppAvatar from '@/components/common/AppAvatar.vue'
import type { Contact } from '@/types/api'
@@ -132,16 +128,20 @@ const emit = defineEmits<{
const { isDark } = useTheme()
const searchQuery = ref('')
const selectedIds = ref<Set<string>>(new Set())
const showContent = ref(false)
// 监听打开/关闭重置状态
watch(() => props.modelValue, (val) => {
if (val) {
searchQuery.value = ''
selectedIds.value = new Set()
nextTick(() => {
showContent.value = true
})
} else {
showContent.value = false
}
})
// 过滤后的成员
const filteredMembers = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
if (!query) return props.members
@@ -151,10 +151,6 @@ const filteredMembers = computed(() => {
})
})
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)) {
@@ -162,19 +158,9 @@ function toggleSelection(id: string) {
} 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))
@@ -182,21 +168,73 @@ function emitStartCall(type: 'audio' | 'video') {
}
function handleClose() {
emit('update:modelValue', false)
showContent.value = false
setTimeout(() => {
emit('update:modelValue', false)
}, 300)
}
</script>
<style lang="scss" scoped>
.modal-container {
position: fixed;
inset: 0;
z-index: 1000;
--bg-surface: #fff;
--bg-hover: #f5f5f5;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--border-color: rgba(0, 0, 0, 0.05);
--color-brand: #4F46E5;
&.dark {
--bg-surface: #1c1917;
--bg-hover: #292524;
--text-primary: #f5f5f4;
--text-secondary: #a8a29e;
--text-tertiary: #78716c;
--border-color: rgba(255, 255, 255, 0.08);
--color-brand: #4F46E5;
}
}
.modal-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4rpx);
opacity: 0;
transition: opacity 0.3s;
&.mask-active {
opacity: 1;
}
}
.call-modal {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
background: var(--bg-content);
max-height: 80vh;
background: var(--bg-surface);
max-height: 85vh;
border-radius: 64rpx 64rpx 0 0;
box-shadow: 0 -20rpx 80rpx rgba(0, 0, 0, 0.1);
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
&.modal-active {
transform: translateY(0);
}
}
.modal-header {
padding: 32rpx;
border-bottom: 1rpx solid var(--divider-color);
padding: 40rpx 40rpx 24rpx;
border-bottom: 1rpx solid var(--border-color);
display: flex;
align-items: flex-start;
justify-content: space-between;
@@ -209,9 +247,10 @@ function handleClose() {
}
.header-title {
font-size: 32rpx;
font-weight: 600;
font-size: 36rpx;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.5rpx;
}
.header-subtitle {
@@ -219,18 +258,69 @@ function handleClose() {
color: var(--text-tertiary);
}
.close-btn {
width: 64rpx;
height: 64rpx;
background: var(--bg-hover);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
svg {
width: 40rpx;
height: 40rpx;
color: var(--text-secondary);
}
&:active {
transform: scale(0.95);
background: var(--border-color);
}
}
.search-bar {
padding: 24rpx 32rpx;
padding: 24rpx 40rpx;
background: rgba(0, 0, 0, 0.02);
.dark & {
background: rgba(255, 255, 255, 0.02);
}
}
.search-input {
display: flex;
align-items: center;
gap: 16rpx;
background: var(--bg-hover);
padding: 0 24rpx;
height: 72rpx;
border-radius: 24rpx;
svg {
width: 32rpx;
height: 32rpx;
color: var(--text-tertiary);
flex-shrink: 0;
}
input {
flex: 1;
height: 100%;
font-size: 28rpx;
color: var(--text-primary);
background: transparent;
border: none;
&::placeholder { color: var(--text-tertiary); }
}
}
.member-list {
flex: 1;
max-height: 50vh;
padding: 24rpx;
padding: 32rpx 40rpx 160rpx;
}
.empty-state {
@@ -238,37 +328,46 @@ function handleClose() {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
gap: 16rpx;
padding: 100rpx 0;
gap: 24rpx;
svg {
width: 100rpx;
height: 100rpx;
color: var(--text-tertiary);
opacity: 0.4;
}
.empty-text {
font-size: 26rpx;
font-size: 28rpx;
color: var(--text-tertiary);
}
}
.member-grid {
display: flex;
flex-wrap: wrap;
gap: 32rpx;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 48rpx 16rpx;
}
.member-item {
width: calc(20% - 26rpx);
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
gap: 16rpx;
}
.avatar-wrap {
position: relative;
transition: all 0.2s;
.avatar-selected {
opacity: 1;
transform: scale(1.05);
box-shadow: 0 0 0 4rpx #10b981;
border-radius: 50%;
&.selected {
transform: scale(0.95);
:deep(.app-avatar) {
box-shadow: 0 0 0 8rpx var(--color-brand);
border-radius: 32rpx;
}
}
}
@@ -276,25 +375,31 @@ function handleClose() {
position: absolute;
top: -8rpx;
right: -8rpx;
width: 36rpx;
height: 36rpx;
background: #10b981;
width: 40rpx;
height: 40rpx;
background: var(--color-brand);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 10rpx rgba(16, 185, 129, 0.4);
border: 4rpx solid var(--bg-surface);
box-shadow: 0 4rpx 12rpx rgba(79, 70, 229, 0.4);
animation: bounceIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
svg {
width: 24rpx;
height: 24rpx;
}
}
.online-dot {
position: absolute;
bottom: 4rpx;
right: 4rpx;
width: 20rpx;
height: 20rpx;
width: 24rpx;
height: 24rpx;
background: #10b981;
border: 4rpx solid var(--bg-content);
border: 4rpx solid var(--bg-surface);
border-radius: 50%;
}
@@ -302,40 +407,98 @@ function handleClose() {
font-size: 22rpx;
color: var(--text-secondary);
text-align: center;
max-width: 100%;
max-width: 128rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.2s;
font-weight: 500;
&.name-selected {
color: #10b981;
font-weight: 500;
color: var(--color-brand);
font-weight: 600;
}
}
.modal-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx 32rpx;
border-top: 1rpx solid var(--divider-color);
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-content);
background: var(--bg-surface);
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.02);
border-radius: 32rpx 32rpx 0 0;
}
.selected-count {
font-size: 24rpx;
color: var(--text-tertiary);
.selected-info {
font-size: 28rpx;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 4rpx;
.count-num {
color: #10b981;
font-weight: 600;
font-size: 28rpx;
color: var(--color-brand);
font-weight: 700;
font-size: 36rpx;
margin: 0 8rpx;
transition: all 0.2s;
}
}
.action-buttons {
display: flex;
gap: 16rpx;
gap: 24rpx;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
border-radius: 32rpx;
transition: all 0.15s;
svg {
width: 40rpx;
height: 40rpx;
}
&.disabled {
opacity: 0.4;
pointer-events: none;
}
&:active {
transform: scale(0.95);
}
}
.audio-btn {
width: 96rpx;
height: 96rpx;
background: var(--bg-hover);
border: 1rpx solid var(--border-color);
color: var(--text-secondary);
}
.video-btn {
height: 96rpx;
padding: 0 48rpx;
background: var(--color-brand);
box-shadow: 0 8rpx 24rpx rgba(79, 70, 229, 0.3);
text {
font-size: 28rpx;
font-weight: 700;
color: #fff;
}
}
@keyframes bounceIn {
@@ -343,4 +506,3 @@ function handleClose() {
100% { transform: scale(1); opacity: 1; }
}
</style>

View File

@@ -4,153 +4,154 @@
class="call-window"
:class="{ minimized: callState.minimized }"
>
<!-- 顶部功能栏 -->
<view class="window-header" @click="handleMinimize">
<!-- 最小化内容 -->
<view v-if="callState.minimized" class="minimized-content" @click="toggleMinimize">
<svg viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
<text class="mini-duration">{{ formatDuration(callState.duration) }}</text>
<view class="mini-pulse"></view>
</view>
<!-- 顶部功能栏 (渐变遮罩) -->
<view v-if="!callState.minimized" class="window-header">
<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 class="minimize-btn" @click="toggleMinimize">
<!-- minimize-2 icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<polyline points="4 14 10 14 10 20"/>
<polyline points="20 10 14 10 14 4"/>
<line x1="14" y1="10" x2="21" y2="3"/>
<line x1="3" y1="21" x2="10" y2="14"/>
</svg>
</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>
<!-- 视频网格 -->
<scroll-view v-if="!callState.minimized" 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="160" round />
<text class="cam-off-text">摄像头已关闭</text>
</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 class="video-label">
<text class="label-name">我</text>
<svg v-if="callState.isSelfMuted" class="mute-icon" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
</svg>
</view>
</view>
<!-- 无视频流或摄像头关闭 -->
<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
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="160" round />
<view v-if="user.status === 'connecting'" class="connecting-overlay">
<wd-loading color="#fff" size="32rpx" />
</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>
<text class="participant-name">{{ user.name }}</text>
<text class="participant-status">
{{ user.status === 'connecting' ? '连接中...' : user.status === 'rejected' ? '已拒绝' : '摄像头关闭' }}
</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 class="video-label">
<text class="label-name">{{ user.name }}</text>
<svg v-if="user.isMuted" class="mute-icon" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
</svg>
</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
v-if="!user.isMuted && user.volume && user.volume > 0.05"
class="speaking-indicator"
></view>
</view>
</template>
</scroll-view>
<!-- 底部控制栏 -->
<view v-if="!callState.minimized" class="control-bar">
<view class="control-item" @click="webRTC.toggleSelfMute()">
<view class="control-btn" :class="{ active: callState.isSelfMuted }">
<svg v-if="callState.isSelfMuted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</view>
<text class="control-label">{{ callState.isSelfMuted ? '已静音' : '静音' }}</text>
</view>
<view class="control-item" @click="webRTC.leaveCall()">
<view class="control-btn hangup-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5">
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</view>
<text class="control-label">挂断</text>
</view>
<view class="control-item" @click="webRTC.toggleSelfCamera()">
<view class="control-btn" :class="{ active: callState.isSelfCamOff }">
<svg v-if="callState.isSelfCamOff" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
</view>
<text class="control-label">{{ callState.isSelfCamOff ? '已关闭' : '摄像头' }}</text>
</view>
</view>
</view>
</template>
@@ -164,7 +165,6 @@ const { callState, participants, formatDuration, localStream } = webRTC
const localVideoRef = ref<HTMLVideoElement | null>(null)
// 绑定本地视频流
watchEffect(() => {
const stream = localStream.value
const videoEl = localVideoRef.value
@@ -180,69 +180,92 @@ watchEffect(() => {
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;
background: #1c1c1e;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.5);
transition: all 0.3s ease;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
// 正常模式
&:not(.minimized) {
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 0;
inset: 0;
}
// 最小化模式
&.minimized {
top: calc(var(--status-bar-height, 0) + 160rpx);
right: 24rpx;
top: calc(var(--status-bar-height, 0) + 240rpx);
right: 32rpx;
width: 200rpx;
height: 280rpx;
border-radius: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.1);
border-radius: 32rpx;
border: 2rpx solid rgba(255, 255, 255, 0.15);
}
}
.minimized-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
background: #2c2c2e;
position: relative;
cursor: pointer;
svg {
width: 64rpx;
height: 64rpx;
}
.mini-duration {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
font-family: monospace;
}
.mini-pulse {
position: absolute;
bottom: 16rpx;
width: 8rpx;
height: 8rpx;
background: #10b981;
border-radius: 50%;
animation: pulse 2s infinite;
}
&:active {
opacity: 0.9;
}
}
.window-header {
height: 88rpx;
padding: 0 24rpx;
height: 200rpx;
padding: 0 40rpx;
padding-top: calc(24rpx + var(--status-bar-height, 0));
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
background: linear-gradient(180deg, rgba(0, 0, 0, 0.6) 0%, transparent 100%);
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 50;
.minimized & {
background: transparent;
height: 60rpx;
padding: 0 16rpx;
}
pointer-events: none;
}
.header-left {
display: flex;
align-items: center;
gap: 12rpx;
gap: 24rpx;
pointer-events: auto;
}
.status-dot {
@@ -250,77 +273,64 @@ function handleMinimize() {
height: 16rpx;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 20rpx #10b981;
animation: pulse 2s infinite;
}
.header-title {
font-size: 28rpx;
font-weight: 600;
font-weight: 500;
color: #fff;
.minimized & {
display: none;
}
letter-spacing: 0.5rpx;
}
.duration {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
color: rgba(255, 255, 255, 0.5);
font-family: monospace;
padding-left: 12rpx;
border-left: 2rpx solid rgba(255, 255, 255, 0.2);
margin-left: 4rpx;
.minimized & {
display: none;
}
padding-left: 24rpx;
border-left: 2rpx solid rgba(255, 255, 255, 0.1);
}
.minimize-btn {
width: 56rpx;
height: 56rpx;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
pointer-events: auto;
svg {
width: 32rpx;
height: 32rpx;
}
&:active {
transform: scale(0.9);
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;
padding: calc(var(--status-bar-height, 0) + 200rpx) 24rpx 240rpx;
display: flex;
flex-wrap: wrap;
gap: 16rpx;
align-content: flex-start;
overflow-y: auto;
}
.video-item {
position: relative;
width: calc(50% - 8rpx);
aspect-ratio: 4 / 3;
background: #252525;
border-radius: 16rpx;
aspect-ratio: 3 / 4;
background: #2c2c2e;
border-radius: 32rpx;
overflow: hidden;
border: 2rpx solid rgba(255, 255, 255, 0.05);
}
@@ -330,9 +340,7 @@ function handleMinimize() {
height: 100%;
object-fit: cover;
&.mirror {
transform: scaleX(-1);
}
&.mirror { transform: scaleX(-1); }
}
.cam-off-placeholder {
@@ -343,7 +351,7 @@ function handleMinimize() {
align-items: center;
justify-content: center;
gap: 16rpx;
background: #252525;
background: #2c2c2e;
}
.avatar-wrap {
@@ -360,104 +368,125 @@ function handleMinimize() {
justify-content: center;
}
.cam-off-text,
.participant-name {
font-size: 24rpx;
.cam-off-text, .participant-name {
font-size: 26rpx;
color: #fff;
font-weight: 500;
}
.participant-status {
font-size: 20rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.5);
}
.video-label {
position: absolute;
bottom: 12rpx;
left: 12rpx;
bottom: 24rpx;
left: 24rpx;
display: flex;
align-items: center;
gap: 8rpx;
gap: 12rpx;
padding: 8rpx 16rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 8rpx;
backdrop-filter: blur(4px);
background: rgba(0, 0, 0, 0.4);
border-radius: 16rpx;
backdrop-filter: blur(16rpx);
border: 1rpx solid rgba(255, 255, 255, 0.1);
.label-name {
font-size: 22rpx;
font-size: 20rpx;
color: #fff;
font-weight: 500;
max-width: 120rpx;
max-width: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mute-icon {
width: 24rpx;
height: 24rpx;
}
}
.speaking-indicator {
position: absolute;
inset: 0;
border: 4rpx solid rgba(16, 185, 129, 0.6);
border-radius: 16rpx;
border: 4rpx solid #10b981;
border-radius: 32rpx;
pointer-events: none;
animation: speakPulse 0.5s ease-in-out;
}
.control-bar {
height: 180rpx;
padding: 24rpx 0 40rpx;
height: 320rpx;
padding: 40rpx 0;
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
justify-content: center;
gap: 80rpx;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
background: linear-gradient(0deg, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.5) 50%, transparent 100%);
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
}
.control-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
gap: 16rpx;
}
.control-btn {
width: 96rpx;
height: 96rpx;
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.1);
border: 2rpx solid rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: all 0.3s;
backdrop-filter: blur(8rpx);
&:active {
transform: scale(0.95);
svg {
width: 48rpx;
height: 48rpx;
}
&:active { transform: scale(0.95); }
&.active {
background: #fff;
:deep(.wd-icon) {
color: #000 !important;
}
color: #000;
border-color: #fff;
}
}
.hangup-btn {
width: 120rpx;
height: 120rpx;
border-radius: 32rpx;
width: 144rpx;
height: 72rpx;
border-radius: 56rpx;
background: #ef4444;
box-shadow: 0 10rpx 40rpx rgba(239, 68, 68, 0.4);
border: none;
box-shadow: 0 8rpx 32rpx rgba(239, 68, 68, 0.3);
padding: 0 32rpx;
&:active {
background: #dc2626;
svg {
width: 64rpx;
height: 64rpx;
}
&:active { background: #dc2626; }
}
.control-label {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.6);
color: rgba(255, 255, 255, 0.5);
}
@keyframes pulse {
@@ -471,4 +500,3 @@ function handleMinimize() {
100% { opacity: 0; }
}
</style>

View File

@@ -112,3 +112,9 @@ const bubbleClass = computed(() => {

View File

@@ -1,11 +1,16 @@
<template>
<view class="message-input">
<!-- @ 提及弹窗 -->
<view class="message-input" :class="{ 'theme-dark': isDark }">
<!-- @ 提及弹窗 (保持原有功能不变) -->
<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 class="close-btn" @click="showMentionPicker = false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</view>
</view>
<scroll-view scroll-y class="mention-list">
<view
@@ -26,49 +31,96 @@
</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 v-if="isRecording" class="recording-overlay">
<view class="recording-content" :class="{ 'cancel-mode': isCancelRecording }">
<view class="recording-icon">
<svg v-if="!isCancelRecording" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="23" x2="16" y2="23" stroke="currentColor" stroke-width="2"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</view>
<view class="wave-bars">
<view class="wave-bar" v-for="i in 5" :key="i" :style="{ animationDelay: `${i * 0.1}s` }"></view>
</view>
<text class="recording-duration">{{ formatRecordDuration(recordDuration) }}</text>
<text class="recording-tip">{{ isCancelRecording ? '松开取消' : '上滑取消发送' }}</text>
</view>
</view>
<!-- 输入框 -->
<view class="input-wrap">
<wd-input
v-model="inputValue"
type="textarea"
:auto-height="true"
:rows="1"
placeholder="输入消息..."
no-border
@input="onInput"
@confirm="onSend"
/>
</view>
<!-- 底部输入栏 (按设计稿布局) -->
<view class="input-bar">
<!-- 左侧: 语音按钮 -->
<view
class="voice-btn"
@touchstart="startRecording"
@touchmove="onRecordingMove"
@touchend="stopRecording"
@touchcancel="cancelRecording"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</view>
<!-- 发送按钮 -->
<wd-button
v-if="inputValue.trim()"
type="primary"
size="small"
@click="onSend"
>
发送
</wd-button>
<wd-icon
v-else
name="add-circle"
size="48rpx"
class="tool-icon"
@click="$emit('more')"
/>
<!-- 中间: 输入框 -->
<view class="input-wrap">
<textarea
v-model="inputValue"
class="text-input"
:auto-height="true"
:maxlength="-1"
placeholder="发送消息..."
placeholder-class="input-placeholder"
:adjust-position="true"
@input="onInput"
@confirm="onSend"
/>
</view>
<!-- 右侧: 表情 + 加号/发送 -->
<view class="right-actions">
<view class="action-btn" @click="$emit('emoji')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
</view>
<!-- 有文字时显示发送按钮,否则显示加号 -->
<view v-if="inputValue.trim()" class="send-btn" @click="onSend">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</view>
<view v-else class="action-btn" @click="$emit('more')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</svg>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useTheme } from '@/composables/useTheme'
import AppAvatar from '@/components/common/AppAvatar.vue'
import type { GroupMember } from '@/types/api'
@@ -87,6 +139,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'send', value: string): void
(e: 'sendVoice', data: { path: string; duration: number }): void
(e: 'emoji'): void
(e: 'image'): void
(e: 'file'): void
@@ -94,9 +147,21 @@ const emit = defineEmits<{
(e: 'mention', member: GroupMember): void
}>()
const { isDark } = useTheme()
// 文本输入相关
const inputValue = ref(props.modelValue)
const showMentionPicker = ref(false)
// 语音录制相关
const isRecording = ref(false)
const isCancelRecording = ref(false)
const recordDuration = ref(0)
let recordTimer: ReturnType<typeof setInterval> | null = null
let recorderManager: UniApp.RecorderManager | null = null
let startY = 0
let voicePath = ''
watch(() => props.modelValue, (val) => {
inputValue.value = val
})
@@ -105,6 +170,7 @@ watch(inputValue, (val) => {
emit('update:modelValue', val)
})
// ========== 文本输入处理 (保持原有逻辑) ==========
function onInput(e: any) {
const value = e.detail?.value || e.value || inputValue.value
// 检测 @ 符号
@@ -119,7 +185,6 @@ function openMentionPicker() {
function selectMention(member: GroupMember) {
const name = member.nickname || member.user?.name || ''
// 如果已经输入了 @,就替换;否则追加 @名字
if (inputValue.value.endsWith('@')) {
inputValue.value = inputValue.value + name + ' '
} else {
@@ -134,37 +199,367 @@ function onSend() {
emit('send', inputValue.value.trim())
inputValue.value = ''
}
// ========== 语音录制处理 ==========
function initRecorderManager() {
if (recorderManager) return recorderManager
recorderManager = uni.getRecorderManager()
recorderManager.onStart(() => {
console.log('录音开始')
isRecording.value = true
recordDuration.value = 0
recordTimer = setInterval(() => {
recordDuration.value++
// 最长60秒
if (recordDuration.value >= 60) {
stopRecording()
}
}, 1000)
})
recorderManager.onStop((res) => {
console.log('录音结束', res)
clearRecordTimer()
isRecording.value = false
if (!isCancelRecording.value && res.tempFilePath && recordDuration.value >= 1) {
voicePath = res.tempFilePath
emit('sendVoice', {
path: voicePath,
duration: recordDuration.value
})
}
isCancelRecording.value = false
recordDuration.value = 0
})
recorderManager.onError((err) => {
console.error('录音错误', err)
clearRecordTimer()
isRecording.value = false
isCancelRecording.value = false
uni.showToast({ title: '录音失败', icon: 'none' })
})
return recorderManager
}
function clearRecordTimer() {
if (recordTimer) {
clearInterval(recordTimer)
recordTimer = null
}
}
function startRecording(e: TouchEvent) {
startY = e.touches[0].clientY
isCancelRecording.value = false
// 初始化录音管理器
const recorder = initRecorderManager()
// 开始录音
recorder.start({
duration: 60000, // 最长60秒
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
format: 'mp3'
})
}
function onRecordingMove(e: TouchEvent) {
if (!isRecording.value) return
const currentY = e.touches[0].clientY
const diff = startY - currentY
// 上滑超过80px触发取消
isCancelRecording.value = diff > 80
}
function stopRecording() {
if (!isRecording.value) return
if (recorderManager) {
recorderManager.stop()
}
}
function cancelRecording() {
isCancelRecording.value = true
stopRecording()
}
function formatRecordDuration(seconds: number): string {
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
const s = (seconds % 60).toString().padStart(2, '0')
return `${m}:${s}`
}
// 暴露方法供父组件使用
defineExpose({
openMentionPicker
})
</script>
<style lang="scss" scoped>
.message-input {
--input-bg: #f1f1f1;
--input-text: #1f2937;
--placeholder-color: #9ca3af;
--icon-color: #6b7280;
--icon-hover: #4F46E5;
--send-bg: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
--border-color: rgba(0, 0, 0, 0.05);
--bg-content: #ffffff;
position: relative;
background: var(--bg-content);
border-top: 1rpx solid var(--border-color);
&.theme-dark {
--input-bg: #292524;
--input-text: #f5f5f4;
--placeholder-color: #78716c;
--icon-color: #a8a29e;
--icon-hover: #f97316;
--send-bg: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
--border-color: #44403c;
--bg-content: #1c1917;
}
}
// 底部输入栏
.input-bar {
display: flex;
align-items: flex-end;
padding: 16rpx 20rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
background: var(--bg-content);
border-top: 1rpx solid var(--divider-color);
gap: 16rpx;
gap: 24rpx;
padding: 24rpx 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.input-toolbar {
// 语音按钮
.voice-btn {
width: 56rpx;
height: 56rpx;
display: flex;
gap: 16rpx;
padding-bottom: 8rpx;
}
.tool-icon {
color: var(--text-secondary);
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 16rpx;
color: var(--icon-color);
transition: color 0.15s;
svg {
width: 56rpx;
height: 56rpx;
}
&:active {
color: var(--icon-hover);
transform: scale(1.1);
}
}
// 输入框容器
.input-wrap {
flex: 1;
background: var(--search-bg);
border-radius: 16rpx;
padding: 8rpx 16rpx;
min-height: 88rpx;
background: var(--input-bg);
border-radius: 40rpx;
padding: 20rpx 28rpx;
display: flex;
align-items: center;
transition: all 0.2s;
.theme-dark & {
border: 1rpx solid var(--border-color);
}
}
// @ 提及选择器
.text-input {
width: 100%;
min-height: 48rpx;
max-height: 200rpx;
font-size: 30rpx;
line-height: 1.5;
color: var(--input-text);
background: transparent;
}
.input-placeholder {
color: var(--placeholder-color);
}
// 右侧操作按钮
.right-actions {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.action-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
color: var(--icon-color);
transition: all 0.15s;
svg {
width: 56rpx;
height: 56rpx;
}
&:active {
color: var(--icon-hover);
}
}
// 发送按钮
.send-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: var(--send-bg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(79, 70, 229, 0.3);
transition: all 0.2s;
animation: popIn 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
.theme-dark & {
box-shadow: 0 4rpx 16rpx rgba(249, 115, 22, 0.3);
}
svg {
width: 36rpx;
height: 36rpx;
}
&:active {
transform: scale(0.95);
}
}
@keyframes popIn {
from { transform: scale(0); }
to { transform: scale(1); }
}
// 录音浮层
.recording-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.recording-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 32rpx;
min-width: 300rpx;
.theme-dark & {
background: rgba(41, 37, 36, 0.95);
}
&.cancel-mode {
.recording-icon {
background: #ef4444;
}
.wave-bars .wave-bar {
background: #ef4444;
animation: none;
height: 8rpx;
}
}
}
.recording-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: #10b981;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
transition: background 0.2s;
svg {
width: 60rpx;
height: 60rpx;
color: #fff;
}
}
.wave-bars {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
height: 64rpx;
margin-bottom: 24rpx;
}
.wave-bar {
width: 8rpx;
height: 16rpx;
background: #10b981;
border-radius: 8rpx;
animation: wave 0.8s infinite ease-in-out;
@for $i from 1 through 5 {
&:nth-child(#{$i}) {
animation-delay: #{($i - 1) * 0.1}s;
}
}
}
@keyframes wave {
0%, 100% { height: 16rpx; }
50% { height: 48rpx; }
}
.recording-duration {
font-size: 36rpx;
font-weight: 600;
color: var(--input-text);
font-family: monospace;
margin-bottom: 16rpx;
}
.recording-tip {
font-size: 24rpx;
color: var(--placeholder-color);
}
// @ 提及选择器 (保持原有样式)
.mention-picker {
max-height: 60vh;
background: var(--bg-content);
@@ -174,12 +569,26 @@ function onSend() {
align-items: center;
justify-content: space-between;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid var(--divider-color);
border-bottom: 1rpx solid var(--border-color);
.title {
font-size: 32rpx;
font-weight: 600;
color: var(--text-primary);
color: var(--input-text);
}
.close-btn {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
color: var(--icon-color);
svg {
width: 32rpx;
height: 32rpx;
}
}
}
@@ -194,20 +603,13 @@ function onSend() {
gap: 20rpx;
&:active {
background: var(--bg-hover);
background: var(--input-bg);
}
.mention-name {
font-size: 30rpx;
color: var(--text-primary);
color: var(--input-text);
}
}
}
</style>

View File

@@ -55,3 +55,9 @@ withDefaults(defineProps<Props>(), {

View File

@@ -1,5 +1,5 @@
<template>
<view class="app-tabbar">
<view class="app-tabbar" :class="{ dark: isDark }">
<view class="tabbar-wrapper">
<view
v-for="tab in tabs"
@@ -9,13 +9,13 @@
@click="switchTab(tab.name)"
>
<view class="tabbar-icon">
<!-- 消息图标 -->
<!-- 消息图标 (message-circle) -->
<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"/>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</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 v-else class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
<!-- 未读角标 -->
<view v-if="unreadCount > 0" class="badge">
@@ -23,27 +23,31 @@
</view>
</template>
<!-- 联系人图标 -->
<!-- 联系人图标 (users) -->
<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"/>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4" fill="currentColor"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</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 v-else class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</template>
<!-- 朋友圈图标 -->
<!-- 发现图标 (compass) -->
<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"/>
<circle cx="12" cy="12" r="10" fill="currentColor"/>
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" fill="white"/>
</svg>
<svg v-else class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg v-else class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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"/>
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>
</svg>
</template>
</view>
@@ -56,6 +60,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { useTheme } from '@/composables/useTheme'
interface Props {
current?: string
@@ -65,19 +70,19 @@ const props = withDefaults(defineProps<Props>(), {
current: 'messages'
})
const { isDark } = useTheme()
const conversationStore = useConversationStore()
const unreadCount = computed(() => conversationStore.totalUnread)
// 底部导航只有3个: 消息/联系人/发现 (无"我的")
const tabs = [
{ name: 'messages', label: '消息', path: '/pages/index/index' },
{ name: 'contacts', label: '联系人', path: '/pages/contact/index' },
{ name: 'moments', label: '朋友圈', path: '/pages/moment/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 })
@@ -86,20 +91,25 @@ function switchTab(name: string) {
</script>
<style lang="scss" scoped>
// Tab Bar 容器 (高度 84px = 168rpx)
.app-tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1;
background: var(--bg-content, #fff);
border-top: 1rpx solid var(--divider-color, #eee);
z-index: 100;
height: 168rpx;
background: #ffffff;
border-top: 1rpx solid rgba(0, 0, 0, 0.05);
padding-bottom: env(safe-area-inset-bottom);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.tabbar-wrapper {
display: flex;
height: 100rpx;
align-items: center;
padding-bottom: 12rpx;
}
.tabbar-item {
@@ -108,12 +118,13 @@ function switchTab(name: string) {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4rpx;
color: #999;
gap: 8rpx;
color: #9ca3af;
transition: color 0.2s;
cursor: pointer;
&.active {
color: #07c160;
color: #4F46E5;
}
&:active {
@@ -137,126 +148,45 @@ function switchTab(name: string) {
.badge {
position: absolute;
top: -8rpx;
right: -20rpx;
top: -12rpx;
right: -24rpx;
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
background: #fa5151;
background: #ef4444;
color: #fff;
font-size: 20rpx;
line-height: 32rpx;
text-align: center;
border-radius: 32rpx;
white-space: nowrap;
font-weight: 700;
box-sizing: border-box;
}
.tabbar-text {
font-size: 22rpx;
font-size: 20rpx;
line-height: 1;
font-weight: 500;
.tabbar-item.active & {
font-weight: 700;
}
}
// ==========================================
// 深色模式 (Warm Stone)
// ==========================================
.app-tabbar.dark {
background: #1c1917;
border-top-color: #44403c;
.tabbar-item {
color: #78716c;
&.active {
color: #f97316;
}
}
}
</style>
<!--/*-->
<!--<template>-->
<!-- <view :class="{ dark: isDark }">-->
<!-- <wd-tabbar-->
<!-- :modelValue="current"-->
<!-- fixed-->
<!-- placeholder-->
<!-- safe-area-inset-bottom-->
<!-- bordered-->
<!-- custom-class="app-tab-bar"-->
<!-- @change="handleChange"-->
<!-- >-->
<!-- <wd-tabbar-item-->
<!-- v-for="(item, index) in list"-->
<!-- :key="index"-->
<!-- :name="item.pagePath"-->
<!-- :title="item.text"-->
<!-- :value="item.badge > 0 ? item.badge : null"-->
<!-- :icon="item.icon"-->
<!-- >-->
<!-- </wd-tabbar-item>-->
<!-- </wd-tabbar>-->
<!-- </view>-->
<!--</template>-->
<!--<script setup lang="ts">-->
<!--import { computed } from 'vue'-->
<!--import { useTheme } from '@/composables/useTheme'-->
<!--// 保持原有的 store 引用-->
<!--import { useAppStore } from '@/stores'-->
<!--const props = defineProps<{-->
<!-- active?: string-->
<!--}>()-->
<!--const { isDark } = useTheme()-->
<!--const appStore = useAppStore()-->
<!--// 核心修改:移除 image 路径,改用 Wot-UI设计 内置 Icon name-->
<!--// 如果需要自定义 SVG,可以使用 custom-icon 插槽-->
<!--const list = computed(() => [-->
<!-- {-->
<!-- pagePath: '/pages/index/index',-->
<!-- text: '消息',-->
<!-- icon: 'chat', // 对应 wd-icon name="chat"-->
<!-- badge: appStore.unreadCount || 0-->
<!-- },-->
<!-- {-->
<!-- pagePath: '/pages/contact/index',-->
<!-- text: '通讯录',-->
<!-- icon: 'user-circle', // 对应 wd-icon name="user-circle"-->
<!-- badge: appStore.contactUnread || 0-->
<!-- },-->
<!-- {-->
<!-- pagePath: '/pages/moment/index',-->
<!-- text: '发现',-->
<!-- icon: 'camera', // 对应 wd-icon name="camera"-->
<!-- badge: appStore.momentUnread ? 1 : 0-->
<!-- },-->
<!-- {-->
<!-- pagePath: '/pages/profile/index',-->
<!-- text: '我的',-->
<!-- icon: 'user', // 对应 wd-icon name="user"-->
<!-- badge: 0-->
<!-- }-->
<!--])-->
<!--const current = computed(() => {-->
<!-- if (props.active) return props.active-->
<!-- const pages = getCurrentPages()-->
<!-- const page = pages[pages.length - 1]-->
<!-- return '/' + page.route-->
<!--})-->
<!--function handleChange({ value }: { value: string }) {-->
<!-- uni.switchTab({ url: value })-->
<!--}-->
<!--</script>-->
<!--<style lang="scss" scoped>-->
<!--:deep(.app-tab-bar) {-->
<!-- background-color: var(&#45;&#45;bg-content) !important;-->
<!-- // 覆盖选中态颜色,保持与原有设计一致-->
<!-- .wd-tabbar-item.is-active {-->
<!-- color: var(&#45;&#45;color-primary) !important;-->
<!-- }-->
<!--}-->
<!--.dark {-->
<!-- :deep(.app-tab-bar) {-->
<!-- background-color: #1c1c1e !important;-->
<!-- border-top-color: #333;-->
<!-- .wd-tabbar-item {-->
<!-- color: var(&#45;&#45;text-secondary);-->
<!-- &.is-active {-->
<!-- color: var(&#45;&#45;color-primary);-->
<!-- }-->
<!-- }-->
<!-- }-->
<!--}-->
<!--</style>-->
<!--*/-->

View File

@@ -6,6 +6,9 @@ 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 AppDrawer } from './common/AppDrawer.vue'
export { default as PlusMenu } from './common/PlusMenu.vue'
export { default as AppTabBar } from './common/AppTabBar.vue'
// 聊天组件
export { default as MessageBubble } from './chat/MessageBubble.vue'

File diff suppressed because it is too large Load Diff

View File

@@ -1,106 +1,180 @@
<template>
<wd-config-provider :theme="isDark ? 'dark' : 'light'">
<view class="page-container" :class="{ 'theme-dark': isDark }">
<!-- 顶部背景装饰 -->
<view class="page-bg-decoration" />
<view class="page-container" :class="{ 'theme-dark': isDark }">
<!-- 渐变弧形头部 -->
<view class="curved-header">
<view class="header-bg"></view>
<view class="decoration-orb orb-1"></view>
<view class="decoration-orb orb-2"></view>
<!-- 导航栏 -->
<app-nav-bar title="添加好友" custom-style="background: transparent;" />
<!-- 返回按钮 -->
<view class="nav-back" @click="goBack">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</view>
<view class="content-body">
<!-- 头部标题区 -->
<view class="header-area">
<text class="main-title">发现新朋友</text>
<text class="sub-title">Expanding your circle</text>
<!-- 大标题 -->
<view class="header-content">
<text class="main-title">发现新朋友</text>
<text class="sub-title">EXPANDING YOUR CIRCLE</text>
</view>
</view>
<!-- 悬浮搜索框 -->
<view class="search-floater">
<view class="search-box">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
class="search-input"
type="text"
v-model="keyword"
placeholder="搜索账号 / 手机号"
@confirm="doSearch"
/>
<view v-if="keyword" class="clear-btn" @click="keyword = ''">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z"/>
</svg>
</view>
<view v-else class="search-btn" @click="doSearch">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</view>
</view>
</view>
<!-- 主内容 -->
<scroll-view scroll-y class="main-content custom-scrollbar">
<!-- 搜索结果 -->
<view v-if="searched" class="result-section animate-fade-in-up">
<view class="section-header">
<text class="section-title">搜索结果</text>
</view>
<!-- 搜索区域 -->
<view class="search-section">
<wd-search
v-model="keyword"
placeholder="搜索账号 / 手机号"
hide-cancel
placeholder-left
custom-class="glass-search"
@search="doSearch"
@clear="keyword = ''"
<!-- Loading -->
<view v-if="loading" class="loading-box">
<wd-loading size="50rpx" :color="isDark ? '#f97316' : '#4F46E5'" />
<text>正在寻找...</text>
</view>
<!-- Empty -->
<view v-else-if="results.length === 0" class="empty-box">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<text class="empty-text">未找到匹配的用户</text>
</view>
<!-- List -->
<view v-else class="user-list">
<view
v-for="(item, index) in results"
:key="item.id"
class="user-card animate-fade-in-up"
:style="{ animationDelay: `${index * 50}ms` }"
>
<template #suffix>
<view class="search-btn" @click="doSearch">
<wd-icon name="search" size="36rpx" />
</view>
</template>
</wd-search>
<app-avatar
:src="item.avatar"
:name="item.name"
:size="100"
radius="28rpx"
/>
<view class="card-info">
<text class="user-name">{{ item.name }}</text>
<text class="user-id">ID: {{ item.id }}</text>
</view>
<view class="action-wrap" :class="{ added: addedIds.includes(item.id) }" @click.stop="addFriend(item)">
<svg v-if="!addedIds.includes(item.id)" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
</view>
</view>
</view>
</view>
<!-- 初始态:扫一扫卡片 + 推荐用户 -->
<view v-else class="initial-content">
<view class="feature-card scan-card" @click="scanQrCode">
<view class="card-content">
<view class="icon-bubble">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M3 7V5a2 2 0 0 1 2-2h2"/>
<path d="M17 3h2a2 2 0 0 1 2 2v2"/>
<path d="M21 17v2a2 2 0 0 1-2 2h-2"/>
<path d="M7 21H5a2 2 0 0 1-2-2v-2"/>
<line x1="7" y1="12" x2="17" y2="12"/>
</svg>
</view>
<view class="text-group">
<text class="card-title">扫一扫</text>
<text class="card-desc">扫描二维码名片</text>
</view>
</view>
<svg class="arrow-icon" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.6)" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
<!-- 搜索结果 -->
<view v-if="searched" class="result-section">
<!-- 推荐用户列表 -->
<view v-if="recommendUsers.length > 0" class="recommend-section animate-fade-in-up">
<view class="section-header">
<text class="section-title">搜索结果</text>
<text class="section-title">为您推荐</text>
</view>
<!-- Loading -->
<view v-if="loading" class="loading-box">
<wd-loading size="50rpx" color="#4f46e5" />
<text>正在寻找...</text>
</view>
<!-- Empty -->
<view v-else-if="results.length === 0" class="empty-box">
<text class="empty-icon">🔍</text>
<text class="empty-text">未找到匹配的用户</text>
</view>
<!-- List -->
<view v-else class="user-list">
<view class="user-list">
<view
v-for="item in results"
v-for="(item, index) in recommendUsers"
:key="item.id"
class="user-card"
class="user-card animate-fade-in-up"
:style="{ animationDelay: `${index * 50}ms` }"
>
<app-avatar
:src="item.avatar"
:name="item.name"
:size="110"
radius="36rpx"
:size="100"
radius="28rpx"
/>
<view class="card-info">
<text class="user-name">{{ item.name }}</text>
<text class="user-id">ID: {{ item.id }}</text>
</view>
<view class="action-wrap" @click.stop="addFriend(item)">
<wd-icon name="add" size="36rpx" color="#fff" />
<view class="action-wrap" :class="{ added: addedIds.includes(item.id) }" @click.stop="addFriend(item)">
<svg v-if="!addedIds.includes(item.id)" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
</view>
</view>
</view>
</view>
<!-- 扫码入口 (无搜索结果时显示) -->
<view v-if="!searched" class="feature-cards">
<view class="feature-card scan-card" @click="scanQrCode">
<view class="card-content">
<view class="icon-bubble">
<wd-icon name="scan" size="48rpx" color="#fff" />
</view>
<view class="text-group">
<text class="card-title">扫一扫</text>
<text class="card-desc">扫描二维码名片</text>
</view>
</view>
<wd-icon name="arrow-right" size="32rpx" color="rgba(255,255,255,0.6)" />
</view>
<!-- 加载推荐用户中 -->
<view v-else-if="loadingRecommend" class="loading-box">
<wd-loading size="50rpx" :color="isDark ? '#f97316' : '#4F46E5'" />
<text>获取推荐...</text>
</view>
</view>
</scroll-view>
<wd-toast />
<wd-message-box />
</view>
</wd-config-provider>
<wd-toast />
<wd-message-box />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import * as contactApi from '@/api/modules/contact'
import { useToast, useMessage } from 'wot-design-uni'
import { useTheme } from '@/composables/useTheme'
@@ -109,117 +183,285 @@ import type { User } from '@/types/api'
// --- 保持逻辑完全不变 ---
const toast = useToast(); const message = useMessage(); const { isDark } = useTheme();
const keyword = ref(''); const loading = ref(false); const searched = ref(false); const results = ref<User[]>([]);
const keyword = ref(''); const loading = ref(false); const searched = ref(false); const results = ref<User[]>([]); const addedIds = ref<string[]>([]);
// 推荐用户相关
const recommendUsers = ref<User[]>([]); const loadingRecommend = ref(false);
function goBack() { uni.navigateBack() }
async function doSearch() { if (!keyword.value.trim()) { toast.show('请输入搜索内容'); return } loading.value = true; searched.value = true; try { results.value = await contactApi.searchUsers(keyword.value.trim()) } catch (error) { console.error('搜索失败:', error); toast.error('搜索失败') } finally { loading.value = false } }
async function addFriend(user: User) { try { const { value } = await message.prompt({ title: '添加好友.html', inputPlaceholder: '请输入验证消息', inputValue: '你好,我是...' }); await contactApi.addFriend({ to_user_id: user.id, message: value || '' }); toast.success('申请已发送') } catch (e: any) { if (e !== 'cancel') { toast.error('发送失败') } } }
async function addFriend(user: User) {
if (addedIds.value.includes(user.id)) { toast.show('已发送申请'); return }
try { const { value } = await message.prompt({ title: '添加好友', inputPlaceholder: '请输入验证消息', inputValue: '你好,我是...' }); await contactApi.addFriend({ to_user_id: user.id, message: value || '' }); addedIds.value.push(user.id); toast.success('申请已发送') } catch (e: any) { if (e !== 'cancel') { toast.error('发送失败') } }
}
function scanQrCode() { uni.scanCode({ onlyFromCamera: false, success: (res) => { console.log('扫码结果:', res.result); toast.show('扫码功能开发中') }, fail: () => { toast.error('扫码失败') } }) }
// 加载推荐用户
async function loadRecommendUsers() {
loadingRecommend.value = true;
try {
// 尝试获取推荐用户,如果 API 不存在则使用空列表
if (typeof contactApi.getRecommendUsers === 'function') {
recommendUsers.value = await contactApi.getRecommendUsers();
} else {
// API 不存在时使用默认数据
recommendUsers.value = [];
}
} catch (error) {
console.error('获取推荐用户失败:', error);
recommendUsers.value = [];
} finally {
loadingRecommend.value = false;
}
}
onMounted(() => {
loadRecommendUsers();
});
</script>
<style lang="scss" scoped>
// 页面容器 - 浅色模式
.page-container {
--bg-base: #F7F8FA;
--bg-card: #FFFFFF;
--text-main: #1D2129;
--text-sub: #86909C;
--primary: #4F46E5;
--primary-gradient: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
--shadow-sm: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
--shadow-lg: 0 12rpx 32rpx rgba(79, 70, 229, 0.15);
--bg-base: #f7f8fa;
--bg-card: #ffffff;
--text-main: #1d1d1f;
--text-sub: #6b7280;
--color-brand: #4F46E5;
--gradient-start: #4b6cb7;
--gradient-end: #8E9EFE;
min-height: 100vh;
background: var(--bg-base);
position: relative;
overflow: hidden;
transition: all 0.3s ease;
// 深色模式 - Warm Stone
&.theme-dark {
--bg-base: #121212; /* 高级灰 */
--bg-card: #1C1C1E; /* 卡片灰 */
--text-main: #F2F3F5;
--text-sub: #86909C;
--shadow-sm: 0 4rpx 16rpx rgba(0, 0, 0, 0.2);
--bg-base: #1c1917;
--bg-card: #292524;
--text-main: #f5f5f4;
--text-sub: #78716c;
--color-brand: #f97316;
--gradient-start: #7c2d12;
--gradient-end: #b45309;
}
}
/* 背景装饰球 */
.page-bg-decoration {
position: absolute;
top: -100rpx;
right: -100rpx;
width: 400rpx;
height: 400rpx;
background: radial-gradient(circle, rgba(79, 70, 229, 0.15) 0%, rgba(0,0,0,0) 70%);
border-radius: 50%;
pointer-events: none;
z-index: 0;
// 动画
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.content-body {
.animate-fade-in-up {
animation: fadeInUp 0.4s ease-out forwards;
opacity: 0;
}
.custom-scrollbar {
&::-webkit-scrollbar { display: none; }
-ms-overflow-style: none;
scrollbar-width: none;
}
// 渐变弧形头部
.curved-header {
position: relative;
z-index: 1;
padding: 20rpx 32rpx;
}
height: 720rpx;
overflow: hidden;
.header-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
clip-path: ellipse(150% 100% at 50% 0%);
}
.decoration-orb {
position: absolute;
border-radius: 50%;
filter: blur(70rpx);
pointer-events: none;
&.orb-1 {
width: 300rpx;
height: 300rpx;
background: rgba(255, 255, 255, 0.2);
top: 100rpx;
left: -50rpx;
}
&.orb-2 {
width: 250rpx;
height: 250rpx;
background: rgba(255, 255, 255, 0.15);
top: 200rpx;
right: -80rpx;
filter: blur(80rpx);
}
}
.nav-back {
position: absolute;
top: calc(var(--status-bar-height) + 16rpx);
left: 24rpx;
width: 72rpx;
height: 72rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
z-index: 10;
transition: all 0.15s;
svg {
width: 40rpx;
height: 40rpx;
}
&:active {
transform: scale(0.95);
background: rgba(255, 255, 255, 0.3);
}
}
.header-content {
position: absolute;
bottom: 200rpx;
left: 48rpx;
z-index: 2;
}
/* 头部 */
.header-area {
margin: 20rpx 0 40rpx;
.main-title {
display: block;
font-size: 48rpx;
font-weight: 800;
color: var(--text-main);
letter-spacing: -1rpx;
font-size: 72rpx;
font-weight: 900;
font-style: italic;
color: #fff;
letter-spacing: -2rpx;
text-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.2);
}
.sub-title {
display: block;
font-size: 24rpx;
color: var(--text-sub);
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 2rpx;
margin-top: 8rpx;
letter-spacing: 4rpx;
margin-top: 12rpx;
}
}
/* 搜索栏定制 */
.search-section {
margin-bottom: 40rpx;
// 悬浮搜索框
.search-floater {
position: relative;
margin-top: -80rpx;
padding: 0 32rpx;
z-index: 10;
}
:deep(.glass-search) {
background: var(--bg-card) !important;
padding: 16rpx 24rpx !important;
border-radius: 32rpx !important;
box-shadow: var(--shadow-sm);
.wd-search__field {
background: transparent !important;
height: 60rpx;
}
.wd-icon-search { color: var(--text-sub); }
.uni-input-placeholder { color: var(--text-sub); opacity: 0.6; }
}
.search-btn {
width: 64rpx;
height: 64rpx;
background: var(--primary-gradient);
border-radius: 50%;
.search-box {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 4rpx 12rpx rgba(79, 70, 229, 0.3);
&:active { transform: scale(0.95); }
background: var(--bg-card);
padding: 16rpx 24rpx;
border-radius: 50rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
.search-icon {
width: 40rpx;
height: 40rpx;
color: var(--text-sub);
margin-right: 16rpx;
flex-shrink: 0;
}
.search-input {
flex: 1;
height: 72rpx;
font-size: 30rpx;
color: var(--text-main);
background: transparent;
border: none;
outline: none;
&::placeholder {
color: var(--text-sub);
opacity: 0.6;
}
}
.clear-btn {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-sub);
margin-right: 12rpx;
svg {
width: 36rpx;
height: 36rpx;
}
&:active { opacity: 0.7; }
}
.search-btn {
width: 72rpx;
height: 72rpx;
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(79, 70, 229, 0.3);
flex-shrink: 0;
transition: all 0.15s;
.theme-dark & {
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
box-shadow: 0 4rpx 16rpx rgba(249, 115, 22, 0.3);
}
svg {
width: 36rpx;
height: 36rpx;
}
&:active {
transform: scale(0.95);
}
}
}
/* 结果列表 */
.section-header {
margin-bottom: 24rpx;
.section-title {
font-size: 30rpx;
font-weight: 700;
color: var(--text-main);
// 主内容
.main-content {
height: calc(100vh - 720rpx + 80rpx);
padding: 40rpx 32rpx;
}
// 结果区
.result-section {
.section-header {
margin-bottom: 24rpx;
.section-title {
font-size: 30rpx;
font-weight: 700;
color: var(--text-main);
}
}
}
@@ -232,27 +474,34 @@ function scanQrCode() { uni.scanCode({ onlyFromCamera: false, success: (res) =>
color: var(--text-sub);
font-size: 26rpx;
}
.empty-icon { font-size: 60rpx; opacity: 0.5; }
.empty-icon {
width: 100rpx;
height: 100rpx;
opacity: 0.4;
}
// 用户卡片
.user-card {
background: var(--bg-card);
padding: 24rpx;
border-radius: 32rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
margin-bottom: 24rpx;
box-shadow: var(--shadow-sm);
animation: slideUp 0.3s ease-out;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
.card-info {
flex: 1;
margin-left: 24rpx;
.user-name {
display: block;
font-size: 34rpx;
font-size: 32rpx;
font-weight: 600;
color: var(--text-main);
}
.user-id {
display: block;
margin-top: 6rpx;
@@ -266,23 +515,61 @@ function scanQrCode() { uni.scanCode({ onlyFromCamera: false, success: (res) =>
width: 80rpx;
height: 80rpx;
border-radius: 24rpx;
background: var(--text-main); /* 黑色/白色按钮 */
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
box-shadow: 0 4rpx 12rpx rgba(79, 70, 229, 0.25);
.theme-dark & { background: #333; }
.theme-dark & {
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
box-shadow: 0 4rpx 12rpx rgba(249, 115, 22, 0.25);
}
&:active { transform: scale(0.92); }
svg {
width: 36rpx;
height: 36rpx;
}
&.added {
background: #10b981;
box-shadow: 0 4rpx 12rpx rgba(16, 185, 129, 0.25);
}
&:active {
transform: scale(0.92);
}
}
}
/* 功能卡片 (扫一扫) */
.feature-cards {
margin-top: 20rpx;
// 初始内容
.initial-content {
padding-top: 20rpx;
}
// 推荐区
.recommend-section {
margin-top: 48rpx;
.section-header {
margin-bottom: 24rpx;
.section-title {
font-size: 30rpx;
font-weight: 700;
color: var(--text-main);
}
}
.user-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
}
// 功能卡片
.feature-card {
position: relative;
border-radius: 40rpx;
@@ -291,14 +578,17 @@ function scanQrCode() { uni.scanCode({ onlyFromCamera: false, success: (res) =>
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow-lg);
box-shadow: 0 12rpx 32rpx rgba(79, 70, 229, 0.2);
.theme-dark & {
box-shadow: 0 12rpx 32rpx rgba(249, 115, 22, 0.2);
}
&.scan-card {
background: var(--primary-gradient);
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
color: #fff;
}
/* 卡片内部纹理 */
&::before {
content: '';
position: absolute;
@@ -306,7 +596,7 @@ function scanQrCode() { uni.scanCode({ onlyFromCamera: false, success: (res) =>
left: -20%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%);
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 60%);
pointer-events: none;
}
@@ -320,25 +610,44 @@ function scanQrCode() { uni.scanCode({ onlyFromCamera: false, success: (res) =>
.icon-bubble {
width: 88rpx;
height: 88rpx;
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
svg {
width: 44rpx;
height: 44rpx;
}
}
.text-group {
display: flex;
flex-direction: column;
.card-title { font-size: 36rpx; font-weight: 700; margin-bottom: 6rpx; }
.card-desc { font-size: 24rpx; opacity: 0.8; font-weight: 400; }
.card-title {
font-size: 36rpx;
font-weight: 700;
margin-bottom: 6rpx;
}
.card-desc {
font-size: 24rpx;
opacity: 0.8;
}
}
.arrow-icon {
width: 40rpx;
height: 40rpx;
z-index: 2;
}
&:active {
transform: scale(0.98);
}
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -4,67 +4,116 @@
<view class="profile-header">
<view class="blur-bg" :style="headerBgStyle" />
<view class="gradient-mask" />
<app-nav-bar custom-style="background: transparent;" title-style="color: transparent;" />
<!-- 返回按钮 -->
<view class="nav-back" @click="goBack">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</view>
</view>
<!-- 主体内容 (浮动卡片) -->
<scroll-view scroll-y class="main-content" v-if="!loading && contact">
<scroll-view scroll-y class="main-content custom-scrollbar" v-if="!loading && contact">
<view class="content-wrapper">
<!-- 用户身份卡片 -->
<view class="identity-card">
<view class="identity-card animate-fade-in-up">
<view class="avatar-wrapper">
<app-avatar
:src="contact.user?.avatar"
:name="contact.remark_name || contact.user?.name"
:size="180"
radius="50%"
custom-style="border: 6rpx solid var(--bg-card);"
:size="256"
round
custom-style="border: 12rpx solid var(--bg-card);"
/>
</view>
<view class="info-block">
<text class="user-name">{{ contact.remark_name || contact.user?.name || '未知' }}</text>
<view class="meta-row">
<text class="meta-tag">ID: {{ contact.user?.id || '-' }}</text>
<text class="meta-tag">{{ contact.user?.region || '未知地区' }}</text>
<view class="meta-tag">
<svg 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>
<text>ID: {{ contact.user?.id || '-' }}</text>
</view>
<view class="meta-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
<text>{{ contact.user?.region || '未知地区' }}</text>
</view>
</view>
<text class="bio-text">{{ contact.user?.desc || '这个人很懒,什么都没写' }}</text>
</view>
</view>
<!-- 设置组:资料 -->
<view class="settings-group">
<wd-cell-group border>
<wd-cell title="设置备注" is-link @click="editRemark">
<template #icon><view class="icon-wrap color-blue"><wd-icon name="edit-1" size="32rpx" color="#fff"/></view></template>
<template #value><text class="cell-text">{{ contact.remark_name || '未设置' }}</text></template>
</wd-cell>
</wd-cell-group>
<view class="settings-group animate-fade-in-up" style="animation-delay: 100ms;">
<view class="settings-card">
<view class="setting-item" @click="editRemark">
<view class="icon-wrap color-blue">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</view>
<view class="item-body">
<text class="item-title">设置备注</text>
<text class="item-value">{{ contact.remark_name || '未设置' }}</text>
</view>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
</view>
</view>
<!-- 设置组:交互 -->
<view class="settings-group">
<view class="settings-group animate-fade-in-up" style="animation-delay: 150ms;">
<view class="group-label">隐私与通知</view>
<wd-cell-group border>
<wd-cell title="置顶聊天" center>
<template #icon><view class="icon-wrap color-orange"><wd-icon name="star" size="32rpx" color="#fff"/></view></template>
<template #value>
<wd-switch v-model="contact.is_top" size="24px" active-color="#4F46E5" @change="toggleTop" />
</template>
</wd-cell>
<wd-cell title="消息免打扰" center>
<template #icon><view class="icon-wrap color-purple"><wd-icon name="notification-off" size="32rpx" color="#fff"/></view></template>
<template #value>
<wd-switch v-model="contact.is_muted" size="24px" active-color="#4F46E5" @change="toggleMute" />
</template>
</wd-cell>
<wd-cell title="加入黑名单" center>
<template #icon><view class="icon-wrap color-red"><wd-icon name="forbid" size="32rpx" color="#fff"/></view></template>
<template #value>
<wd-switch v-model="contact.is_blocked" size="24px" active-color="#FA5151" @change="toggleBlock" />
</template>
</wd-cell>
</wd-cell-group>
<view class="settings-card">
<view class="setting-item">
<view class="icon-wrap color-orange">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
</view>
<view class="item-body">
<text class="item-title">置顶聊天</text>
</view>
<wd-switch v-model="contact.is_top" size="24px" :active-color="isDark ? '#f97316' : '#4F46E5'" @change="toggleTop" />
</view>
<view class="setting-item">
<view class="icon-wrap color-purple">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</view>
<view class="item-body">
<text class="item-title">消息免打扰</text>
</view>
<wd-switch v-model="contact.is_muted" size="24px" :active-color="isDark ? '#f97316' : '#4F46E5'" @change="toggleMute" />
</view>
<view class="setting-item">
<view class="icon-wrap color-red">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
</view>
<view class="item-body">
<text class="item-title">加入黑名单</text>
</view>
<wd-switch v-model="contact.is_blocked" size="24px" active-color="#ef4444" @change="toggleBlock" />
</view>
</view>
</view>
<view class="spacer" />
@@ -73,21 +122,28 @@
<!-- Loading -->
<view v-else-if="loading" class="center-loading">
<wd-loading size="60rpx" color="#4F46E5"/>
<wd-loading size="60rpx" :color="isDark ? '#f97316' : '#4F46E5'"/>
</view>
<!-- 底部悬浮操作栏 -->
<view class="floating-dock" v-if="contact">
<view class="dock-content">
<view class="dock-btn secondary" @click="startAudioCall">
<wd-icon name="phone-filled" size="44rpx" />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72"/>
</svg>
</view>
<view class="dock-btn primary" @click="goChat">
<wd-icon name="chat-filled" size="40rpx" />
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" 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>
<text>发消息</text>
</view>
<view class="dock-btn secondary" @click="startVideoCall">
<wd-icon name="video" size="44rpx" />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
</view>
</view>
@@ -123,6 +179,7 @@ onLoad((options: any) => { contactId.value = options?.id || ''; userId.value = o
onMounted(async () => { if (contactId.value) { await loadContact() } else if (userId.value) { await loadContactByUserId() } })
async function loadContact() { loading.value = true; try { contact.value = await contactApi.getContactDetail(contactId.value) } catch (error) { console.error('加载联系人失败:', error); toast.error('加载失败') } finally { loading.value = false } }
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 goBack() { uni.navigateBack() }
function goChat() { if (!contact.value) return; uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}` }) }
function startAudioCall() { if (!contact.value) return; uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}&callType=audio` }) }
function startVideoCall() { if (!contact.value) return; uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}&callType=video` }) }
@@ -134,26 +191,52 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
</script>
<style lang="scss" scoped>
/* 高级灰配色 */
// 页面容器 - 浅色模式
.detail-container {
--bg-base: #F5F7FA;
--bg-card: #FFFFFF;
--text-main: #1D1D1F;
--text-sub: #86909C;
--primary: #4F46E5;
--bg-base: #f7f8fa;
--bg-card: #ffffff;
--text-main: #1d1d1f;
--text-sub: #6b7280;
--color-brand: #4F46E5;
min-height: 100vh;
background: var(--bg-base);
position: relative;
// 深色模式 - Warm Stone
&.theme-dark {
--bg-base: #121212; /* 深度灰 */
--bg-card: #1C1C1E; /* 卡片灰 */
--text-main: #F7F8FA;
--text-sub: #888;
--bg-base: #1c1917;
--bg-card: #292524;
--text-main: #f5f5f4;
--text-sub: #78716c;
--color-brand: #f97316;
}
}
// 动画
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.4s ease-out forwards;
opacity: 0;
}
.custom-scrollbar {
&::-webkit-scrollbar { display: none; }
-ms-overflow-style: none;
scrollbar-width: none;
}
// 视差头部背景
.profile-header {
position: absolute;
top: 0;
@@ -168,19 +251,50 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
height: 100%;
background-size: cover;
background-position: center;
filter: blur(50px);
transform: scale(1.2);
opacity: 0.8;
.theme-dark & { opacity: 0.4; }
filter: blur(40px);
transform: scale(1.1);
opacity: 0.7;
.theme-dark & {
opacity: 0.4;
}
}
.gradient-mask {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 0%, var(--bg-base) 90%);
background: linear-gradient(to bottom, transparent 0%, var(--bg-base) 85%);
}
.nav-back {
position: absolute;
top: calc(var(--status-bar-height) + 16rpx);
left: 24rpx;
width: 72rpx;
height: 72rpx;
background: rgba(0, 0, 0, 0.15);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
z-index: 10;
transition: all 0.15s;
svg {
width: 40rpx;
height: 40rpx;
color: #fff;
}
&:active {
transform: scale(0.95);
background: rgba(0, 0, 0, 0.25);
}
}
}
// 主内容
.main-content {
height: 100vh;
position: relative;
@@ -189,10 +303,10 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
.content-wrapper {
padding: 0 32rpx;
padding-top: calc(180rpx + var(--status-bar-height));
padding-top: calc(220rpx + var(--status-bar-height));
}
/* 身份卡片 */
// 身份卡片
.identity-card {
display: flex;
flex-direction: column;
@@ -200,7 +314,7 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
margin-bottom: 60rpx;
.avatar-wrapper {
box-shadow: 0 16rpx 40rpx rgba(0,0,0,0.1);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.15);
border-radius: 50%;
}
@@ -210,10 +324,9 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
.user-name {
font-size: 48rpx;
font-weight: 800;
font-weight: 700;
color: var(--text-main);
margin-bottom: 16rpx;
letter-spacing: -1rpx;
}
.meta-row {
@@ -223,49 +336,75 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
margin-bottom: 24rpx;
.meta-tag {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 24rpx;
color: var(--text-sub);
background: rgba(0,0,0,0.03);
.theme-dark & { background: rgba(255,255,255,0.08); }
padding: 6rpx 20rpx;
border-radius: 12rpx;
background: rgba(0, 0, 0, 0.04);
padding: 10rpx 20rpx;
border-radius: 20rpx;
.theme-dark & {
background: rgba(255, 255, 255, 0.08);
}
svg {
width: 24rpx;
height: 24rpx;
}
}
}
.bio-text {
font-size: 28rpx;
color: var(--text-main);
opacity: 0.6;
color: var(--text-sub);
max-width: 500rpx;
line-height: 1.6;
}
}
}
/* 设置组 */
// 设置组
.settings-group {
margin-bottom: 40rpx;
.group-label {
font-size: 26rpx;
color: var(--text-sub);
margin-bottom: 20rpx;
margin-bottom: 16rpx;
margin-left: 12rpx;
font-weight: 600;
}
}
:deep(.wd-cell-group) {
border-radius: 32rpx;
overflow: hidden;
background: var(--bg-card);
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.02);
.settings-card {
background: var(--bg-card);
border-radius: 24rpx;
overflow: hidden;
}
.setting-item {
display: flex;
align-items: center;
padding: 28rpx 28rpx;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.04);
transition: background 0.15s;
.theme-dark & {
border-bottom-color: rgba(255, 255, 255, 0.06);
}
:deep(.wd-cell) {
background: transparent;
padding: 36rpx 32rpx;
&::after { left: 110rpx; background: rgba(0,0,0,0.05); }
.theme-dark &::after { background: rgba(255,255,255,0.05); }
&:last-child {
border-bottom: none;
}
&:active {
background: rgba(0, 0, 0, 0.02);
.theme-dark & {
background: rgba(255, 255, 255, 0.02);
}
}
.icon-wrap {
@@ -277,78 +416,128 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
justify-content: center;
margin-right: 24rpx;
&.color-blue { background: #007AFF; }
&.color-orange { background: #FF9500; }
&.color-purple { background: #5856D6; }
&.color-red { background: #FF3B30; }
svg {
width: 32rpx;
height: 32rpx;
}
&.color-blue { background: #0ea5e9; }
&.color-orange { background: #f97316; }
&.color-purple { background: #8b5cf6; }
&.color-red { background: #ef4444; }
}
.cell-text {
.item-body {
flex: 1;
display: flex;
flex-direction: column;
}
.item-title {
font-size: 30rpx;
color: var(--text-main);
font-weight: 500;
}
.item-value {
font-size: 26rpx;
color: var(--text-sub);
font-size: 28rpx;
margin-top: 4rpx;
}
.chevron {
width: 32rpx;
height: 32rpx;
color: var(--text-sub);
opacity: 0.5;
}
}
.spacer { height: 300rpx; }
.spacer {
height: 320rpx;
}
/* 底部操作台 */
// 底部操作台
.floating-dock {
position: fixed;
bottom: 0;
width: 100%;
left: 0;
right: 0;
padding: 30rpx 40rpx;
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
background: var(--bg-card);
border-top: 1rpx solid rgba(0,0,0,0.05);
border-radius: 48rpx 48rpx 0 0;
box-shadow: 0 -10rpx 40rpx rgba(0,0,0,0.04);
border-top: 1rpx solid rgba(0, 0, 0, 0.05);
border-radius: 64rpx 64rpx 0 0;
box-shadow: 0 -20rpx 80rpx rgba(0, 0, 0, 0.05);
z-index: 100;
.theme-dark & { border-top-color: rgba(255,255,255,0.05); }
.theme-dark & {
border-top-color: rgba(255, 255, 255, 0.05);
}
.dock-content {
display: flex;
align-items: center;
gap: 32rpx;
margin-bottom: 30rpx;
gap: 24rpx;
margin-bottom: 24rpx;
.dock-btn {
height: 110rpx;
border-radius: 36rpx;
height: 100rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
transition: all 0.15s;
&:active { transform: scale(0.96); }
svg {
width: 44rpx;
height: 44rpx;
}
&:active {
transform: scale(0.96);
}
&.primary {
flex: 2;
background: var(--primary);
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
color: #fff;
gap: 16rpx;
font-weight: 600;
font-size: 34rpx;
box-shadow: 0 10rpx 24rpx rgba(79, 70, 229, 0.2);
font-size: 32rpx;
box-shadow: 0 8rpx 24rpx rgba(79, 70, 229, 0.25);
.theme-dark & {
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
box-shadow: 0 8rpx 24rpx rgba(249, 115, 22, 0.25);
}
}
&.secondary {
flex: 1;
background: rgba(0,0,0,0.04);
background: rgba(0, 0, 0, 0.04);
color: var(--text-main);
.theme-dark & { background: rgba(255,255,255,0.08); }
.theme-dark & {
background: rgba(255, 255, 255, 0.08);
}
}
}
}
.danger-btn {
text-align: center;
text {
color: var(--text-sub);
font-size: 26rpx;
text-decoration: underline;
padding: 10rpx 20rpx;
opacity: 0.8;
opacity: 0.6;
}
&:active text {
opacity: 1;
color: #ef4444;
}
}
}

View File

@@ -5,28 +5,36 @@
<!-- 头部固定区域 -->
<view class="fixed-header">
<view class="nav-bar">
<text class="nav-title">通讯录</text>
<!-- 修复:添加好友按钮 -->
<text class="nav-title">联系人</text>
<view class="icon-btn" @click="goAddFriend">
<wd-icon name="add-user" size="48rpx" color="var(--text-primary)" />
<svg class="add-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
</view>
</view>
<view class="search-box-wrap">
<wd-search
v-model="searchKeyword"
placeholder="搜索联系人/群聊"
disabled
hide-cancel
custom-style="background: var(--bg-surface);"
@click="goSearch"
/>
<view class="search-inner" @click="goSearch">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<text class="search-placeholder">搜索联系人/群聊</text>
</view>
</view>
<!-- 分段控制器 -->
<view class="tabs-wrapper">
<view class="segmented-control">
<view
v-for="tab in tabs"
class="segment-slider"
:style="{ transform: `translateX(${activeTabIndex * 100}%)` }"
/>
<view
v-for="(tab, index) in tabs"
:key="tab.key"
class="segment-item"
:class="{ active: activeTab === tab.key }"
@@ -40,53 +48,76 @@
<!-- 滚动内容 -->
<scroll-view
class="main-scroll"
class="main-scroll custom-scrollbar"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- 修复:顶部间距,防止Grid被遮挡 -->
<view class="scroll-spacer"></view>
<!-- 功能 Grid -->
<view v-if="activeTab === 'groups'" class="feature-grid">
<view class="feature-card" @click="goFriendRequests">
<view class="icon-wrap is-orange"><wd-icon name="user-add" size="36rpx" color="#fff" /></view>
<view class="icon-wrap is-orange">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
</view>
<view class="text-wrap">
<text class="feature-title">新朋友</text>
<wd-badge v-if="friendRequestCount > 0" :value="friendRequestCount" type="danger" />
<text v-if="friendRequestCount > 0" class="badge">{{ friendRequestCount }}</text>
</view>
</view>
<view class="feature-card" @click="goGroupNotify">
<view class="icon-wrap is-blue"><wd-icon name="bell" size="36rpx" color="#fff" /></view>
<view class="text-wrap"><text class="feature-title">群通知</text></view>
<view class="icon-wrap is-blue">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
</view>
<view class="text-wrap">
<text class="feature-title">群通知</text>
</view>
</view>
</view>
<!-- 列表内容 -->
<view class="list-container">
<view v-if="loading" class="loading-box"><wd-loading size="40rpx" /></view>
<view v-if="loading" class="loading-box">
<wd-loading size="40rpx" />
</view>
<!-- 1. 分组 Tab -->
<template v-else-if="activeTab === 'groups'">
<view class="group-section-header">
<text class="section-title">我的分组</text>
<view class="add-group-btn" @click="showCreateGroupModal = true">
<wd-icon name="add" size="24rpx" /> <text>新建</text>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
<text>新建</text>
</view>
</view>
<!-- 未分组 -->
<view v-if="ungroupedContacts.length" class="collapse-item">
<view v-if="ungroupedContacts.length" class="collapse-item animate-fade-in-up">
<view class="collapse-header" @click="toggleCollapse(0)">
<wd-icon :name="collapsedIds.includes(0) ? 'arrow-right' : 'arrow-down'" size="24rpx" color="var(--text-tertiary)" />
<view class="collapse-arrow" :class="{ collapsed: collapsedIds.includes(0) }">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</view>
<text class="collapse-title">未分组</text>
<text class="collapse-count">{{ ungroupedContacts.length }}</text>
</view>
<view v-show="!collapsedIds.includes(0)" class="collapse-content">
<view v-for="contact in ungroupedContacts" :key="contact.id" class="contact-cell" @click="goContactDetail(contact)" @longpress="handleContactLongPress(contact)">
<app-avatar :src="contact.user?.avatar" :name="contact.remark_name || contact.user?.name" :size="80" radius="12rpx" />
<app-avatar :src="contact.user?.avatar" :name="contact.remark_name || contact.user?.name" :size="80" radius="20rpx" />
<view class="cell-body">
<text class="cell-title">{{ contact.remark_name || contact.user?.name }}</text>
<text class="cell-desc">{{ contact.user?.desc || '暂无签名' }}</text>
@@ -96,15 +127,19 @@
</view>
<!-- 自定义分组 -->
<view v-for="group in contactGroups" :key="group.id" class="collapse-item">
<view v-for="(group, index) in contactGroups" :key="group.id" class="collapse-item animate-fade-in-up" :style="{ animationDelay: `${(index + 1) * 50}ms` }">
<view class="collapse-header" @click="toggleCollapse(group.id)" @longpress="handleGroupLongPress(group)">
<wd-icon :name="collapsedIds.includes(group.id) ? 'arrow-right' : 'arrow-down'" size="24rpx" color="var(--text-tertiary)" />
<view class="collapse-arrow" :class="{ collapsed: collapsedIds.includes(group.id) }">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</view>
<text class="collapse-title">{{ group.group_name }}</text>
<text class="collapse-count">{{ getGroupContacts(group.id).length }}</text>
</view>
<view v-show="!collapsedIds.includes(group.id)" class="collapse-content">
<view v-for="contact in getGroupContacts(group.id)" :key="contact.id" class="contact-cell" @click="goContactDetail(contact)" @longpress="handleContactLongPress(contact)">
<app-avatar :src="contact.user?.avatar" :name="contact.remark_name || contact.user?.name" :size="80" radius="12rpx" />
<app-avatar :src="contact.user?.avatar" :name="contact.remark_name || contact.user?.name" :size="80" radius="20rpx" />
<view class="cell-body">
<text class="cell-title">{{ contact.remark_name || contact.user?.name }}</text>
<text class="cell-desc">{{ contact.user?.desc }}</text>
@@ -116,11 +151,11 @@
<!-- 2. 好友 Tab (A-Z) -->
<template v-else-if="activeTab === 'friends'">
<view v-for="group in alphabetGroupedContacts" :key="group.letter" class="alpha-group">
<view v-for="group in alphabetGroupedContacts" :key="group.letter" class="alpha-group" :id="`letter-${group.letter}`">
<view class="alpha-header">{{ group.letter }}</view>
<view class="alpha-list">
<view v-for="contact in group.contacts" :key="contact.id" class="contact-cell" @click="goContactDetail(contact)">
<app-avatar :src="contact.user?.avatar" :name="contact.remark_name || contact.user?.name" :size="80" radius="12rpx" />
<app-avatar :src="contact.user?.avatar" :name="contact.remark_name || contact.user?.name" :size="80" radius="20rpx" />
<view class="cell-body border-bottom">
<text class="cell-title">{{ contact.remark_name || contact.user?.name }}</text>
</view>
@@ -129,48 +164,57 @@
</view>
</template>
<!-- 3. 群聊 Tab (修复:显示所有群组) -->
<!-- 3. 群聊 Tab -->
<template v-else-if="activeTab === 'chats'">
<!-- 我创建的 -->
<view v-if="createdGroups.length" class="collapse-item">
<view v-if="createdGroups.length" class="collapse-item animate-fade-in-up">
<view class="collapse-header" @click="toggleCollapse('created')">
<wd-icon :name="collapsedIds.includes('created') ? 'arrow-right' : 'arrow-down'" size="24rpx" color="var(--text-tertiary)" />
<view class="collapse-arrow" :class="{ collapsed: collapsedIds.includes('created') }">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</view>
<text class="collapse-title">我创建的群</text>
<text class="collapse-count">{{ createdGroups.length }}</text>
</view>
<view v-show="!collapsedIds.includes('created')" class="collapse-content">
<view v-for="g in createdGroups" :key="g.room_id" class="contact-cell" @click="goGroupChat(g)">
<app-avatar :src="g.room_avatar" :name="g.room_name" :size="80" radius="12rpx" />
<app-avatar :src="g.room_avatar" :name="g.room_name" :size="80" radius="20rpx" />
<view class="cell-body"><text class="cell-title">{{ g.room_name }}</text></view>
</view>
</view>
</view>
<!-- 我管理的 -->
<view v-if="managedGroups.length" class="collapse-item">
<view v-if="managedGroups.length" class="collapse-item animate-fade-in-up" style="animation-delay: 50ms;">
<view class="collapse-header" @click="toggleCollapse('managed')">
<wd-icon :name="collapsedIds.includes('managed') ? 'arrow-right' : 'arrow-down'" size="24rpx" color="var(--text-tertiary)" />
<view class="collapse-arrow" :class="{ collapsed: collapsedIds.includes('managed') }">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</view>
<text class="collapse-title">我管理的群</text>
<text class="collapse-count">{{ managedGroups.length }}</text>
</view>
<view v-show="!collapsedIds.includes('managed')" class="collapse-content">
<view v-for="g in managedGroups" :key="g.room_id" class="contact-cell" @click="goGroupChat(g)">
<app-avatar :src="g.room_avatar" :name="g.room_name" :size="80" radius="12rpx" />
<app-avatar :src="g.room_avatar" :name="g.room_name" :size="80" radius="20rpx" />
<view class="cell-body"><text class="cell-title">{{ g.room_name }}</text></view>
</view>
</view>
</view>
<!-- 我加入的 -->
<view v-if="joinedGroups.length" class="collapse-item">
<view v-if="joinedGroups.length" class="collapse-item animate-fade-in-up" style="animation-delay: 100ms;">
<view class="collapse-header" @click="toggleCollapse('joined')">
<wd-icon :name="collapsedIds.includes('joined') ? 'arrow-right' : 'arrow-down'" size="24rpx" color="var(--text-tertiary)" />
<view class="collapse-arrow" :class="{ collapsed: collapsedIds.includes('joined') }">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</view>
<text class="collapse-title">我加入的群</text>
<text class="collapse-count">{{ joinedGroups.length }}</text>
</view>
<view v-show="!collapsedIds.includes('joined')" class="collapse-content">
<view v-for="g in joinedGroups" :key="g.room_id" class="contact-cell" @click="goGroupChat(g)">
<app-avatar :src="g.room_avatar" :name="g.room_name" :size="80" radius="12rpx" />
<app-avatar :src="g.room_avatar" :name="g.room_name" :size="80" radius="20rpx" />
<view class="cell-body"><text class="cell-title">{{ g.room_name }}</text></view>
</view>
</view>
@@ -181,15 +225,18 @@
</view>
</scroll-view>
<!-- 索引条 -->
<!-- A-Z 索引条 -->
<view v-if="activeTab === 'friends'" class="index-bar">
<view v-for="l in letters" :key="l" class="index-item" @click.stop="scrollToLetter(l)">{{ l }}</view>
<view v-for="l in letters" :key="l" class="index-item" @click.stop="scrollToLetter(l)">
{{ l }}
</view>
</view>
<!-- 弹窗组件 -->
<wd-action-sheet v-model="showContactActions" :actions="contactActionItems" @select="onContactActionSelect" cancel-text="取消" />
<wd-action-sheet v-model="showGroupActions" :actions="groupActionItems" @select="onGroupActionSelect" cancel-text="取消" />
<wd-action-sheet v-model="showMoveGroupModal" :actions="moveGroupActions" @select="onMoveGroupSelect" cancel-text="取消" />
<wd-popup v-model="showCreateGroupModal" custom-style="border-radius: 24rpx; width: 80%; padding: 40rpx; background: var(--bg-surface);">
<view class="modal-content">
<view class="modal-title">新建分组</view>
@@ -200,6 +247,7 @@
</view>
</view>
</wd-popup>
<wd-popup v-model="showRenameGroupModal" custom-style="border-radius: 24rpx; width: 80%; padding: 40rpx; background: var(--bg-surface);">
<view class="modal-content">
<view class="modal-title">重命名分组</view>
@@ -210,6 +258,7 @@
</view>
</view>
</wd-popup>
<wd-popup v-model="showRemarkModal" custom-style="border-radius: 24rpx; width: 80%; padding: 40rpx; background: var(--bg-surface);">
<view class="modal-content">
<view class="modal-title">修改备注</view>
@@ -221,6 +270,9 @@
</view>
</wd-popup>
<!-- 全局群通话组件 -->
<global-call-provider />
<wd-toast />
<wd-message-box />
<app-tab-bar current="contacts" />
@@ -238,6 +290,7 @@ import * as contactApi from '@/api/modules/contact'
import * as roomApi from '@/api/modules/room'
import AppAvatar from '@/components/common/AppAvatar.vue'
import AppTabBar from '@/components/common/AppTabBar.vue'
import GlobalCallProvider from '@/components/call/GlobalCallProvider.vue'
import type { Contact, ContactGroup } from '@/types/api'
// --- 逻辑完全不变 ---
@@ -247,7 +300,11 @@ const tabs = [{ key: 'groups', label: '分组' }, { key: 'friends', label: '好
const contacts = computed(() => chatStore.contacts); const friendRequestCount = computed(() => contactStore.friendRequests.filter(r => r.status === 'pending').length); const ungroupedContacts = computed(() => 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 alphabetGroupedContacts = computed(() => { const groups: Record<string, Contact[]> = {}; contacts.value.forEach(contact => { const name = contact.remark_name || contact.user?.name || ''; const firstChar = name.charAt(0).toUpperCase(); const letter = /[A-Z]/.test(firstChar) ? firstChar : '#'; if (!groups[letter]) groups[letter] = []; groups[letter].push(contact) }); return Object.keys(groups).sort((a, b) => (a === '#' ? 1 : b === '#' ? -1 : a.localeCompare(b))).map(letter => ({ letter, contacts: groups[letter] })) });
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'));
const contactActionItems = [{ name: '修改备注', value: 'remark' }, { name: '移动到分组', value: 'move' }, { name: '删除好友', value: 'delete', color: '#fa5151' }]; const groupActionItems = [{ name: '重命名', value: 'rename' }, { name: '删除分组', value: 'delete', color: '#fa5151' }]; const moveGroupActions = computed(() => { const actions: any[] = [{ name: '未分组', value: 0 }]; contactGroups.value.forEach(g => { if (selectedContact.value?.group_id !== g.id) actions.push({ name: g.group_name, value: g.id }) }); return actions });
const contactActionItems = [{ name: '修改备注', value: 'remark' }, { name: '移动到分组', value: 'move' }, { name: '删除好友', value: 'delete', color: '#ef4444' }]; const groupActionItems = [{ name: '重命名', value: 'rename' }, { name: '删除分组', value: 'delete', color: '#ef4444' }]; const moveGroupActions = computed(() => { const actions: any[] = [{ name: '未分组', value: 0 }]; contactGroups.value.forEach(g => { if (selectedContact.value?.group_id !== g.id) actions.push({ name: g.group_name, value: g.id }) }); return actions });
// 计算当前选中的标签索引
const activeTabIndex = computed(() => tabs.findIndex(t => t.key === activeTab.value))
onMounted(() => loadData()); onShow(() => loadFriendRequests());
async function loadData() { loading.value = true; try { 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) } finally { loading.value = false } }
async function loadFriendRequests() { try { const requests = await contactApi.getFriendRequests(); contactStore.setFriendRequests(requests) } catch {} }
@@ -273,104 +330,495 @@ async function handleDeleteGroup() { try { await messageBox.confirm({title:'提
</script>
<style lang="scss" scoped>
// ==========================================
// 页面容器 - 浅色模式 (与设计稿一致)
// ==========================================
.page-container {
--bg-page: #f7f8fa;
--bg-page: #ffffff;
--bg-surface: #ffffff;
--bg-highlight: #f2f3f5;
--text-primary: #1f1f1f;
--bg-highlight: #f5f5f5;
--bg-group: rgba(0, 0, 0, 0.02);
--text-primary: #1f2937;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--border-color: rgba(0,0,0,0.05);
--brand-color: #4f46e5;
--tab-bg: #e5e7eb;
--border-color: rgba(0, 0, 0, 0.05);
--color-brand: #4F46E5;
--tab-bg: #f1f1f1;
--tab-active-bg: #ffffff;
--search-bg: #f1f1f1;
min-height: 100vh;
background: var(--bg-page);
color: var(--text-primary);
}
// ==========================================
// 深色模式 - Warm Stone (与设计稿一致)
// ==========================================
.page-container.dark {
--bg-page: #1c1c1e; /* Dark Grey */
--bg-surface: #2c2c2e;
--bg-highlight: #3a3a3c;
--text-primary: #f2f2f7;
--text-secondary: #aeaeb2;
--text-tertiary: #636366;
--border-color: rgba(255,255,255,0.1);
--tab-bg: #2c2c2e;
--tab-active-bg: #636366;
--bg-page: #1c1917;
--bg-surface: #1c1917;
--bg-highlight: #44403c;
--bg-group: rgba(255, 255, 255, 0.03);
--text-primary: #f5f5f4;
--text-secondary: #e7e5e4;
--text-tertiary: #78716c;
--border-color: #44403c;
--color-brand: #f97316;
--tab-bg: #292524;
--tab-active-bg: #44403c;
--search-bg: #292524;
}
// 动画
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.4s ease-out forwards;
opacity: 0;
}
.custom-scrollbar {
&::-webkit-scrollbar { display: none; }
-ms-overflow-style: none;
scrollbar-width: none;
}
// ==========================================
// 固定头部 (与设计稿一致)
// ==========================================
.fixed-header {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: var(--bg-page);
padding-top: var(--status-bar-height);
border-bottom: 1rpx solid var(--border-color);
transition: background 0.3s;
}
.nav-bar {
height: 88rpx; display: flex; align-items: center; justify-content: space-between; padding: 0 32rpx;
.nav-title { font-size: 40rpx; font-weight: 700; }
.icon-btn { &:active { opacity: 0.7; } }
height: 88rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40rpx 16rpx;
.nav-title {
font-size: 42rpx;
font-weight: 700;
letter-spacing: -0.5rpx;
}
.icon-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--search-bg);
transition: all 0.15s;
&:active {
background: var(--bg-highlight);
transform: scale(0.95);
}
.add-icon {
width: 32rpx;
height: 32rpx;
color: var(--text-secondary);
}
}
}
.search-box-wrap { padding: 10rpx 24rpx; }
// 搜索栏
.search-box-wrap {
padding: 10rpx 32rpx;
}
.search-inner {
height: 80rpx;
background: var(--search-bg);
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
transition: background 0.2s;
&:active {
background: var(--bg-highlight);
}
.search-icon {
width: 32rpx;
height: 32rpx;
color: var(--text-tertiary);
}
.search-placeholder {
font-size: 28rpx;
color: var(--text-tertiary);
}
}
// 分段控制器
.tabs-wrapper {
padding: 16rpx 32rpx 24rpx;
}
.tabs-wrapper { padding: 16rpx 32rpx 24rpx; }
.segmented-control {
background: var(--tab-bg); border-radius: 16rpx; padding: 6rpx; display: flex; height: 72rpx;
position: relative;
background: var(--tab-bg);
border-radius: 16rpx;
padding: 6rpx;
display: flex;
height: 72rpx;
.segment-slider {
position: absolute;
top: 6rpx;
left: 6rpx;
width: calc((100% - 12rpx) / 3);
height: calc(100% - 12rpx);
background: var(--tab-active-bg);
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.segment-item {
flex: 1; display: flex; align-items: center; justify-content: center;
font-size: 28rpx; font-weight: 500; color: var(--text-secondary); border-radius: 12rpx;
transition: all 0.3s;
&.active { background: var(--tab-active-bg); color: var(--text-primary); font-weight: 600; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.08); }
position: relative;
z-index: 1;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 500;
color: var(--text-secondary);
transition: color 0.3s;
&.active {
color: var(--text-primary);
font-weight: 600;
}
}
}
.main-scroll { margin-top: calc(var(--status-bar-height) + 290rpx); height: calc(100vh - var(--status-bar-height) - 290rpx); }
// 滚动区
.main-scroll {
margin-top: calc(var(--status-bar-height) + 290rpx);
height: calc(100vh - var(--status-bar-height) - 290rpx);
}
.scroll-spacer {
height: 20rpx;
}
// ==========================================
// 功能卡片 (与设计稿一致)
// ==========================================
.feature-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
padding: 16rpx 32rpx 16rpx;
}
/* 修复:Grid 顶部垫片 */
.scroll-spacer { height: 20rpx; }
.feature-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24rpx; padding: 0 32rpx 32rpx; }
.feature-card {
background: var(--bg-surface); padding: 24rpx; border-radius: 20rpx; display: flex; align-items: center; gap: 20rpx;
.icon-wrap { width: 80rpx; height: 80rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; &.is-orange { background: linear-gradient(135deg, #f97316, #fb923c); } &.is-blue { background: linear-gradient(135deg, #0ea5e9, #38bdf8); } }
.text-wrap { flex: 1; display: flex; flex-direction: column; .feature-title { font-weight: 600; font-size: 28rpx; } }
}
background: var(--bg-surface);
padding: 24rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
gap: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
border: 1rpx solid var(--border-color);
transition: transform 0.15s;
.list-container { padding: 0 32rpx; }
.group-section-header {
display: flex; align-items: center; justify-content: space-between; margin: 32rpx 0 16rpx;
.section-title { font-size: 32rpx; font-weight: 700; color: var(--text-secondary); }
.add-group-btn { display: flex; align-items: center; gap: 4rpx; font-size: 26rpx; color: var(--brand-color); background: rgba(79, 70, 229, 0.1); padding: 8rpx 20rpx; border-radius: 30rpx; }
}
.collapse-item { margin-bottom: 24rpx; background: var(--bg-surface); border-radius: 20rpx; overflow: hidden; }
.collapse-header {
padding: 24rpx; display: flex; align-items: center; gap: 16rpx; &:active { background: var(--bg-highlight); }
.collapse-title { flex: 1; font-weight: 600; font-size: 30rpx; }
.collapse-count { color: var(--text-tertiary); font-size: 24rpx; }
}
.contact-cell {
padding: 20rpx 24rpx; display: flex; align-items: center; gap: 24rpx; &:active { background: var(--bg-highlight); }
.cell-body {
flex: 1; display: flex; flex-direction: column; padding-right: 20rpx;
&.border-bottom { padding-bottom: 20rpx; border-bottom: 1rpx solid var(--border-color); }
.dark & {
box-shadow: none;
}
&:active {
transform: scale(0.98);
}
.icon-wrap {
width: 80rpx;
height: 80rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 40rpx;
height: 40rpx;
}
&.is-orange {
background: linear-gradient(135deg, #fb923c, #f97316);
}
&.is-blue {
background: linear-gradient(135deg, #38bdf8, #0ea5e9);
}
}
.text-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
.feature-title {
font-weight: 700;
font-size: 28rpx;
}
.badge {
font-size: 20rpx;
font-weight: 500;
color: #ef4444;
}
}
.cell-title { font-size: 30rpx; font-weight: 500; margin-bottom: 6rpx; }
.cell-desc { font-size: 24rpx; color: var(--text-tertiary); }
}
.alpha-group { margin-bottom: 30rpx; }
.alpha-header { font-size: 24rpx; font-weight: 700; color: var(--brand-color); margin-bottom: 12rpx; }
// 列表容器
.list-container {
padding: 0 32rpx;
}
.loading-box {
display: flex;
justify-content: center;
padding: 60rpx;
}
// 分组头部
.group-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 32rpx 0 16rpx;
.section-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text-secondary);
}
.add-group-btn {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 26rpx;
color: var(--color-brand);
background: rgba(79, 70, 229, 0.1);
padding: 10rpx 20rpx;
border-radius: 30rpx;
transition: all 0.15s;
svg {
width: 24rpx;
height: 24rpx;
}
.dark & {
background: rgba(249, 115, 22, 0.15);
}
&:active {
opacity: 0.7;
}
}
}
// ==========================================
// 折叠项 (与设计稿一致)
// ==========================================
.collapse-item {
margin-bottom: 16rpx;
background: var(--bg-group);
border-radius: 24rpx;
overflow: hidden;
border: 1rpx solid var(--border-color);
}
.collapse-header {
padding: 24rpx;
display: flex;
align-items: center;
gap: 16rpx;
transition: background 0.15s;
&:active {
background: var(--bg-highlight);
}
.collapse-arrow {
width: 32rpx;
height: 32rpx;
color: var(--text-tertiary);
transition: transform 0.2s;
svg {
width: 100%;
height: 100%;
}
&.collapsed {
transform: rotate(-90deg);
}
}
.collapse-title {
flex: 1;
font-weight: 600;
font-size: 30rpx;
}
.collapse-count {
color: var(--text-tertiary);
font-size: 24rpx;
}
}
.collapse-content {
border-top: 1rpx solid var(--border-color);
}
// 联系人单元格
.contact-cell {
padding: 20rpx 24rpx;
display: flex;
align-items: center;
gap: 24rpx;
transition: background 0.15s;
&:active {
background: var(--bg-highlight);
}
.cell-body {
flex: 1;
display: flex;
flex-direction: column;
padding-right: 20rpx;
&.border-bottom {
padding-bottom: 20rpx;
border-bottom: 1rpx solid var(--border-color);
}
}
.cell-title {
font-size: 30rpx;
font-weight: 500;
margin-bottom: 6rpx;
}
.cell-desc {
font-size: 24rpx;
color: var(--text-tertiary);
}
}
// 字母分组
.alpha-group {
margin-bottom: 30rpx;
}
.alpha-header {
font-size: 26rpx;
font-weight: 700;
color: var(--color-brand);
margin-bottom: 12rpx;
padding-left: 4rpx;
}
.alpha-list {
background: var(--bg-surface);
border-radius: 20rpx;
overflow: hidden;
}
// A-Z索引条
.index-bar {
position: fixed; right: 8rpx; top: 55%; transform: translateY(-50%);
background: var(--bg-highlight); border-radius: 20rpx; padding: 16rpx 6rpx; z-index: 101;
.index-item { font-size: 20rpx; font-weight: 600; padding: 2rpx 10rpx; text-align: center; color: var(--text-secondary); margin: 4rpx 0; &:active { color: var(--brand-color); } }
position: fixed;
right: 16rpx;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.04);
border-radius: 20rpx;
padding: 16rpx 8rpx;
z-index: 101;
.dark & {
background: rgba(255, 255, 255, 0.08);
}
.index-item {
font-size: 20rpx;
font-weight: 600;
width: 32rpx;
height: 32rpx;
line-height: 32rpx;
text-align: center;
color: var(--text-secondary);
margin: 2rpx 0;
border-radius: 50%;
transition: all 0.15s;
&:active {
color: var(--color-brand);
background: rgba(79, 70, 229, 0.1);
.dark & {
background: rgba(249, 115, 22, 0.15);
}
}
}
}
.modal-content { display: flex; flex-direction: column; align-items: center; .modal-title { font-size: 34rpx; font-weight: 600; margin-bottom: 10rpx; color: var(--text-primary); } .modal-btns { display: flex; gap: 32rpx; width: 100%; justify-content: space-between; margin-top: 20rpx;} }
.safe-area-spacer { height: 180rpx; }
// 弹窗
.modal-content {
display: flex;
flex-direction: column;
align-items: center;
.modal-title {
font-size: 34rpx;
font-weight: 600;
margin-bottom: 10rpx;
color: var(--text-primary);
}
.modal-btns {
display: flex;
gap: 32rpx;
width: 100%;
justify-content: space-between;
margin-top: 20rpx;
}
}
.safe-area-spacer {
height: 180rpx;
}
</style>

View File

@@ -2,45 +2,58 @@
<wd-config-provider :theme="isDark ? 'dark' : 'light'">
<view class="page-container" :class="{ dark: isDark }">
<!-- 1. 沉浸式导航栏 -->
<!-- 1. 顶部导航栏 (与设计稿完全一致) -->
<view class="custom-navbar">
<view class="navbar-bg"></view>
<view class="navbar-content">
<view class="nav-left-group" @click="openDrawer">
<app-avatar
:src="user?.avatar"
:name="user?.name"
:size="76"
round
custom-style="border: 2rpx solid var(--border-subtle);"
/>
<view class="nav-info">
<text class="nav-title">{{ user?.name || '消息' }}</text>
<view class="online-tag" v-if="!loading">
<view class="dot"></view>在线
<!-- 左侧: 头像 + 标题 -->
<view class="nav-left" @click="openDrawer">
<view class="avatar-container">
<image
v-if="user?.avatar"
class="nav-avatar"
:src="resolveImageUrl(user.avatar)"
mode="aspectFill"
/>
<view v-else class="nav-avatar avatar-placeholder">
{{ (user?.name || '?').charAt(0) }}
</view>
<view class="online-dot" v-if="!loading"></view>
</view>
<text class="nav-title">消息</text>
</view>
<!-- 右侧: 加号按钮 -->
<view class="nav-right">
<view class="icon-btn" @click="showPlusMenu = true">
<wd-icon name="add" size="52rpx" color="var(--text-primary)" />
<view class="icon-btn-circle" @click="showPlusMenu = true">
<svg class="plus-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</view>
</view>
</view>
</view>
<!-- 2. 搜索栏 -->
<!-- 2. 搜索栏 (扁平化设计) -->
<view class="search-wrapper">
<view class="search-inner" @click="goSearch">
<wd-icon name="search" size="32rpx" color="var(--text-tertiary)" />
<text class="search-placeholder">搜索</text>
<view class="search-bar" @click="goSearch">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="text"
class="search-input"
placeholder="搜索"
disabled
/>
</view>
</view>
<!-- 3. 会话列表 -->
<scroll-view
class="conversation-list"
class="conversation-list custom-scrollbar"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@@ -51,52 +64,62 @@
<text>连接中...</text>
</view>
<wd-status-tip
v-else-if="conversations.length === 0"
image="message"
tip="暂无消息,开始聊天吧"
/>
<view v-else-if="conversations.length === 0" class="empty-state">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<line x1="9" y1="10" x2="15" y2="10"/>
</svg>
<text class="empty-text">暂无消息</text>
</view>
<template v-else>
<wd-swipe-action
v-for="item in conversations"
v-for="(item, index) in conversations"
:key="item.id"
:disabled="false"
>
<view
class="conversation-item"
:class="{ 'is-top': item.is_top }"
:style="{ animationDelay: `${index * 30}ms` }"
@click="goChat(item)"
@longpress="handleLongPress(item)"
>
<!-- 头像 -->
<view class="avatar-wrap">
<app-avatar
:src="item.avatar"
:name="item.name"
:size="104"
radius="20rpx"
:badge="item.is_muted ? 0 : item.unread_count"
:dot="item.is_muted && item.unread_count > 0"
<image
v-if="item.avatar"
class="conv-avatar"
:src="resolveImageUrl(item.avatar)"
mode="aspectFill"
/>
<view v-if="item.is_special_care" class="special-badge-wrap">
<wd-icon name="heart-fill" size="20rpx" color="#fff" />
<view v-else class="conv-avatar avatar-placeholder">
{{ (item.name || '?').charAt(0) }}
</view>
<!-- 在线状态点 (单聊时显示) -->
<view v-if="item.is_online && !item.is_group" class="online-indicator"></view>
<!-- 未读红点/数字 -->
<view v-if="!item.is_muted && item.unread_count > 0" class="unread-badge">
{{ item.unread_count > 99 ? '99+' : item.unread_count }}
</view>
<view v-else-if="item.is_muted && item.unread_count > 0" class="unread-dot"></view>
</view>
<!-- 内容区 -->
<view class="content-wrap">
<view class="row-top">
<view class="title-box">
<wd-icon v-if="item.is_top" name="pin" size="24rpx" class="pin-icon" />
<text class="name text-ellipsis" :class="{ 'special-text': item.is_special_care }">
{{ item.name || '未知' }}
</text>
</view>
<text class="name text-ellipsis" :class="{ 'special-name': item.is_special_care }">
{{ item.name || '未知' }}
</text>
<text class="time">{{ formatMessageTime(item.last_time) }}</text>
</view>
<view class="row-bottom">
<text class="msg-preview text-ellipsis">{{ item.last_message || '暂无消息' }}</text>
<wd-icon v-if="item.is_muted" name="volume-mute" size="28rpx" class="mute-icon" />
<svg v-if="item.is_muted" class="mute-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 5L6 9H2v6h4l5 4V5z"/>
<line x1="23" y1="9" x2="17" y2="15"/>
<line x1="17" y1="9" x2="23" y2="15"/>
</svg>
</view>
</view>
</view>
@@ -104,10 +127,15 @@
<template #right>
<view class="swipe-actions">
<view class="action-btn top" @click.stop="toggleTop(item)">
<wd-icon name="pin" size="40rpx" />
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/>
</svg>
</view>
<view class="action-btn delete" @click.stop="deleteConversation(item)">
<wd-icon name="delete" size="40rpx" />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</view>
</view>
</template>
@@ -117,14 +145,13 @@
<view class="safe-area-spacer"></view>
</scroll-view>
<!-- 弹窗组件 -->
<wd-action-sheet
<!-- 加号菜单 -->
<plus-menu
v-model="showPlusMenu"
:actions="plusMenuActions"
@select="onPlusMenuSelect"
:z-index="9999"
/>
<!-- 长按操作菜单 -->
<wd-action-sheet
v-model="showConvActions"
:actions="convActionItems"
@@ -134,33 +161,13 @@
/>
<!-- 侧边抽屉 -->
<wd-popup
<app-drawer
v-model="showDrawer"
position="left"
custom-style="width: 75%; height: 100%; background: var(--bg-surface);"
:z-index="10000"
>
<view class="drawer-container">
<view class="drawer-header-bg"></view>
<view class="drawer-info">
<app-avatar :src="user?.avatar" :name="user?.name" :size="140" round custom-style="border: 4rpx solid var(--bg-surface);" />
<text class="drawer-username">{{ user?.name || '请登录' }}</text>
<text class="drawer-bio">{{ user?.desc || '编辑个签,展示你的独特个性' }}</text>
</view>
@logout="logout"
/>
<view class="drawer-menu-list">
<wd-cell-group border>
<wd-cell title="个人资料" icon="user" is-link size="large" @click="goProfile" />
<wd-cell title="设置" icon="setting" is-link size="large" @click="goSettings" />
<wd-cell title="深色模式" icon="picture" :value="themeText" is-link size="large" @click="goTheme" />
</wd-cell-group>
</view>
<view class="drawer-footer-btn">
<wd-button type="info" plain block @click="logout">退出登录</wd-button>
</view>
</view>
</wd-popup>
<!-- 全局群通话组件 -->
<global-call-provider />
<wd-toast />
<wd-message-box :z-index="11000" />
@@ -175,10 +182,13 @@ import { onShow } from '@dcloudio/uni-app'
import { useAuthStore, useConversationStore } from '@/stores'
import { useTheme } from '@/composables/useTheme'
import { formatMessageTime } from '@/utils/format'
import { resolveImageUrl } from '@/utils/image'
import { useToast, useMessage } from 'wot-design-uni'
import * as conversationApi from '@/api/modules/conversation'
import AppAvatar from '@/components/common/AppAvatar.vue'
import AppTabBar from '@/components/common/AppTabBar.vue'
import AppDrawer from '@/components/common/AppDrawer.vue'
import PlusMenu from '@/components/common/PlusMenu.vue'
import GlobalCallProvider from '@/components/call/GlobalCallProvider.vue'
import type { Conversation } from '@/types/conversation'
const authStore = useAuthStore()
@@ -187,7 +197,6 @@ const { isDark } = useTheme()
const toast = useToast()
const messageBox = useMessage()
const searchKeyword = ref('')
const showPlusMenu = ref(false)
const showDrawer = ref(false)
const refreshing = ref(false)
@@ -198,18 +207,6 @@ const user = computed(() => authStore.user)
const conversations = computed(() => conversationStore.conversations)
const loading = computed(() => conversationStore.loading)
const themeText = computed(() => {
const mode = uni.getStorageSync('nl_im_theme_mode') || 'system'
const texts: Record<string, string> = { system: '跟随系统', light: '浅色模式', dark: '深色模式' }
return texts[mode] || '跟随系统'
})
const plusMenuActions = [
{ name: '创建群聊', value: 'createGroup' },
{ name: '添加好友.html', value: 'addFriend' },
{ name: '扫一扫', value: 'scan' }
]
const convActionItems = computed(() => {
if (!selectedConv.value) return []
const conv = selectedConv.value
@@ -220,7 +217,7 @@ const convActionItems = computed(() => {
]
if (!isGroup) items.push({ name: conv.is_special_care ? '取消特别关心' : '特别关心', value: 'toggleSpecial' })
if (conv.unread_count && conv.unread_count > 0) items.push({ name: '标记已读', value: 'markRead' })
items.push({ name: '删除会话', value: 'delete', color: '#fa5151' })
items.push({ name: '删除会话', value: 'delete', color: '#ef4444' })
return items
})
@@ -247,11 +244,20 @@ function goChat(item: Conversation) {
uni.navigateTo({ url: `/pages/chat/index?roomId=${item.room_id || ''}&targetId=${item.target_id}&name=${encodeURIComponent(item.name || '')}&avatar=${encodeURIComponent(item.avatar || '')}` })
}
function handleLongPress(item: Conversation) { selectedConv.value = item; showConvActions.value = true }
async function onConvActionSelect(event: any) {
// Wot-UI设计 select 事件通常返回 { item, index },或者直接是 detail 对象
// 防御性获取 item
const action = event.item || event
async function toggleTop(item: Conversation) {
try {
await conversationApi.updateConversation({ target_id: item.target_id, is_top: !item.is_top })
item.is_top = !item.is_top
conversationStore.sortConversations()
toast.success(item.is_top ? '已置顶' : '已取消置顶')
} catch (e) {
toast.error('操作失败')
}
}
async function onConvActionSelect(event: any) {
const action = event.item || event
if (!selectedConv.value) return
const conv = selectedConv.value
showConvActions.value = false
@@ -284,156 +290,487 @@ async function deleteConversation(item: Conversation) {
} catch (e: any) { if(e !== 'cancel') toast.error('删除失败') }
}
// 修复:正确处理 select 事件参数
function onPlusMenuSelect(event: any) {
showPlusMenu.value = false
// WotUI 的 ActionSheet select 事件参数结构可能是 { item: {...} }
// 我们兼容直接传 item 和传 event.item 的情况
const item = event.item || event
console.log('Selected menu item:', item) // 调试日志
if (item && item.value === 'createGroup') {
function onPlusMenuSelect(key: string) {
if (key === 'createGroup') {
uni.navigateTo({ url: '/pages/group/create' })
} else if (item && item.value === 'addFriend') {
} else if (key === 'addFriend') {
uni.navigateTo({ url: '/pages/contact/add' })
} else if (item && item.value === 'scan') {
} else if (key === 'scan') {
uni.scanCode({ success: () => toast.show('扫码成功'), fail: () => toast.error('扫码失败') })
}
}
function openDrawer() { showDrawer.value = true }
function goProfile() { showDrawer.value = false; uni.navigateTo({ url: '/pages/profile/index' }) }
function goSettings() { showDrawer.value = false; uni.navigateTo({ url: '/pages/settings/index' }) }
function goTheme() { showDrawer.value = false; uni.navigateTo({ url: '/pages/settings/theme' }) }
async function logout() {
try { await messageBox.confirm({ title: '提示', msg: '确定退出?' }); showDrawer.value = false; authStore.logout() } catch {}
try {
await messageBox.confirm({ title: '提示', msg: '确定退出?' })
showDrawer.value = false
authStore.logout()
} catch {}
}
</script>
<style lang="scss" scoped>
// ==========================================
// 页面容器 - 浅色模式变量
// ==========================================
.page-container {
--bg-page: #f7f8fa;
--bg-page: #ffffff;
--bg-surface: #ffffff;
--bg-hover: #f5f5f5;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--text-tertiary: #a1a1a6;
--border-subtle: rgba(0,0,0,0.05);
--nav-bg: rgba(255, 255, 255, 0.85);
--color-brand: #4f46e5;
--bg-top: rgba(0, 0, 0, 0.02);
--text-primary: #1f2937;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--border-color: rgba(0, 0, 0, 0.05);
--nav-bg: rgba(255, 255, 255, 0.9);
--color-brand: #4F46E5;
--search-bg: #f1f1f1;
--search-focus: rgba(79, 70, 229, 0.1);
min-height: 100vh;
background: var(--bg-page);
color: var(--text-primary);
}
// ==========================================
// 深色模式 - Warm Stone 暖色调
// ==========================================
.page-container.dark {
--bg-page: #1c1c1e;
--bg-surface: #2c2c2e;
--bg-hover: #3a3a3c;
--text-primary: #f2f2f7;
--text-secondary: #aeaeb2;
--text-tertiary: #636366;
--border-subtle: rgba(255,255,255,0.1);
--nav-bg: rgba(28, 28, 30, 0.85);
--bg-page: #1c1917;
--bg-surface: #1c1917;
--bg-hover: #44403c;
--bg-top: rgba(255, 255, 255, 0.03);
--text-primary: #f5f5f4;
--text-secondary: #e7e5e4;
--text-tertiary: #78716c;
--border-color: #44403c;
--nav-bg: rgba(28, 25, 23, 0.9);
--color-brand: #f97316;
--search-bg: #292524;
--search-focus: rgba(249, 115, 22, 0.1);
}
// ==========================================
// 动画
// ==========================================
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.custom-scrollbar {
&::-webkit-scrollbar { display: none; }
-ms-overflow-style: none;
scrollbar-width: none;
}
// ==========================================
// 导航栏 (与设计稿一致)
// ==========================================
.custom-navbar {
position: sticky; top: 0; z-index: 100;
padding-top: var(--status-bar-height);
position: sticky;
top: 0;
z-index: 100;
padding-top: var(--status-bar-height, 0);
.navbar-bg {
position: absolute; inset: 0;
background: var(--nav-bg); backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-subtle);
}
position: absolute;
inset: 0;
background: var(--nav-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1rpx solid transparent;
.navbar-content {
position: relative; height: 44px;
display: flex; align-items: center; justify-content: space-between;
padding: 0 32rpx;
}
.nav-left-group {
display: flex; align-items: center;
&:active { opacity: 0.7; }
.nav-info {
display: flex; flex-direction: column; justify-content: center;
margin-left: 16rpx;
.nav-title { font-size: 34rpx; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
.online-tag {
display: flex; align-items: center; margin-top: 4rpx; font-size: 22rpx; color: #34c759;
.dot { width: 10rpx; height: 10rpx; background: #34c759; border-radius: 50%; margin-right: 6rpx; }
}
.dark & {
border-bottom-color: rgba(68, 64, 60, 0.5);
}
}
.icon-btn {
width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%;
&:active { background: var(--bg-hover); }
.navbar-content {
position: relative;
height: 88rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40rpx 16rpx;
}
}
// 左侧: 头像 + 标题
.nav-left {
display: flex;
align-items: center;
gap: 24rpx;
cursor: pointer;
transition: opacity 0.15s;
&:active {
opacity: 0.7;
}
}
.avatar-container {
position: relative;
width: 72rpx;
height: 72rpx;
}
.nav-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 16rpx;
object-fit: cover;
background: var(--search-bg);
box-shadow: 0 0 0 2rpx rgba(0, 0, 0, 0.05);
.dark & {
box-shadow: 0 0 0 2rpx rgba(255, 255, 255, 0.1);
}
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--color-brand);
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
}
.online-dot {
position: absolute;
bottom: -4rpx;
right: -4rpx;
width: 24rpx;
height: 24rpx;
background: #10b981;
border: 4rpx solid var(--bg-surface);
border-radius: 50%;
}
.nav-title {
font-size: 42rpx;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.5rpx;
}
// 右侧: 加号按钮
.nav-right {
display: flex;
align-items: center;
}
.icon-btn-circle {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
background: var(--search-bg);
border-radius: 50%;
transition: all 0.15s;
&:active {
background: var(--bg-hover);
transform: scale(0.95);
}
.plus-icon {
width: 40rpx;
height: 40rpx;
color: var(--text-secondary);
}
}
// ==========================================
// 搜索栏 (扁平化设计)
// ==========================================
.search-wrapper {
background: var(--bg-page); padding: 16rpx 32rpx;
position: sticky; top: calc(44px + var(--status-bar-height)); z-index: 99;
}
.search-inner {
height: 72rpx; background: var(--bg-surface); border-radius: 16rpx;
display: flex; align-items: center; justify-content: center; gap: 12rpx;
.search-placeholder { font-size: 28rpx; color: var(--text-tertiary); }
padding: 0 40rpx 16rpx;
background: var(--bg-page);
}
.conversation-list { height: calc(100vh - 44px - var(--status-bar-height) - 104rpx); }
.conversation-item {
display: flex; padding: 24rpx 32rpx; background: var(--bg-surface);
transition: background-color 0.2s;
&:active { background: var(--bg-hover); }
&.is-top { background: var(--bg-page); }
.content-wrap { border-bottom: 1rpx solid var(--border-subtle); }
&:last-child .content-wrap { border-bottom: none; }
}
.search-bar {
position: relative;
height: 80rpx;
background: var(--search-bg);
border-radius: 24rpx;
display: flex;
align-items: center;
padding: 0 24rpx;
transition: all 0.2s;
.avatar-wrap {
position: relative; margin-right: 24rpx; flex-shrink: 0;
.special-badge-wrap {
position: absolute; bottom: -6rpx; right: -6rpx;
background: #ec4899; border: 4rpx solid var(--bg-surface); border-radius: 50%;
width: 36rpx; height: 36rpx; display: flex; align-items: center; justify-content: center;
&:active {
background: var(--bg-hover);
}
}
.content-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center; padding-right: 4rpx; }
.row-top, .row-bottom { display: flex; align-items: center; justify-content: space-between; }
.row-top { margin-bottom: 8rpx; }
.title-box {
display: flex; align-items: center; flex: 1;
.pin-icon { color: var(--color-brand); margin-right: 8rpx; transform: rotate(45deg); }
.name { font-size: 32rpx; font-weight: 600; color: var(--text-primary); &.special-text { color: #ec4899; } }
.search-icon {
width: 32rpx;
height: 32rpx;
color: var(--text-tertiary);
flex-shrink: 0;
}
.search-input {
flex: 1;
height: 100%;
background: transparent;
border: none;
outline: none;
font-size: 30rpx;
color: var(--text-primary);
margin-left: 16rpx;
&::placeholder {
color: var(--text-tertiary);
}
}
// ==========================================
// 会话列表
// ==========================================
.conversation-list {
height: calc(100vh - 88rpx - var(--status-bar-height, 0) - 96rpx - 168rpx);
}
// 空状态
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
color: var(--text-tertiary);
opacity: 0.6;
.empty-icon {
width: 96rpx;
height: 96rpx;
margin-bottom: 24rpx;
}
.empty-text {
font-size: 28rpx;
}
}
// 加载状态
.loading-state {
padding-top: 200rpx;
display: flex;
flex-direction: column;
align-items: center;
color: var(--text-tertiary);
gap: 20rpx;
font-size: 26rpx;
}
// ==========================================
// 会话条目 (与设计稿完全一致)
// ==========================================
.conversation-item {
position: relative;
display: flex;
align-items: center;
padding: 28rpx 40rpx;
gap: 24rpx;
background: var(--bg-surface);
transition: background-color 0.15s;
animation: fadeInUp 0.4s ease-out forwards;
opacity: 0;
&:active {
background: var(--bg-hover);
}
&.is-top {
background: var(--bg-top);
}
}
// 头像区域
.avatar-wrap {
position: relative;
flex-shrink: 0;
}
.conv-avatar {
width: 100rpx;
height: 100rpx;
border-radius: 20rpx;
object-fit: cover;
background: var(--search-bg);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
// 未读数字徽章
.unread-badge {
position: absolute;
top: -12rpx;
right: -12rpx;
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
background: #ef4444;
color: #ffffff;
font-size: 20rpx;
font-weight: 700;
line-height: 32rpx;
text-align: center;
border-radius: 32rpx;
white-space: nowrap;
box-shadow: 0 0 0 4rpx var(--bg-surface);
}
// 未读红点 (免打扰)
.unread-dot {
position: absolute;
top: -8rpx;
right: -8rpx;
width: 20rpx;
height: 20rpx;
background: #ef4444;
border-radius: 50%;
box-shadow: 0 0 0 4rpx var(--bg-surface);
}
// 在线状态指示器
.online-indicator {
position: absolute;
bottom: 4rpx;
right: 4rpx;
width: 24rpx;
height: 24rpx;
background: #10b981;
border-radius: 50%;
box-shadow: 0 0 0 4rpx var(--bg-surface);
}
// 内容区域
.content-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
border-bottom: 1rpx solid var(--border-color);
padding-bottom: 28rpx;
margin-bottom: -28rpx;
margin-left: 8rpx;
.conversation-item:last-child & {
border-bottom: none;
}
}
.row-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
}
.row-bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
.name {
font-size: 32rpx;
font-weight: 500;
color: var(--text-primary);
flex: 1;
min-width: 0;
&.special-name {
color: #ec4899;
.dark & {
color: #f472b6;
}
}
}
.time {
font-size: 22rpx;
color: var(--text-tertiary);
margin-left: 16rpx;
flex-shrink: 0;
font-feature-settings: "tnum";
letter-spacing: -0.5rpx;
}
.msg-preview {
font-size: 26rpx;
color: var(--text-secondary);
flex: 1;
min-width: 0;
padding-right: 16rpx;
}
.mute-icon {
width: 28rpx;
height: 28rpx;
color: var(--text-tertiary);
flex-shrink: 0;
opacity: 0.6;
}
// ==========================================
// 滑动操作
// ==========================================
.swipe-actions {
display: flex;
height: 100%;
}
.time { font-size: 24rpx; color: var(--text-tertiary); margin-left: 16rpx; font-feature-settings: "tnum"; }
.msg-preview { font-size: 28rpx; color: var(--text-secondary); flex: 1; }
.swipe-actions { display: flex; height: 100%; }
.action-btn {
width: 140rpx; height: 100%; display: flex; align-items: center; justify-content: center; color: #fff;
&.top { background: #4f46e5; } &.delete { background: #ef4444; }
width: 140rpx;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
svg {
width: 44rpx;
height: 44rpx;
}
&.top {
background: #4F46E5;
.dark & {
background: #f97316;
}
}
&.delete {
background: #ef4444;
}
}
.drawer-container { height: 100%; display: flex; flex-direction: column; position: relative; overflow: hidden; background: var(--bg-surface); }
.drawer-header-bg {
position: absolute; top: 0; left: 0; width: 100%; height: 300rpx;
background: linear-gradient(135deg, var(--color-brand), rgba(79, 70, 229, 0.2)); opacity: 0.1; z-index: 0;
// 底部安全区
.safe-area-spacer {
height: 180rpx;
}
.drawer-info {
position: relative; z-index: 1; padding: 100rpx 40rpx 60rpx; display: flex; flex-direction: column;
.drawer-username { font-size: 40rpx; font-weight: 700; margin-top: 24rpx; color: var(--text-primary); }
.drawer-bio { font-size: 26rpx; color: var(--text-secondary); margin-top: 12rpx; }
// 文本溢出省略
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawer-menu-list { flex: 1; background: var(--bg-surface); }
.drawer-footer-btn { padding: 40rpx; padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); }
.safe-area-spacer { height: 150rpx; }
.loading-state { padding-top: 200rpx; display: flex; flex-direction: column; align-items: center; color: var(--text-tertiary); gap: 20rpx; font-size: 26rpx; }
</style>

View File

@@ -14,7 +14,7 @@
<image
v-if="moment.user?.avatar"
class="avatar"
:src="moment.user.avatar"
:src="resolveImageUrl(moment.user.avatar)"
mode="aspectFill"
/>
<view v-else class="avatar avatar-placeholder">
@@ -82,7 +82,7 @@
<image
v-if="comment.user?.avatar"
class="avatar"
:src="comment.user.avatar"
:src="resolveImageUrl(comment.user.avatar)"
mode="aspectFill"
/>
<view v-else class="avatar avatar-placeholder">
@@ -124,6 +124,7 @@
import { ref, onMounted } from 'vue'
import { useMomentStore } from '@/stores'
import { formatTime } from '@/utils/format'
import { resolveImageUrl } from '@/utils/image'
import { parseMediaUrls } from '@/types/moment'
import { useTheme } from '@/composables/useTheme'
import AppAvatar from '@/components/common/AppAvatar.vue'

View File

@@ -2,18 +2,18 @@
<wd-config-provider :theme="isDark ? 'dark' : 'light'">
<view class="moments-page" :class="{ dark: isDark }">
<!-- 1. 沉浸式渐变导航栏 -->
<!-- 使用动态背景色实现滚动渐变 -->
<view class="nav-header" :style="{ background: `rgba(var(--bg-nav-rgb), ${navOpacity})`, borderBottom: navOpacity > 0.8 ? '1rpx solid var(--border-color)' : 'none' }">
<view class="nav-content">
<text class="nav-title" :style="{ opacity: navOpacity }">朋友圈</text>
<text class="nav-title" :style="{ opacity: navOpacity }">发现</text>
<!-- 保留原有的消息通知逻辑:铃铛图标 + 红点 -->
<view class="nav-right">
<wd-badge :value="unreadCount" :max="99" v-if="unreadCount > 0">
<!-- 根据背景透明度自动切换图标颜色 -->
<wd-icon name="bell" size="44rpx" :color="iconColor" @click="goNotifications" />
</wd-badge>
<wd-icon v-else name="bell" size="44rpx" :color="iconColor" @click="goNotifications" />
<view class="nav-icon-btn" @click="goNotifications">
<svg class="icon" :style="{ color: iconColor }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
<view v-if="unreadCount > 0" class="badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</view>
</view>
</view>
</view>
</view>
@@ -21,7 +21,7 @@
<!-- 2. 内容滚动区 -->
<scroll-view
scroll-y
class="scroll-container"
class="scroll-container custom-scrollbar"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@scroll="onPageScroll"
@@ -30,12 +30,12 @@
>
<!-- 封面图区域 -->
<view class="cover-area">
<image src="/static/images/moment-cover.jpg" mode="aspectFill" class="cover-img" />
<image src="https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?w=800&q=80" mode="aspectFill" class="cover-img" />
<view class="cover-gradient"></view>
<view class="user-entry">
<text class="username">{{ user?.name || '我' }}</text>
<view class="avatar-box">
<app-avatar :src="user?.avatar" :name="user?.name" :size="140" radius="20rpx" />
<app-avatar :src="user?.avatar" :name="user?.name" :size="160" radius="24rpx" />
</view>
</view>
</view>
@@ -43,18 +43,24 @@
<!-- 列表内容 -->
<view class="feed-list">
<view v-if="loading && moments.length === 0" class="loading-state">
<wd-loading />
<wd-loading :color="isDark ? '#f97316' : '#4F46E5'" />
<text>加载中...</text>
</view>
<wd-status-tip v-else-if="moments.length === 0" tip="暂无动态" />
<view v-else-if="moments.length === 0" class="empty-state">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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>
<text class="empty-text">暂无动态,快去发布吧</text>
</view>
<template v-else>
<!-- 循环变量名恢复为 moment,匹配原有逻辑 -->
<view v-for="moment in moments" :key="moment.id" class="feed-item">
<!-- 头像 (移除原代码中不存在的点击事件) -->
<view v-for="(moment, index) in moments" :key="moment.id" class="feed-item animate-fade-in-up" :style="{ animationDelay: `${index * 50}ms` }">
<view class="feed-avatar">
<app-avatar :src="moment.user?.avatar" :name="moment.user?.name" :size="88" radius="12rpx" />
<app-avatar :src="moment.user?.avatar" :name="moment.user?.name" :size="84" radius="24rpx" />
</view>
<view class="feed-content">
@@ -65,8 +71,8 @@
<!-- 图片九宫格 -->
<view v-if="moment.media_type === 1" class="media-grid" :class="`grid-${getImageCount(moment)}`">
<view
v-for="(url, index) in parseMediaUrls(moment.media_urls)"
:key="index"
v-for="(url, imgIndex) in parseMediaUrls(moment.media_urls)"
:key="imgIndex"
class="grid-img-wrap"
:style="{ width: getImageSize(moment), height: getImageSize(moment) }"
>
@@ -75,7 +81,7 @@
width="100%"
height="100%"
mode="aspectFill"
radius="8rpx"
radius="12rpx"
enable-preview
:preview-src-list="parseMediaUrls(moment.media_urls).map(resolveImageUrl)"
/>
@@ -97,25 +103,27 @@
<view class="feed-footer">
<text class="time">{{ formatTime(moment.created_at) }}</text>
<view class="action-btn" @click="showActionMenu(moment)">
<wd-icon name="more" size="32rpx" color="var(--color-primary)" />
<svg viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/>
</svg>
</view>
</view>
<!-- 点赞评论区 -->
<!-- 使用原有的判断逻辑 -->
<view v-if="(moment.likes && moment.likes.length > 0) || (moment.comments && moment.comments.length > 0)" class="interaction-area">
<view class="triangle"></view>
<!-- 点赞列表 -->
<view v-if="moment.likes && moment.likes.length > 0" class="likes-list">
<wd-icon name="heart-fill" size="26rpx" color="var(--color-primary)" />
<svg class="heart-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
<text class="names">{{ moment.likes.map(l => l.user?.name || '').join('、') }}</text>
</view>
<!-- 分割线 -->
<view v-if="moment.likes?.length && moment.comments?.length" class="divider"></view>
<!-- 评论列表 (保留原有点击跳转逻辑,不新增回复方法) -->
<!-- 评论列表 -->
<view v-if="moment.comments?.length" class="comments-list">
<view
v-for="comment in moment.comments"
@@ -132,7 +140,6 @@
</view>
</view>
<!-- 加载更多 -->
<wd-loadmore :state="loadState" @loadmore="loadMore" />
</template>
</view>
@@ -140,7 +147,10 @@
<!-- 悬浮发布按钮 -->
<view class="fab-btn" @click="goPublish">
<wd-icon name="camera" size="52rpx" color="#fff" />
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
</view>
<!-- 操作菜单 -->
@@ -148,12 +158,15 @@
v-model="showActions"
:actions="actionItems"
@select="onActionSelect"
cancel-text="取消"
/>
<!-- 全局群通话组件 -->
<global-call-provider />
<wd-toast />
<wd-message-box />
<!-- TabBar (保留) -->
<app-tab-bar current="moments" />
</view>
</wd-config-provider>
@@ -169,6 +182,8 @@ 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 AppAvatar from '@/components/common/AppAvatar.vue'
import GlobalCallProvider from '@/components/call/GlobalCallProvider.vue'
import type { Moment } from '@/types/moment'
const authStore = useAuthStore()
@@ -177,27 +192,26 @@ const { isDark } = useTheme()
const toast = useToast()
const messageBox = useMessage()
// --- UI设计 状态 (新增) ---
// UI 状态
const navOpacity = ref(0)
const iconColor = computed(() => {
// 当导航栏变白时,图标变黑;否则为白色
if (navOpacity.value > 0.6) return 'var(--text-main)'
return '#ffffff'
})
// --- 原有状态保留 ---
// 原有状态保留
const refreshing = ref(false)
const showActions = ref(false)
const currentMoment = ref<Moment | null>(null)
const loadState = ref<'loading' | 'finished' | 'error'>('loading')
// --- 原有计算属性保留 ---
// 原有计算属性保留
const user = computed(() => authStore.user)
const moments = computed(() => momentStore.moments)
const loading = computed(() => momentStore.loading)
const unreadCount = computed(() => momentStore.unreadCount)
// --- 原有菜单逻辑保留 ---
// 原有菜单逻辑保留
const actionItems = computed(() => {
const items = []
if (currentMoment.value) {
@@ -207,29 +221,23 @@ const actionItems = computed(() => {
})
items.push({ name: '评论', value: 'comment' })
if (currentMoment.value.user_id === user.value?.id) {
items.push({ name: '删除', value: 'delete', color: '#fa5151' })
items.push({ name: '删除', value: 'delete', color: '#ef4444' })
}
}
return items
})
// --- 生命周期 ---
onMounted(() => {
loadData()
})
// 生命周期
onMounted(() => { loadData() })
onShow(() => { loadUnreadCount() })
onShow(() => {
loadUnreadCount()
})
// --- 新增滚动监听 (UI逻辑) ---
// 滚动监听
function onPageScroll(e: any) {
const scrollTop = e.detail.scrollTop
// 滚动 200px 后导航栏完全不透明
navOpacity.value = Math.min(scrollTop / 200, 1)
}
// --- 原有方法保留 ---
// 原有方法保留
async function loadData() {
try {
await momentStore.fetchMoments()
@@ -240,11 +248,7 @@ async function loadData() {
}
async function loadUnreadCount() {
try {
await momentStore.fetchUnreadCount()
} catch {
// ignore
}
try { await momentStore.fetchUnreadCount() } catch {}
}
async function onRefresh() {
@@ -261,43 +265,23 @@ async function loadMore() {
loadState.value = momentStore.pagination.hasMore ? 'loading' : 'finished'
}
function goNotifications() {
uni.navigateTo({ url: '/pages/moment/notify' })
}
function goPublish() {
uni.navigateTo({ url: '/pages/moment/publish' })
}
function showActionMenu(moment: Moment) {
currentMoment.value = moment
showActions.value = true
}
function goNotifications() { uni.navigateTo({ url: '/pages/moment/notify' }) }
function goPublish() { uni.navigateTo({ url: '/pages/moment/publish' }) }
function showActionMenu(moment: Moment) { currentMoment.value = moment; showActions.value = true }
async function onActionSelect(item: { value: string }) {
if (!currentMoment.value) return
switch (item.value) {
case 'like':
await toggleLike()
break
case 'comment':
goComment()
break
case 'delete':
await deleteMoment()
break
case 'like': await toggleLike(); break
case 'comment': goComment(); break
case 'delete': await deleteMoment(); break
}
showActions.value = false
}
async function toggleLike() {
if (!currentMoment.value) return
try {
await momentStore.toggleLike(currentMoment.value.id)
} catch {
toast.error('操作失败')
}
try { await momentStore.toggleLike(currentMoment.value.id) } catch { toast.error('操作失败') }
}
function goComment() {
@@ -308,18 +292,12 @@ function goComment() {
async function deleteMoment() {
if (!currentMoment.value) return
try {
await messageBox.confirm({
title: '提示',
msg: '确定要删除这条动态吗?'
})
await messageBox.confirm({ title: '提示', msg: '确定要删除这条动态吗?' })
await momentStore.deleteMoment(currentMoment.value.id)
toast.success('已删除')
} catch {
// 取消或失败
}
} catch {}
}
// --- 原有辅助函数保留 ---
function getImageCount(moment: Moment): number {
const urls = parseMediaUrls(moment.media_urls)
return Math.min(urls.length, 9)
@@ -334,47 +312,70 @@ function getImageSize(moment: Moment): string {
</script>
<style lang="scss" scoped>
// 页面容器 - 浅色模式
.moments-page {
/* 定义 CSS 变量以便暗黑模式切换 */
--bg-page: #ffffff;
--bg-nav-rgb: 255, 255, 255;
--bg-detail: #f7f7f7;
--text-main: #1d1d1f;
--text-blue: #576b95;
--color-primary: #576b95;
--border-color: rgba(0,0,0,0.05);
--color-brand: #4F46E5;
--border-color: rgba(0, 0, 0, 0.05);
height: 100vh;
background: var(--bg-page);
position: relative;
}
// 深色模式 - Warm Stone
.moments-page.dark {
/* 调整深色模式颜色:
使用 #1c1c1e (iOS 深色背景) 替代纯黑 #000000
提升质感,减少 OLED 拖影
*/
--bg-page: #1c1c1e;
--bg-nav-rgb: 28, 28, 30; /* 对应 #1c1c1e 的 RGB 值 */
--bg-detail: #2c2c2e; /* 稍微亮一点的二级背景,用于评论区 */
--text-main: #f2f2f7; /* 柔和的白色文字 */
--bg-page: #1c1917;
--bg-nav-rgb: 28, 25, 23;
--bg-detail: #292524;
--text-main: #f5f5f4;
--text-blue: #7e95c5;
--border-color: rgba(255,255,255,0.1);
--color-brand: #f97316;
--border-color: rgba(255, 255, 255, 0.1);
}
/* 1. 沉浸式 Header */
// 动画
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.4s ease-out forwards;
opacity: 0;
}
.custom-scrollbar {
&::-webkit-scrollbar { display: none; }
-ms-overflow-style: none;
scrollbar-width: none;
}
// 沉浸式 Header
.nav-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: calc(44px + var(--status-bar-height));
height: calc(88rpx + var(--status-bar-height));
z-index: 100;
display: flex;
align-items: flex-end; /* 内容底部对齐 */
padding-bottom: 20rpx;
align-items: flex-end;
padding-bottom: 16rpx;
justify-content: center;
transition: background 0.3s;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.nav-content {
@@ -383,82 +384,143 @@ function getImageSize(moment: Moment): string {
display: flex;
justify-content: center;
align-items: center;
padding: 0 30rpx;
padding: 0 32rpx;
}
.nav-title {
font-weight: 600;
font-weight: 700;
font-size: 34rpx;
color: var(--text-main);
}
.nav-right {
position: absolute;
right: 30rpx;
right: 32rpx;
display: flex;
align-items: center;
}
.nav-icon-btn {
position: relative;
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.15s;
&:active {
background: rgba(0, 0, 0, 0.05);
}
.icon {
width: 44rpx;
height: 44rpx;
}
.badge {
position: absolute;
top: 4rpx;
right: 4rpx;
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
background: #ef4444;
color: #fff;
font-size: 20rpx;
font-weight: 600;
line-height: 32rpx;
text-align: center;
border-radius: 32rpx;
box-sizing: border-box;
}
}
.scroll-container {
height: 100%;
}
/* 2. 封面区域 */
// 封面区域
.cover-area {
position: relative;
height: 600rpx;
margin-bottom: 80rpx; /* 留出空间给头像 */
height: 680rpx;
margin-bottom: 100rpx;
.cover-img { width: 100%; height: 100%; }
.cover-img {
width: 100%;
height: 100%;
}
/* 底部渐变遮罩,保证文字清晰 */
.cover-gradient {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 160rpx;
height: 200rpx;
background: linear-gradient(to top, var(--bg-page), transparent);
}
.user-entry {
position: absolute;
bottom: -40rpx;
right: 30rpx;
bottom: -60rpx;
right: 32rpx;
display: flex;
align-items: flex-start;
gap: 24rpx;
align-items: flex-end;
gap: 20rpx;
.username {
font-weight: 600;
font-weight: 700;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.5);
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.5);
font-size: 36rpx;
margin-top: 20rpx;
margin-bottom: 20rpx;
}
.avatar-box {
border: 6rpx solid var(--bg-page);
border-radius: 24rpx;
border: 8rpx solid var(--bg-page);
border-radius: 28rpx;
background: var(--bg-page);
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
}
}
}
/* 3. 动态列表 */
// 动态列表
.feed-list {
padding: 0 30rpx;
padding: 0 32rpx;
}
.loading-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx;
color: var(--text-blue);
gap: 24rpx;
}
.empty-icon {
width: 100rpx;
height: 100rpx;
opacity: 0.4;
}
.empty-text {
font-size: 28rpx;
}
.feed-item {
display: flex;
gap: 20rpx;
margin-bottom: 60rpx;
margin-bottom: 48rpx;
padding-bottom: 32rpx;
border-bottom: 1rpx solid var(--border-color);
padding-bottom: 30rpx;
&:last-child { border-bottom: none; }
&:last-child {
border-bottom: none;
}
}
.feed-avatar {
@@ -467,12 +529,12 @@ function getImageSize(moment: Moment): string {
.feed-content {
flex: 1;
min-width: 0; /* 防止文本溢出 */
min-width: 0;
.nickname {
color: var(--text-blue);
font-weight: 600;
font-size: 32rpx;
font-size: 30rpx;
margin-bottom: 12rpx;
display: block;
}
@@ -487,59 +549,78 @@ function getImageSize(moment: Moment): string {
}
}
// 图片九宫格
.media-grid {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
gap: 12rpx;
margin-bottom: 16rpx;
.grid-img-wrap {
overflow: hidden;
border-radius: 8rpx;
border-radius: 12rpx;
}
}
.video-wrap {
margin-bottom: 16rpx;
.video-player {
width: 400rpx;
height: 300rpx;
border-radius: 8rpx;
border-radius: 12rpx;
}
}
// 底部操作栏
.feed-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10rpx;
margin-top: 12rpx;
.time { font-size: 24rpx; color: #999; }
.time {
font-size: 24rpx;
color: var(--text-blue);
opacity: 0.6;
}
.action-btn {
width: 60rpx;
height: 44rpx;
background: var(--bg-detail);
padding: 6rpx 16rpx;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
color: var(--text-blue);
transition: all 0.15s;
svg {
width: 32rpx;
height: 32rpx;
}
&:active {
opacity: 0.6;
}
}
}
/* 4. 交互区 (点赞评论) */
// 交互区 (点赞评论)
.interaction-area {
margin-top: 20rpx;
margin-top: 16rpx;
background: var(--bg-detail);
border-radius: 8rpx;
border-radius: 12rpx;
padding: 16rpx 20rpx;
position: relative;
.triangle {
position: absolute;
top: -12rpx;
left: 20rpx;
width: 0; height: 0;
left: 24rpx;
width: 0;
height: 0;
border-left: 12rpx solid transparent;
border-right: 12rpx solid transparent;
border-bottom: 12rpx solid var(--bg-detail);
@@ -553,9 +634,24 @@ function getImageSize(moment: Moment): string {
color: var(--text-blue);
font-weight: 500;
line-height: 1.5;
.heart-icon {
width: 28rpx;
height: 28rpx;
flex-shrink: 0;
margin-top: 4rpx;
}
}
.divider { height: 1rpx; background: rgba(0,0,0,0.05); margin: 12rpx 0; }
.divider {
height: 1rpx;
background: rgba(0, 0, 0, 0.05);
margin: 12rpx 0;
.dark & {
background: rgba(255, 255, 255, 0.05);
}
}
.comment-row {
font-size: 28rpx;
@@ -563,37 +659,40 @@ function getImageSize(moment: Moment): string {
margin-bottom: 8rpx;
line-height: 1.5;
&:last-child { margin-bottom: 0; }
.user-link { color: var(--text-blue); font-weight: 500; }
&:last-child {
margin-bottom: 0;
}
.user-link {
color: var(--text-blue);
font-weight: 500;
}
}
}
/* 5. 悬浮按钮 */
// 悬浮发布按钮
.fab-btn {
position: fixed;
bottom: calc(150rpx + env(safe-area-inset-bottom));
bottom: calc(180rpx + env(safe-area-inset-bottom));
right: 40rpx;
width: 100rpx;
height: 100rpx;
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: #07c160;
box-shadow: 0 10rpx 30rpx rgba(7, 193, 96, 0.3);
background: #10b981;
box-shadow: 0 10rpx 30rpx rgba(16, 185, 129, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 99;
transition: transform 0.2s;
&:active { transform: scale(0.95); }
}
svg {
width: 52rpx;
height: 52rpx;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx;
color: #999;
gap: 20rpx;
&:active {
transform: scale(0.95);
}
}
</style>

View File

@@ -25,7 +25,7 @@
>
<wd-img
v-if="item.from_user?.avatar"
:src="item.from_user.avatar"
:src="resolveImageUrl(item.from_user.avatar)"
width="80rpx"
height="80rpx"
radius="8rpx"
@@ -46,7 +46,7 @@
<view v-if="item.moment" class="moment-thumb">
<wd-img
v-if="item.moment.media_urls"
:src="parseMediaUrls(item.moment.media_urls)[0]"
:src="resolveImageUrl(parseMediaUrls(item.moment.media_urls)[0])"
width="80rpx"
height="80rpx"
mode="aspectFill"
@@ -68,6 +68,7 @@
import { ref, computed, onMounted } from 'vue'
import { useMomentStore } from '@/stores'
import { formatTime, generateColor } from '@/utils/format'
import { resolveImageUrl } from '@/utils/image'
import { parseMediaUrls } from '@/types/moment'
import { useToast } from 'wot-design-uni'
import { useTheme } from '@/composables/useTheme'

View File

@@ -1,10 +1,21 @@
<template>
<wd-config-provider :theme="isDark ? 'dark' : 'light'">
<view class="edit-page" :class="{ dark: isDark }">
<app-nav-bar title="编辑资料" custom-style="background: transparent;" />
<!-- 导航栏 -->
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</view>
<text class="nav-title">{{ pageTitle }}</text>
<view class="nav-placeholder"></view>
</view>
<view class="content-body">
<view class="input-card">
<!-- 输入卡片 -->
<view class="input-card animate-fade-in-up">
<view class="input-label">{{ inputLabel }}</view>
<textarea
v-if="field === 'desc'"
v-model="value"
@@ -24,8 +35,11 @@
<text v-if="maxLength" class="counter">{{ value.length }}/{{ maxLength }}</text>
</view>
<view class="btn-wrap">
<wd-button type="primary" block size="large" custom-style="border-radius: 24rpx; background: #4f46e5;" @click="save">保存</wd-button>
<!-- 保存按钮 -->
<view class="btn-wrap animate-fade-in-up" style="animation-delay: 100ms;">
<view class="save-btn" @click="save">
保存
</view>
</view>
</view>
</view>
@@ -36,59 +50,217 @@
import { ref, onMounted } from 'vue'
import { useTheme } from '@/composables/useTheme'
// --- 逻辑不变 ---
const { isDark } = useTheme(); const field = ref(''); const value = ref(''); const placeholder = ref(''); const maxLength = ref(0);
const { isDark } = useTheme()
const field = ref('')
const value = ref('')
const placeholder = ref('')
const maxLength = ref(0)
const pageTitle = ref('编辑资料')
const inputLabel = ref('')
function goBack() { uni.navigateBack() }
onMounted(() => {
const pages = getCurrentPages(); const currentPage = pages[pages.length - 1]; const options = (currentPage as any).$page?.options || {};
field.value = options.field || ''; value.value = decodeURIComponent(options.value || '');
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = (currentPage as any).$page?.options || {}
field.value = options.field || ''
value.value = decodeURIComponent(options.value || '')
switch (field.value) {
case 'name': placeholder.value = '请输入昵称'; maxLength.value = 20; uni.setNavigationBarTitle({ title: '修改昵称' }); break;
case 'desc': placeholder.value = '请输入个性签名'; maxLength.value = 100; uni.setNavigationBarTitle({ title: '修改签名' }); break;
case 'region': placeholder.value = '请输入地区'; maxLength.value = 50; uni.setNavigationBarTitle({ title: '修改地区' }); break;
case 'name':
placeholder.value = '请输入昵称'
maxLength.value = 20
pageTitle.value = '修改昵称'
inputLabel.value = '昵称'
break
case 'desc':
placeholder.value = '请输入个性签名'
maxLength.value = 100
pageTitle.value = '修改签名'
inputLabel.value = '个性签名'
break
case 'region':
placeholder.value = '请输入地区'
maxLength.value = 50
pageTitle.value = '修改地区'
inputLabel.value = '地区'
break
}
})
function save() { if (!value.value.trim()) { uni.showToast({ title: '内容不能为空', icon: 'none' }); return } uni.showToast({ title: '保存成功', icon: 'success' }); setTimeout(() => { uni.navigateBack() }, 1500) }
function save() {
if (!value.value.trim()) {
uni.showToast({ title: '内容不能为空', icon: 'none' })
return
}
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => { uni.navigateBack() }, 1500)
}
</script>
<style lang="scss" scoped>
// 页面容器 - 浅色模式
.edit-page {
--bg-page: #f5f7fa;
--bg-page: #f7f8fa;
--bg-surface: #ffffff;
--text-primary: #1d1d1f;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--color-brand: #4F46E5;
min-height: 100vh;
background: var(--bg-page);
}
// 深色模式 - Warm Stone
.edit-page.dark {
--bg-page: #1c1c1e;
--bg-surface: #2c2c2e;
--text-primary: #f2f2f7;
--bg-page: #1c1917;
--bg-surface: #292524;
--text-primary: #f5f5f4;
--text-secondary: #e7e5e4;
--text-tertiary: #78716c;
--color-brand: #f97316;
}
.content-body { padding: 40rpx; }
// 动画
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.4s ease-out forwards;
opacity: 0;
}
// 导航栏
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(var(--status-bar-height) + 16rpx) 24rpx 16rpx;
background: var(--bg-page);
.nav-back {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.15s;
svg {
width: 40rpx;
height: 40rpx;
color: var(--text-primary);
}
&:active {
background: rgba(0, 0, 0, 0.05);
}
}
.nav-title {
font-size: 34rpx;
font-weight: 600;
color: var(--text-primary);
}
.nav-placeholder {
width: 72rpx;
}
}
// 内容
.content-body {
padding: 40rpx;
}
// 输入卡片
.input-card {
background: var(--bg-surface);
border-radius: 24rpx;
padding: 30rpx;
padding: 32rpx;
position: relative;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.02);
margin-bottom: 60rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.03);
margin-bottom: 48rpx;
.input-label {
font-size: 26rpx;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 16rpx;
}
}
.custom-input {
width: 100%; font-size: 32rpx; color: var(--text-primary);
&.textarea { height: 200rpx; }
width: 100%;
font-size: 32rpx;
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
padding: 16rpx 0;
border-bottom: 2rpx solid transparent;
transition: border-color 0.2s;
&:focus {
border-bottom-color: var(--color-brand);
}
&.textarea {
height: 200rpx;
line-height: 1.6;
resize: none;
}
}
:deep(.p-holder) { color: #999; }
:deep(.p-holder) {
color: var(--text-tertiary);
}
.counter {
position: absolute; bottom: 20rpx; right: 30rpx;
font-size: 24rpx; color: #999;
position: absolute;
bottom: 20rpx;
right: 32rpx;
font-size: 24rpx;
color: var(--text-tertiary);
}
// 保存按钮
.btn-wrap {
margin-top: 40rpx;
}
.save-btn {
width: 100%;
height: 100rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
color: #fff;
font-size: 32rpx;
font-weight: 600;
border-radius: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(79, 70, 229, 0.25);
transition: all 0.15s;
.dark & {
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
box-shadow: 0 8rpx 24rpx rgba(249, 115, 22, 0.25);
}
&:active {
transform: scale(0.98);
}
}
</style>

View File

@@ -5,23 +5,35 @@
<!-- 头部大卡片 -->
<view class="profile-header">
<view class="header-top">
<text class="page-title">我的</text>
<view class="back-btn" @click="goBack">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</view>
<text class="page-title">个人资料</text>
<view class="icon-btn" @click="uni.navigateTo({ url: '/pages/settings/index' })">
<wd-icon name="setting" size="44rpx" color="var(--text-primary)" />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</view>
</view>
<view class="user-hero" @click="goProfileEdit">
<app-avatar :src="userInfo.avatar" :name="userInfo.nickname" :size="160" radius="50%" custom-style="border: 6rpx solid var(--bg-surface);" />
<view class="user-hero animate-fade-in-up" @click="goProfileEdit">
<view class="avatar-wrap">
<app-avatar :src="userInfo.avatar" :name="userInfo.nickname" :size="160" round custom-style="border: 6rpx solid var(--bg-surface);" />
</view>
<view class="hero-info">
<text class="nickname">{{ userInfo.nickname || '未设置昵称' }}</text>
<text class="account">账号: {{ userInfo.account || '--' }}</text>
</view>
<wd-icon name="arrow-right" color="#999" />
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
<!-- 数据概览 -->
<view class="stats-row">
<view class="stats-row animate-fade-in-up" style="animation-delay: 100ms;">
<view class="stat-item">
<text class="num">12</text>
<text class="label">动态</text>
@@ -39,26 +51,48 @@
<!-- 菜单列表 -->
<view class="menu-container">
<view class="menu-group">
<view class="menu-group animate-fade-in-up" style="animation-delay: 150ms;">
<view class="menu-item" @click="uni.navigateTo({ url: '/pages/moment/my' })">
<view class="icon-box blue"><wd-icon name="moments" size="40rpx" color="#fff" /></view>
<view class="icon-box indigo">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" 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>
</view>
<text class="menu-text">我的动态</text>
<wd-icon name="arrow-right" color="var(--text-tertiary)" size="28rpx" />
<svg class="menu-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
<view class="menu-item" @click="uni.navigateTo({ url: '/pages/collection/index' })">
<view class="icon-box yellow"><wd-icon name="star" size="40rpx" color="#fff" /></view>
<view class="icon-box amber">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
</view>
<text class="menu-text">我的收藏</text>
<wd-icon name="arrow-right" color="var(--text-tertiary)" size="28rpx" />
<svg class="menu-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
<view class="menu-item" @click="uni.navigateTo({ url: '/pages/album/index' })">
<view class="icon-box green"><wd-icon name="picture" size="40rpx" color="#fff" /></view>
<view class="icon-box emerald">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</view>
<text class="menu-text">相册</text>
<wd-icon name="arrow-right" color="var(--text-tertiary)" size="28rpx" />
<svg class="menu-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
</view>
</view>
<app-tab-bar active="/pages/profile/index" />
</view>
</wd-config-provider>
</template>
@@ -67,73 +101,249 @@
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useAuthStore } from '@/stores'
import AppTabBar from '@/components/common/AppTabBar.vue'
import AppAvatar from '@/components/common/AppAvatar.vue'
const { isDark } = useTheme()
const authStore = useAuthStore()
const userInfo = computed(() => authStore.userInfo || {})
function goBack() { uni.navigateBack() }
function goProfileEdit() { uni.navigateTo({ url: '/pages/profile/edit' }) }
</script>
<style lang="scss" scoped>
// 页面容器 - 浅色模式
.page-container {
--bg-page: #f5f7fa;
--bg-page: #f7f8fa;
--bg-surface: #ffffff;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--border-color: rgba(0, 0, 0, 0.05);
--color-brand: #4F46E5;
min-height: 100vh;
background: var(--bg-page);
padding-bottom: 200rpx;
}
.page-container.dark {
--bg-page: #1c1c1e; /* Dark Grey */
--bg-surface: #2c2c2e;
--text-primary: #f2f2f7;
padding-bottom: 80rpx;
}
// 深色模式 - Warm Stone
.page-container.dark {
--bg-page: #1c1917;
--bg-surface: #292524;
--text-primary: #f5f5f4;
--text-secondary: #e7e5e4;
--text-tertiary: #78716c;
--border-color: rgba(255, 255, 255, 0.1);
--color-brand: #f97316;
}
// 动画
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.4s ease-out forwards;
opacity: 0;
}
// 头部
.profile-header {
background: var(--bg-surface);
padding: calc(var(--status-bar-height) + 20rpx) 40rpx 60rpx;
border-bottom-left-radius: 40rpx;
border-bottom-right-radius: 40rpx;
box-shadow: 0 10rpx 30rpx rgba(0,0,0,0.03);
padding: calc(var(--status-bar-height) + 24rpx) 40rpx 60rpx;
border-bottom-left-radius: 80rpx;
border-bottom-right-radius: 80rpx;
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.05);
}
.header-top {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 60rpx;
.page-title { font-size: 48rpx; font-weight: 800; color: var(--text-primary); }
.icon-btn { opacity: 0.7; }
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 48rpx;
.back-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.15s;
svg {
width: 40rpx;
height: 40rpx;
color: var(--text-primary);
}
&:active {
background: var(--border-color);
}
}
.page-title {
font-size: 36rpx;
font-weight: 700;
color: var(--text-primary);
}
.icon-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.15s;
svg {
width: 40rpx;
height: 40rpx;
color: var(--text-primary);
opacity: 0.7;
}
&:active {
background: var(--border-color);
}
}
}
.user-hero {
display: flex; align-items: center; gap: 30rpx;
display: flex;
align-items: center;
gap: 24rpx;
transition: all 0.15s;
&:active {
opacity: 0.8;
}
.avatar-wrap {
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
border-radius: 50%;
}
.hero-info {
flex: 1; display: flex; flex-direction: column;
.nickname { font-size: 40rpx; font-weight: 700; color: var(--text-primary); margin-bottom: 8rpx; }
.account { font-size: 26rpx; color: var(--text-secondary); }
flex: 1;
display: flex;
flex-direction: column;
.nickname {
font-size: 40rpx;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8rpx;
}
.account {
font-size: 26rpx;
color: var(--text-secondary);
}
}
.chevron {
width: 32rpx;
height: 32rpx;
color: var(--text-tertiary);
}
}
// 数据统计
.stats-row {
display: flex; justify-content: space-around; margin-top: 60rpx;
display: flex;
justify-content: space-around;
margin-top: 60rpx;
padding-top: 40rpx;
border-top: 1rpx solid var(--border-color);
.stat-item {
display: flex; flex-direction: column; align-items: center;
.num { font-size: 36rpx; font-weight: 700; color: var(--text-primary); }
.label { font-size: 24rpx; color: var(--text-secondary); margin-top: 4rpx; }
display: flex;
flex-direction: column;
align-items: center;
.num {
font-size: 40rpx;
font-weight: 700;
color: var(--text-primary);
}
.label {
font-size: 24rpx;
color: var(--text-secondary);
margin-top: 8rpx;
}
}
}
.menu-container { padding: 40rpx; }
.menu-group { background: var(--bg-surface); border-radius: 24rpx; padding: 10rpx 30rpx; }
// 菜单
.menu-container {
padding: 40rpx;
}
.menu-group {
background: var(--bg-surface);
border-radius: 24rpx;
padding: 8rpx 0;
overflow: hidden;
}
.menu-item {
display: flex; align-items: center; padding: 30rpx 0; border-bottom: 1rpx solid rgba(0,0,0,0.05);
&:last-child { border-bottom: none; }
.icon-box {
width: 70rpx; height: 70rpx; border-radius: 16rpx; display: flex; align-items: center; justify-content: center; margin-right: 24rpx;
&.blue { background: #4f46e5; } &.yellow { background: #f59e0b; } &.green { background: #10b981; }
display: flex;
align-items: center;
padding: 28rpx 32rpx;
transition: background 0.15s;
&:active {
background: var(--border-color);
}
.icon-box {
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
svg {
width: 40rpx;
height: 40rpx;
}
&.indigo {
background: #4F46E5;
}
&.amber {
background: #f59e0b;
}
&.emerald {
background: #10b981;
}
}
.menu-text {
flex: 1;
font-size: 30rpx;
font-weight: 500;
color: var(--text-primary);
}
.menu-arrow {
width: 28rpx;
height: 28rpx;
color: var(--text-tertiary);
}
.menu-text { flex: 1; font-size: 30rpx; font-weight: 500; color: var(--text-primary); }
}
</style>

View File

@@ -1,24 +1,39 @@
<template>
<view class="search-page" :class="{ dark: isDark }">
<!-- 搜索栏 (增加 safe-area-top) -->
<view class="search-header safe-area-top">
<wd-search
v-model="keyword"
placeholder="搜索"
show-action
focus
@search="doSearch"
@cancel="goBack"
@clear="clearKeyword"
custom-style="background: transparent;"
/>
<!-- 搜索栏 -->
<view class="search-header">
<view class="search-box">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
class="search-input"
type="text"
v-model="keyword"
placeholder="搜索"
focus
@confirm="doSearch"
/>
<view v-if="keyword" class="clear-btn" @click="clearKeyword">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z"/>
</svg>
</view>
</view>
<text class="cancel-btn" @click="goBack">取消</text>
</view>
<!-- 搜索历史 -->
<view v-if="!keyword && history.length > 0" class="history-section">
<view v-if="!keyword && history.length > 0" class="history-section animate-fade-in-up">
<view class="section-header">
<text class="title">搜索历史</text>
<text class="clear" @click="clearHistory">清空</text>
<view class="clear" @click="clearHistory">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</view>
</view>
<view class="history-tags">
<view
@@ -35,31 +50,40 @@
<!-- 搜索结果 -->
<view v-if="keyword" class="results-section">
<view v-if="loading" class="loading-state">
<wd-loading />
<wd-loading :color="isDark ? '#f97316' : '#4F46E5'" />
<text>搜索中...</text>
</view>
<wd-status-tip v-else-if="results.length === 0 && searched" tip="未找到相关结果" />
<view v-else-if="results.length === 0 && searched" class="empty-state">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<text class="empty-text">未找到相关结果</text>
</view>
<template v-else-if="results.length > 0">
<view class="result-list">
<view
v-for="item in results"
v-for="(item, index) in results"
:key="item.id"
class="result-item"
class="result-item animate-fade-in-up"
:style="{ animationDelay: `${index * 50}ms` }"
@click="goDetail(item)"
>
<app-avatar
:src="item.avatar"
:name="item.name"
:size="88"
radius="12rpx"
radius="20rpx"
/>
<view class="result-info">
<text class="name">{{ item.name }}</text>
<text class="name" v-html="highlightKeyword(item.name)"></text>
<text class="desc">{{ item.desc || '暂无签名' }}</text>
</view>
<wd-icon name="arrow-right" size="32rpx" color="var(--text-tertiary)" />
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
</view>
</template>
@@ -85,11 +109,7 @@ const history = ref<string[]>([])
// 加载搜索历史
const savedHistory = uni.getStorageSync('search_history')
if (savedHistory) {
try {
history.value = JSON.parse(savedHistory)
} catch {
history.value = []
}
try { history.value = JSON.parse(savedHistory) } catch { history.value = [] }
}
// 监听关键词变化
@@ -97,9 +117,7 @@ let searchTimer: number
watch(keyword, (val) => {
clearTimeout(searchTimer)
if (val.trim()) {
searchTimer = setTimeout(() => {
doSearch()
}, 500) as unknown as number
searchTimer = setTimeout(() => { doSearch() }, 500) as unknown as number
} else {
results.value = []
searched.value = false
@@ -108,12 +126,10 @@ watch(keyword, (val) => {
async function doSearch() {
if (!keyword.value.trim()) return
loading.value = true
searched.value = true
try {
results.value = await contactApi.searchUsers(keyword.value.trim())
// 保存搜索历史
saveHistory(keyword.value.trim())
} catch (error) {
console.error('搜索失败:', error)
@@ -124,13 +140,9 @@ async function doSearch() {
function saveHistory(word: string) {
const index = history.value.indexOf(word)
if (index > -1) {
history.value.splice(index, 1)
}
if (index > -1) { history.value.splice(index, 1) }
history.value.unshift(word)
if (history.value.length > 10) {
history.value = history.value.slice(0, 10)
}
if (history.value.length > 10) { history.value = history.value.slice(0, 10) }
uni.setStorageSync('search_history', JSON.stringify(history.value))
}
@@ -150,80 +162,259 @@ function clearHistory() {
uni.removeStorageSync('search_history')
}
function goBack() {
uni.navigateBack()
}
function goBack() { uni.navigateBack() }
function goDetail(item: User) {
uni.navigateTo({ url: `/pages/contact/detail?id=${item.id}` })
}
// 高亮匹配文字
function highlightKeyword(text: string): string {
if (!keyword.value || !text) return text
const regex = new RegExp(`(${keyword.value})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
}
</script>
<style lang="scss" scoped>
// 页面容器 - 浅色模式
.search-page {
--bg-page: #f7f8fa;
--bg-surface: #ffffff;
--text-primary: #1d1d1f;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--search-bg: #f1f1f1;
--color-brand: #4F46E5;
min-height: 100vh;
background: var(--bg-page);
display: flex;
flex-direction: column;
}
// 深色模式 - Warm Stone
.search-page.dark {
--bg-page: #1c1917;
--bg-surface: #292524;
--text-primary: #f5f5f4;
--text-secondary: #e7e5e4;
--text-tertiary: #78716c;
--search-bg: #292524;
--color-brand: #f97316;
}
// 动画
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.4s ease-out forwards;
opacity: 0;
}
// 搜索头部
.search-header {
background: var(--bg-content);
padding: 10rpx 20rpx;
display: flex;
align-items: center;
gap: 20rpx;
padding: calc(var(--status-bar-height) + 16rpx) 24rpx 16rpx;
background: var(--bg-page);
position: sticky;
top: 0;
z-index: 100;
}
.safe-area-top {
padding-top: var(--status-bar-height);
.search-box {
flex: 1;
display: flex;
align-items: center;
background: var(--search-bg);
padding: 0 24rpx;
border-radius: 24rpx;
height: 80rpx;
.search-icon {
width: 36rpx;
height: 36rpx;
color: var(--text-tertiary);
margin-right: 12rpx;
flex-shrink: 0;
}
.search-input {
flex: 1;
height: 100%;
font-size: 30rpx;
color: var(--text-primary);
background: transparent;
border: none;
&::placeholder {
color: var(--text-tertiary);
}
}
.clear-btn {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
svg {
width: 32rpx;
height: 32rpx;
}
&:active { opacity: 0.7; }
}
}
.cancel-btn {
font-size: 30rpx;
color: var(--color-brand);
padding: 10rpx;
&:active { opacity: 0.7; }
}
// 历史记录
.history-section {
padding: 30rpx;
padding: 32rpx;
.section-header {
display: flex; justify-content: space-between; margin-bottom: 24rpx;
.title { font-size: 28rpx; font-weight: 600; color: var(--text-primary); }
.clear { font-size: 26rpx; color: var(--text-tertiary); }
}
.history-tags {
display: flex; flex-wrap: wrap; gap: 20rpx;
.history-tag {
background: var(--bg-content); padding: 10rpx 24rpx; border-radius: 30rpx;
font-size: 26rpx; color: var(--text-secondary);
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
.title {
font-size: 28rpx;
font-weight: 600;
color: var(--text-secondary);
}
.clear {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
svg {
width: 36rpx;
height: 36rpx;
}
&:active { opacity: 0.7; }
}
}
.history-tags {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
.history-tag {
background: var(--bg-surface);
padding: 16rpx 28rpx;
border-radius: 40rpx;
font-size: 26rpx;
color: var(--text-secondary);
transition: all 0.15s;
&:active {
opacity: 0.7;
transform: scale(0.98);
}
}
}
}
// 结果区
.results-section {
flex: 1;
background: var(--bg-page);
}
.loading-state {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding-top: 100rpx; color: var(--text-tertiary); gap: 20rpx;
.loading-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 120rpx;
color: var(--text-tertiary);
gap: 24rpx;
}
.empty-icon {
width: 100rpx;
height: 100rpx;
opacity: 0.4;
}
.empty-text {
font-size: 28rpx;
}
// 结果列表
.result-list {
padding: 20rpx;
padding: 20rpx 32rpx;
}
.result-item {
display: flex; align-items: center;
background: var(--bg-content);
display: flex;
align-items: center;
background: var(--bg-surface);
padding: 24rpx;
border-radius: 20rpx;
margin-bottom: 20rpx;
transition: opacity 0.2s;
transition: all 0.15s;
&:active { opacity: 0.8; }
&:active {
transform: scale(0.99);
opacity: 0.9;
}
.result-info {
flex: 1; margin-left: 24rpx; display: flex; flex-direction: column;
.name { font-size: 32rpx; font-weight: 600; color: var(--text-primary); }
.desc { font-size: 24rpx; color: var(--text-tertiary); margin-top: 6rpx; }
flex: 1;
margin-left: 24rpx;
display: flex;
flex-direction: column;
.name {
font-size: 32rpx;
font-weight: 600;
color: var(--text-primary);
:deep(.highlight) {
color: var(--color-brand);
font-weight: 700;
}
}
.desc {
font-size: 24rpx;
color: var(--text-tertiary);
margin-top: 6rpx;
}
}
.chevron {
width: 32rpx;
height: 32rpx;
color: var(--text-tertiary);
}
}
</style>

View File

@@ -1,39 +1,134 @@
<template>
<wd-config-provider :theme="isDark ? 'dark' : 'light'">
<view class="settings-page" :class="{ dark: isDark }">
<app-nav-bar title="设置" custom-style="background: transparent;" />
<!-- 导航栏 -->
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</view>
<text class="nav-title">设置</text>
<view class="nav-placeholder"></view>
</view>
<view class="content-body">
<!-- 账号安全 -->
<view class="settings-group">
<view class="settings-group animate-fade-in-up">
<view class="group-title">账号</view>
<view class="group-card">
<wd-cell title="账号管理" is-link to="/pages/settings/account" size="large" />
<wd-cell title="隐私设置" is-link to="/pages/settings/privacy" size="large" />
<view class="setting-item" @click="goTo('/pages/settings/account')">
<view class="icon-box blue">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</view>
<text class="item-text">账号管理</text>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
<view class="setting-item" @click="goTo('/pages/settings/privacy')">
<view class="icon-box purple">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</view>
<text class="item-text">隐私设置</text>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
</view>
</view>
<!-- 通用设置 -->
<view class="settings-group">
<view class="settings-group animate-fade-in-up" style="animation-delay: 50ms;">
<view class="group-title">通用</view>
<view class="group-card">
<wd-cell title="新消息通知" is-link to="/pages/settings/notification" size="large" />
<wd-cell title="主题设置" is-link to="/pages/settings/theme" value="自动" size="large" />
<wd-cell title="通用设置" is-link to="/pages/settings/general" size="large" />
<wd-cell title="清理缓存" is-link value="12.5MB" clickable size="large" @click="handleClearCache" />
<view class="setting-item" @click="goTo('/pages/settings/notification')">
<view class="icon-box red">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
</view>
<text class="item-text">新消息通知</text>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
<view class="setting-item" @click="goTo('/pages/settings/theme')">
<view class="icon-box indigo">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
</view>
<text class="item-text">主题设置</text>
<text class="item-value">{{ themeText }}</text>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
<view class="setting-item" @click="handleClearCache">
<view class="icon-box green">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</view>
<text class="item-text">清理缓存</text>
<text class="item-value">{{ cacheSize }}</text>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
</view>
</view>
<!-- 关于 -->
<view class="settings-group">
<view class="settings-group animate-fade-in-up" style="animation-delay: 100ms;">
<view class="group-title">关于</view>
<view class="group-card">
<wd-cell title="帮助与反馈" is-link to="/pages/settings/feedback" size="large" />
<wd-cell title="关于我们" is-link to="/pages/settings/about" value="v1.0.0" size="large" />
<view class="setting-item" @click="goTo('/pages/settings/feedback')">
<view class="icon-box orange">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" 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>
<text class="item-text">帮助与反馈</text>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
<view class="setting-item" @click="goTo('/pages/settings/about')">
<view class="icon-box gray">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</view>
<text class="item-text">关于我们</text>
<text class="item-value">v1.0.0</text>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
</view>
</view>
<!-- 退出登录 -->
<view class="logout-btn" @click="handleLogout">
<view class="logout-btn animate-fade-in-up" style="animation-delay: 150ms;" @click="handleLogout">
<text>退出登录</text>
</view>
</view>
@@ -45,51 +140,230 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useAuthStore } from '@/stores'
import { useToast, useMessage } from 'wot-design-uni'
import AppNavBar from '@/components/common/AppNavBar.vue'
// --- 逻辑不变 ---
const { isDark } = useTheme(); const authStore = useAuthStore(); const toast = useToast(); const message = useMessage();
function handleLogout() { message.confirm({ title: '提示', msg: '确定要退出登录吗?', success: async () => { try { await authStore.logout(); uni.reLaunch({ url: '/pages/login/index' }) } catch (error) { toast.error('退出失败') } } }) }
function handleClearCache() { toast.loading('清理中...'); setTimeout(() => { toast.success('清理完成') }, 1000) }
const { isDark } = useTheme()
const authStore = useAuthStore()
const toast = useToast()
const message = useMessage()
const cacheSize = ref('12.5MB')
const themeText = computed(() => {
const mode = uni.getStorageSync('nl_im_theme_mode') || 'system'
const texts: Record<string, string> = { system: '跟随系统', light: '浅色', dark: '深色' }
return texts[mode] || '跟随系统'
})
function goBack() { uni.navigateBack() }
function goTo(url: string) { uni.navigateTo({ url }) }
function handleLogout() {
message.confirm({
title: '提示',
msg: '确定要退出登录吗?',
success: async () => {
try {
await authStore.logout()
uni.reLaunch({ url: '/pages/login/index' })
} catch (error) {
toast.error('退出失败')
}
}
})
}
function handleClearCache() {
toast.loading('清理中...')
setTimeout(() => {
cacheSize.value = '0KB'
toast.success('清理完成')
}, 1000)
}
</script>
<style lang="scss" scoped>
// 页面容器 - 浅色模式
.settings-page {
--bg-page: #f5f7fa;
--bg-page: #f7f8fa;
--bg-surface: #ffffff;
--text-secondary: #86868b;
--text-primary: #1d1d1f;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--border-color: rgba(0, 0, 0, 0.05);
min-height: 100vh;
background: var(--bg-page);
}
// 深色模式 - Warm Stone
.settings-page.dark {
--bg-page: #1c1c1e;
--bg-surface: #2c2c2e;
--text-secondary: #aeaeb2;
--bg-page: #1c1917;
--bg-surface: #292524;
--text-primary: #f5f5f4;
--text-secondary: #e7e5e4;
--text-tertiary: #78716c;
--border-color: rgba(255, 255, 255, 0.1);
}
.content-body { padding: 30rpx; }
// 动画
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.4s ease-out forwards;
opacity: 0;
}
// 导航栏
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(var(--status-bar-height) + 16rpx) 24rpx 16rpx;
background: var(--bg-page);
.nav-back {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.15s;
svg {
width: 40rpx;
height: 40rpx;
color: var(--text-primary);
}
&:active {
background: var(--border-color);
}
}
.nav-title {
font-size: 34rpx;
font-weight: 600;
color: var(--text-primary);
}
.nav-placeholder {
width: 72rpx;
}
}
// 内容
.content-body {
padding: 20rpx 32rpx;
}
// 设置组
.settings-group {
margin-bottom: 40rpx;
.group-title {
font-size: 26rpx;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 16rpx;
margin-left: 12rpx;
}
}
.settings-group { margin-bottom: 40rpx; }
.group-title { font-size: 26rpx; color: var(--text-secondary); margin-bottom: 16rpx; margin-left: 20rpx; }
.group-card {
border-radius: 24rpx; overflow: hidden;
/* 覆盖 Cell 背景 */
:deep(.wd-cell) { background-color: var(--bg-surface); }
:deep(.wd-cell__title) { font-weight: 500; }
background: var(--bg-surface);
border-radius: 24rpx;
overflow: hidden;
}
.setting-item {
display: flex;
align-items: center;
padding: 24rpx 28rpx;
border-bottom: 1rpx solid var(--border-color);
transition: background 0.15s;
&:last-child {
border-bottom: none;
}
&:active {
background: var(--border-color);
}
.icon-box {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
svg {
width: 32rpx;
height: 32rpx;
}
&.blue { background: #0ea5e9; }
&.purple { background: #8b5cf6; }
&.red { background: #ef4444; }
&.indigo { background: #4F46E5; }
&.green { background: #10b981; }
&.orange { background: #f97316; }
&.gray { background: #6b7280; }
}
.item-text {
flex: 1;
font-size: 30rpx;
font-weight: 500;
color: var(--text-primary);
}
.item-value {
font-size: 26rpx;
color: var(--text-tertiary);
margin-right: 12rpx;
}
.chevron {
width: 28rpx;
height: 28rpx;
color: var(--text-tertiary);
}
}
// 退出按钮
.logout-btn {
background: var(--bg-surface);
border-radius: 24rpx;
padding: 30rpx;
padding: 32rpx;
text-align: center;
margin-top: 60rpx;
text { color: #ef4444; font-size: 32rpx; font-weight: 600; }
&:active { opacity: 0.8; background: rgba(0,0,0,0.02); }
margin-top: 40rpx;
transition: all 0.15s;
text {
color: #ef4444;
font-size: 32rpx;
font-weight: 600;
}
&:active {
opacity: 0.8;
transform: scale(0.99);
}
}
</style>

View File

@@ -1,37 +1,81 @@
<template>
<wd-config-provider :theme="isDark ? 'dark' : 'light'">
<view class="theme-page" :class="{ dark: isDark }">
<app-nav-bar title="主题设置" custom-style="background: transparent;" />
<!-- 导航栏 -->
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</view>
<text class="nav-title">主题设置</text>
<view class="nav-placeholder"></view>
</view>
<view class="content-body">
<view class="option-list">
<!-- 浅色 -->
<view class="option-card" :class="{ active: currentTheme === 'light' }" @click="selectTheme('light')">
<view class="preview light-prev">
<view class="p-nav"></view><view class="p-body"></view>
<!-- 主题选项网格 -->
<view class="option-grid">
<!-- 浅色模式 -->
<view class="option-card animate-fade-in-up" :class="{ active: currentTheme === 'light' }" @click="selectTheme('light')">
<view class="phone-preview light-preview">
<view class="preview-nav"></view>
<view class="preview-body">
<view class="preview-card"></view>
<view class="preview-card short"></view>
</view>
</view>
<view class="option-info">
<text class="option-label">浅色模式</text>
<view v-if="currentTheme === 'light'" class="check-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
</view>
</view>
<text class="label">浅色模式</text>
<wd-icon v-if="currentTheme === 'light'" name="check" color="#4f46e5" class="check-icon" />
</view>
<!-- 深色 -->
<view class="option-card" :class="{ active: currentTheme === 'dark' }" @click="selectTheme('dark')">
<view class="preview dark-prev">
<view class="p-nav"></view><view class="p-body"></view>
<!-- 深色模式 -->
<view class="option-card animate-fade-in-up" style="animation-delay: 50ms;" :class="{ active: currentTheme === 'dark' }" @click="selectTheme('dark')">
<view class="phone-preview dark-preview">
<view class="preview-nav"></view>
<view class="preview-body">
<view class="preview-card"></view>
<view class="preview-card short"></view>
</view>
</view>
<view class="option-info">
<text class="option-label">深色模式</text>
<view v-if="currentTheme === 'dark'" class="check-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
</view>
</view>
<text class="label">深色模式</text>
<wd-icon v-if="currentTheme === 'dark'" name="check" color="#4f46e5" class="check-icon" />
</view>
<!-- 跟随系统 -->
<view class="option-card system" :class="{ active: currentTheme === 'system' }" @click="selectTheme('system')">
<view class="system-icon"><wd-icon name="setting" size="48rpx" /></view>
<text class="label">跟随系统</text>
<wd-icon v-if="currentTheme === 'system'" name="check" color="#4f46e5" class="check-icon" />
</view>
</view>
<view class="tips">
<!-- 跟随系统选项 -->
<view class="system-option animate-fade-in-up" style="animation-delay: 100ms;" :class="{ active: currentTheme === 'system' }" @click="selectTheme('system')">
<view class="system-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</view>
<view class="system-info">
<text class="system-label">跟随系统</text>
<text class="system-desc">根据系统设置自动切换</text>
</view>
<view v-if="currentTheme === 'system'" class="check-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
</view>
</view>
<!-- 提示 -->
<view class="tips animate-fade-in-up" style="animation-delay: 150ms;">
<text>「跟随系统」开启后,将根据系统显示设置自动切换深色或浅色模式。</text>
</view>
</view>
@@ -46,70 +90,297 @@ 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 } });
const { isDark, setTheme: applyTheme } = useTheme()
const toast = useToast()
const currentTheme = ref<'system' | 'light' | 'dark'>('system')
function goBack() { uni.navigateBack() }
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') {
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) {} applyTheme(systemDark, false) }
else { applyTheme(mode === 'dark', true) }
const tips = { system: '已设置为跟随系统', light: '已切换为浅色模式', dark: '已切换为深色模式' }; toast.success(tips[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) {}
applyTheme(systemDark, false)
} else {
applyTheme(mode === 'dark', true)
}
const tips = { system: '已设置为跟随系统', light: '已切换为浅色模式', dark: '已切换为深色模式' }
toast.success(tips[mode])
}
</script>
<style lang="scss" scoped>
// 页面容器 - 浅色模式
.theme-page {
--bg-page: #f5f7fa;
--bg-page: #f7f8fa;
--bg-surface: #ffffff;
--text-primary: #1d1d1f;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--border-color: rgba(0, 0, 0, 0.05);
--color-brand: #4F46E5;
min-height: 100vh;
background: var(--bg-page);
}
// 深色模式 - Warm Stone
.theme-page.dark {
--bg-page: #1c1c1e;
--bg-surface: #2c2c2e;
--text-primary: #f2f2f7;
--bg-page: #1c1917;
--bg-surface: #292524;
--text-primary: #f5f5f4;
--text-secondary: #e7e5e4;
--text-tertiary: #78716c;
--border-color: rgba(255, 255, 255, 0.1);
--color-brand: #f97316;
}
.content-body { padding: 40rpx; }
// 动画
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.option-list {
display: grid; grid-template-columns: 1fr 1fr; gap: 30rpx; margin-bottom: 40rpx;
.animate-fade-in-up {
animation: fadeInUp 0.4s ease-out forwards;
opacity: 0;
}
// 导航栏
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(var(--status-bar-height) + 16rpx) 24rpx 16rpx;
background: var(--bg-page);
.nav-back {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.15s;
svg {
width: 40rpx;
height: 40rpx;
color: var(--text-primary);
}
&:active {
background: var(--border-color);
}
}
.nav-title {
font-size: 34rpx;
font-weight: 600;
color: var(--text-primary);
}
.nav-placeholder {
width: 72rpx;
}
}
// 内容
.content-body {
padding: 32rpx;
}
// 选项网格
.option-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
margin-bottom: 24rpx;
}
.option-card {
background: var(--bg-surface); border-radius: 24rpx; padding: 30rpx;
display: flex; flex-direction: column; align-items: center; gap: 20rpx;
border: 4rpx solid transparent; position: relative;
background: var(--bg-surface);
border-radius: 24rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
border: 4rpx solid transparent;
transition: all 0.2s;
&.active { border-color: #4f46e5; background: rgba(79, 70, 229, 0.05); }
&.active {
border-color: var(--color-brand);
}
.label { font-size: 28rpx; font-weight: 600; color: var(--text-primary); }
.check-icon { position: absolute; top: 20rpx; right: 20rpx; }
&.system { grid-column: span 2; flex-direction: row; padding: 40rpx; }
&:active {
transform: scale(0.98);
}
}
/* 简单的 CSS 预览图 */
.preview {
width: 120rpx; height: 160rpx; border-radius: 12rpx; border: 2rpx solid rgba(0,0,0,0.1); overflow: hidden; display: flex; flex-direction: column;
.p-nav { height: 30rpx; border-bottom: 1rpx solid rgba(0,0,0,0.05); }
.p-body { flex: 1; margin: 10rpx; border-radius: 8rpx; }
// 手机预览
.phone-preview {
width: 160rpx;
height: 220rpx;
border-radius: 20rpx;
overflow: hidden;
display: flex;
flex-direction: column;
border: 2rpx solid var(--border-color);
margin-bottom: 20rpx;
.preview-nav {
height: 40rpx;
border-bottom: 1rpx solid var(--border-color);
}
.preview-body {
flex: 1;
padding: 16rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.preview-card {
height: 50rpx;
border-radius: 8rpx;
&.short {
width: 70%;
}
}
}
.light-prev {
.light-preview {
background: #fff;
.p-nav { background: #f7f7f7; } .p-body { background: #eee; }
.preview-nav {
background: #f7f7f7;
}
.preview-card {
background: #e5e7eb;
}
}
.dark-prev {
background: #1c1c1e; border-color: #333;
.p-nav { background: #2c2c2e; border-color: #333; } .p-body { background: #333; }
.dark-preview {
background: #1c1917;
border-color: #44403c;
.preview-nav {
background: #292524;
border-color: #44403c;
}
.preview-card {
background: #44403c;
}
}
.option-info {
display: flex;
align-items: center;
gap: 12rpx;
}
.option-label {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
.check-mark {
width: 40rpx;
height: 40rpx;
background: var(--color-brand);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 24rpx;
height: 24rpx;
}
}
// 跟随系统
.system-option {
display: flex;
align-items: center;
background: var(--bg-surface);
border-radius: 24rpx;
padding: 32rpx;
border: 4rpx solid transparent;
transition: all 0.2s;
&.active {
border-color: var(--color-brand);
}
&:active {
transform: scale(0.99);
}
}
.system-icon {
width: 80rpx; height: 80rpx; background: rgba(0,0,0,0.05); border-radius: 50%;
display: flex; align-items: center; justify-content: center; color: var(--text-primary); margin-right: 20rpx;
width: 80rpx;
height: 80rpx;
background: var(--border-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
svg {
width: 40rpx;
height: 40rpx;
color: var(--text-primary);
}
}
.tips { padding: 0 10rpx; font-size: 24rpx; color: #999; line-height: 1.5; }
.system-info {
flex: 1;
display: flex;
flex-direction: column;
}
.system-label {
font-size: 30rpx;
font-weight: 600;
color: var(--text-primary);
}
.system-desc {
font-size: 24rpx;
color: var(--text-tertiary);
margin-top: 4rpx;
}
// 提示
.tips {
padding: 32rpx 12rpx;
font-size: 24rpx;
color: var(--text-tertiary);
line-height: 1.6;
}
</style>

View File

@@ -5,3 +5,9 @@ placeholder

View File

@@ -5,3 +5,9 @@ placeholder

View File

@@ -5,3 +5,9 @@ placeholder

View File

@@ -5,3 +5,9 @@ placeholder

View File

@@ -5,3 +5,9 @@ placeholder

View File

@@ -5,3 +5,9 @@ placeholder

View File

@@ -5,3 +5,9 @@ placeholder

View File

@@ -1,8 +1,191 @@
/**
* 公共样式
* 包含布局、动画、工具类
*/
// ==========================================
// 关键帧动画
// ==========================================
// 淡入上滑动画
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// 弹入动画
@keyframes popIn {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
// 滑入动画
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
// 滑出动画
@keyframes slideDown {
from {
transform: translateY(0);
}
to {
transform: translateY(100%);
}
}
// 波形动画 (语音消息)
@keyframes wave {
0%, 100% {
height: 8rpx;
}
50% {
height: 32rpx;
}
}
// 脉冲动画 (通话指示器)
@keyframes pulse-ring {
0% {
transform: scale(0.8);
opacity: 0.5;
}
100% {
transform: scale(1.3);
opacity: 0;
}
}
// 涟漪动画 (来电头像)
@keyframes ripple {
0% {
transform: scale(1);
opacity: 0.4;
}
100% {
transform: scale(2.5);
opacity: 0;
}
}
// 呼吸动画
@keyframes breathing {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
// 弹跳动画
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-4rpx);
}
}
// ==========================================
// 过渡和动画工具类
// ==========================================
.smooth-transition {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.fast-transition {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.animate-fade-in-up {
animation: fadeInUp 0.4s ease-out forwards;
}
.animate-pop-in {
animation: popIn 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out forwards;
}
.animate-pulse {
animation: breathing 2s ease-in-out infinite;
}
// 动画延迟
.delay-50 { animation-delay: 50ms; }
.delay-100 { animation-delay: 100ms; }
.delay-150 { animation-delay: 150ms; }
.delay-200 { animation-delay: 200ms; }
.delay-300 { animation-delay: 300ms; }
// ==========================================
// 滚动条隐藏
// ==========================================
.custom-scrollbar {
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
// ==========================================
// 毛玻璃效果
// ==========================================
.glass-effect {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
.dark & {
background: rgba(28, 25, 23, 0.85);
}
}
.glass-light {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
// ==========================================
// 安全区域
// ==========================================
:root {
// 状态栏高度 - UniApp 会自动注入
--status-bar-height: var(--status-bar-height, 44px);
// 安全区域
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-left: env(safe-area-inset-left, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
}
.safe-area-top {
padding-top: var(--status-bar-height, 0);
}
@@ -11,7 +194,28 @@
padding-bottom: env(safe-area-inset-bottom);
}
// 安全区域占位
.safe-area-spacer {
height: calc(160rpx + env(safe-area-inset-bottom));
}
// 响应式断点
@mixin respond-to($breakpoint) {
@if $breakpoint == 'sm' {
@media (max-width: 375px) { @content; }
}
@else if $breakpoint == 'md' {
@media (min-width: 376px) and (max-width: 414px) { @content; }
}
@else if $breakpoint == 'lg' {
@media (min-width: 415px) { @content; }
}
}
// ==========================================
// Flex 布局
// ==========================================
.flex {
display: flex;
}
@@ -37,7 +241,38 @@
flex: 1;
}
.flex-shrink-0 {
flex-shrink: 0;
}
.items-center {
align-items: center;
}
.items-start {
align-items: flex-start;
}
.items-end {
align-items: flex-end;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.justify-end {
justify-content: flex-end;
}
// ==========================================
// 文本
// ==========================================
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
@@ -64,7 +299,18 @@
color: var(--text-tertiary);
}
.font-bold {
font-weight: 700;
}
.font-medium {
font-weight: 500;
}
// ==========================================
// 边距
// ==========================================
.m-0 { margin: 0; }
.mt-10 { margin-top: 10rpx; }
.mt-20 { margin-top: 20rpx; }
@@ -72,33 +318,82 @@
.mb-10 { margin-bottom: 10rpx; }
.mb-20 { margin-bottom: 20rpx; }
.mb-30 { margin-bottom: 30rpx; }
.ml-10 { margin-left: 10rpx; }
.mr-10 { margin-right: 10rpx; }
.p-0 { padding: 0; }
.p-20 { padding: 20rpx; }
.p-30 { padding: 30rpx; }
.px-20 { padding-left: 20rpx; padding-right: 20rpx; }
.px-30 { padding-left: 30rpx; padding-right: 30rpx; }
.py-20 { padding-top: 20rpx; padding-bottom: 20rpx; }
// ==========================================
// 圆角规范
// ==========================================
.rounded-sm { border-radius: 8rpx; }
.rounded { border-radius: 16rpx; }
.rounded-lg { border-radius: 20rpx; }
.rounded-xl { border-radius: 24rpx; }
.rounded-2xl { border-radius: 32rpx; }
.rounded-3xl { border-radius: 40rpx; }
.rounded-full { border-radius: 50%; }
// ==========================================
// 阴影规范
// ==========================================
.shadow-sm {
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.shadow {
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
}
.shadow-lg {
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
}
.shadow-xl {
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.15);
}
// ==========================================
// 页面容器
// ==========================================
.page-container {
min-height: 100vh;
background: var(--bg-page);
}
// ==========================================
// 卡片
// ==========================================
.card {
background: var(--bg-content);
border-radius: 16rpx;
border-radius: 20rpx;
margin: 20rpx;
overflow: hidden;
}
// ==========================================
// 分割线
// ==========================================
.divider {
height: 1rpx;
background: var(--divider-color);
margin: 0 30rpx;
}
// ==========================================
// 头像占位
// ==========================================
.avatar-placeholder {
display: flex;
align-items: center;
@@ -108,7 +403,10 @@
font-weight: 600;
}
// ==========================================
// 空状态
// ==========================================
.empty-state {
display: flex;
flex-direction: column;
@@ -118,7 +416,10 @@
color: var(--text-tertiary);
}
// ==========================================
// 加载状态
// ==========================================
.loading-state {
display: flex;
align-items: center;
@@ -127,7 +428,10 @@
color: var(--text-tertiary);
}
// ==========================================
// 自定义导航栏
// ==========================================
.custom-navbar {
position: fixed;
top: 0;
@@ -135,19 +439,22 @@
right: 0;
z-index: 100;
background: var(--nav-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
padding-top: var(--status-bar-height, 0);
border-bottom: 1rpx solid var(--border-subtle);
.navbar-content {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 30rpx;
padding: 0 32rpx;
}
.navbar-title {
flex: 1;
font-size: 36rpx;
font-weight: 600;
font-size: 40rpx;
font-weight: 700;
color: var(--text-primary);
}
@@ -158,31 +465,189 @@
}
}
// ==========================================
// 底部安全区
// ==========================================
.bottom-safe-area {
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
// ==========================================
// 浮动按钮
// ==========================================
.fab {
position: fixed;
right: 30rpx;
right: 40rpx;
bottom: calc(180rpx + env(safe-area-inset-bottom));
width: 100rpx;
height: 100rpx;
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: var(--color-primary);
background: #10b981;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 20rpx rgba(7, 193, 96, 0.4);
box-shadow: 0 10rpx 30rpx rgba(16, 185, 129, 0.3);
z-index: 99;
transition: transform 0.2s;
&:active {
transform: scale(0.95);
}
}
// ==========================================
// 图标按钮
// ==========================================
.icon-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.15s;
&:active {
background: var(--bg-hover);
opacity: 0.7;
}
}
// ==========================================
// 点击反馈
// ==========================================
.clickable {
transition: all 0.15s;
&:active {
opacity: 0.7;
}
}
.pressable {
transition: transform 0.15s;
&:active {
transform: scale(0.95);
}
}
// ==========================================
// 遮罩层
// ==========================================
.mask-enter {
opacity: 0;
pointer-events: none;
}
.mask-active {
opacity: 1;
pointer-events: auto;
}
// ==========================================
// 抽屉动画
// ==========================================
.drawer-enter {
transform: translateX(-100%);
}
.drawer-active {
transform: translateX(0);
}
// ==========================================
// 菜单动画
// ==========================================
.menu-enter {
opacity: 0;
transform: scale(0.95) translateY(-10rpx);
pointer-events: none;
}
.menu-active {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
// ==========================================
// 底部弹窗动画
// ==========================================
.bottom-sheet-enter {
transform: translateY(100%);
}
.bottom-sheet-active {
transform: translateY(0);
}
// ==========================================
// 波形可视化 (语音消息)
// ==========================================
.wave-visualizer {
display: flex;
align-items: center;
gap: 4rpx;
height: 32rpx;
.wave-bar {
width: 4rpx;
height: 8rpx;
background: currentColor;
border-radius: 4rpx;
transition: height 0.1s;
&.animating {
animation: wave 0.8s infinite ease-in-out;
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: 0.1s; }
&:nth-child(3) { animation-delay: 0.2s; }
&:nth-child(4) { animation-delay: 0.3s; }
}
}
}
// ==========================================
// 头像涟漪动画 (来电)
// ==========================================
.avatar-ripple {
position: absolute;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
inset: -20rpx;
animation: ripple 2s infinite linear;
&.delay {
animation-delay: 0.5s;
}
}
// ==========================================
// 脉冲指示器 (通话中)
// ==========================================
.pulse-indicator {
position: relative;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: inherit;
animation: pulse-ring 2s infinite;
}
}

View File

@@ -1,107 +1,175 @@
/**
* 主题变量定义
* 浅色/暗色两套主题
* 浅色/暗色两套主题 - Warm Stone 暖色调系统
*/
// ==========================================
// Warm 色板定义 (用于深色模式)
// ==========================================
$warm-950: #0c0a09;
$warm-900: #1c1917;
$warm-800: #292524;
$warm-700: #44403c;
$warm-600: #57534e;
$warm-500: #78716c;
$warm-200: #e7e5e4;
$warm-100: #f5f5f4;
// 品牌色
$color-brand-light: #4F46E5;
$color-brand-dark: #f97316;
// ==========================================
// 浅色主题(默认)
// ==========================================
page {
// 主色调
--color-primary: #07c160;
--color-primary-dark: #06ad56;
--color-danger: #fa5151;
--color-warning: #ffc300;
--color-success: #07c160;
--color-info: #10aeff;
// 主色调 - Indigo 品牌色
--color-primary: #4F46E5;
--color-primary-light: #6366F1;
--color-primary-dark: #4338CA;
--color-danger: #ef4444;
--color-warning: #f97316;
--color-success: #10b981;
--color-info: #0ea5e9;
// 背景色
--bg-page: #ededed;
--bg-page: #f7f8fa;
--bg-surface: #ffffff;
--bg-content: #ffffff;
--bg-hover: #f5f5f5;
--bg-highlight: #f2f3f5;
--bg-mask: rgba(0, 0, 0, 0.5);
--bg-card: #ffffff;
// 文字颜色
--text-primary: #333333;
--text-secondary: #666666;
--text-tertiary: #999999;
--text-placeholder: #cccccc;
--text-primary: #1d1d1f;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--text-placeholder: #a1a1a6;
--text-link: #576b95;
// 边框颜色
--border-color: #e5e5e5;
--divider-color: #f0f0f0;
--border-color: rgba(0, 0, 0, 0.05);
--border-subtle: rgba(0, 0, 0, 0.05);
--divider-color: rgba(0, 0, 0, 0.05);
// 导航栏
--nav-bg: #ededed;
--nav-text: #000000;
--nav-bg: rgba(255, 255, 255, 0.85);
--nav-text: #1d1d1f;
// 标签栏
--tabbar-bg: #f7f7f7;
--tabbar-bg: #ffffff;
--tabbar-text: #999999;
--tabbar-text-active: #07c160;
--tabbar-text-active: #4F46E5;
// 消息气泡
--bubble-self: #95ec69;
--bubble-self-start: #6366F1;
--bubble-self-end: #4F46E5;
--bubble-self: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
--bubble-other: #ffffff;
--bubble-system: rgba(0, 0, 0, 0.1);
--bubble-system: rgba(0, 0, 0, 0.04);
--bubble-shadow: rgba(79, 70, 229, 0.2);
// 搜索框
--search-bg: #f5f5f5;
--search-bg: #f1f1f1;
// 头像
--avatar-bg: #07c160;
--avatar-bg: #4F46E5;
--avatar-text: #ffffff;
// 未读徽标
--badge-bg: #fa5151;
--badge-bg: #ef4444;
--badge-text: #ffffff;
// 开关
--switch-on: #07c160;
--switch-off: #e5e5e5;
--switch-on: #4F46E5;
--switch-off: #e5e7eb;
// 状态栏高度变量 (小程序/App会自动注入,H5需fallback)
--status-bar-height: 20px;
// 分段控制器
--tab-bg: #e5e7eb;
--tab-active-bg: #ffffff;
// 功能卡片渐变
--gradient-orange: linear-gradient(135deg, #f97316, #fb923c);
--gradient-blue: linear-gradient(135deg, #0ea5e9, #38bdf8);
--gradient-indigo: linear-gradient(135deg, #6366F1, #4F46E5);
--gradient-green: linear-gradient(135deg, #10b981, #34d399);
}
// 暗色主题 (Dark Mode - 高级灰优化)
// ==========================================
// 暗色主题 (Dark Mode - Warm Stone 暖色调)
// ==========================================
page.dark,
.dark,
html.dark,
body.dark {
// 背景色 - 使用 #1c1c1e 替代纯黑
--bg-page: #1c1c1e;
--bg-content: #2c2c2e;
--bg-hover: #3a3a3c;
// 主色调 - Orange 品牌色
--color-primary: #f97316;
--color-primary-light: #fb923c;
--color-primary-dark: #ea580c;
--color-danger: #ef4444;
--color-warning: #fbbf24;
--color-success: #10b981;
--color-info: #38bdf8;
// 背景色 - Warm Stone 暖色调
--bg-page: #{$warm-900};
--bg-surface: #{$warm-800};
--bg-content: #{$warm-800};
--bg-hover: #{$warm-700};
--bg-highlight: #{$warm-700};
--bg-mask: rgba(0, 0, 0, 0.7);
--bg-card: #2c2c2e;
--bg-card: #{$warm-800};
// 文字颜色
--text-primary: #f2f2f7; // 柔和白
--text-secondary: #aeaeb2;
--text-tertiary: #636366;
--text-placeholder: #505055;
--text-link: #7b9ad0;
--text-primary: #{$warm-100};
--text-secondary: #{$warm-200};
--text-tertiary: #{$warm-500};
--text-placeholder: #{$warm-600};
--text-link: #7e95c5;
// 边框颜色
--border-color: #38383a;
--divider-color: #38383a;
--border-color: #{$warm-700};
--border-subtle: rgba(255, 255, 255, 0.1);
--divider-color: #{$warm-700};
// 导航栏
--nav-bg: #1c1c1e;
--nav-text: #ffffff;
--nav-bg: rgba(28, 25, 23, 0.85);
--nav-text: #{$warm-100};
// 标签栏
--tabbar-bg: #1c1c1e;
--tabbar-bg: #{$warm-900};
--tabbar-text: #{$warm-500};
--tabbar-text-active: #f97316;
// 消息气泡
--bubble-self: #2d5c2e; // 深绿
--bubble-other: #2c2c2e;
--bubble-system: rgba(255, 255, 255, 0.1);
// 消息气泡 - 深色模式保持渐变
--bubble-self-start: #6366F1;
--bubble-self-end: #4F46E5;
--bubble-self: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
--bubble-other: #262626;
--bubble-system: rgba(255, 255, 255, 0.08);
--bubble-shadow: none;
// 搜索框
--search-bg: #2c2c2e;
--search-bg: #{$warm-800};
// 头像
--avatar-bg: #f97316;
// 开关
--switch-off: #3a3a3c;
--switch-on: #f97316;
--switch-off: #{$warm-700};
// 分段控制器
--tab-bg: #{$warm-800};
--tab-active-bg: #{$warm-700};
// 功能卡片渐变 (暖色调版本)
--gradient-orange: linear-gradient(135deg, #ea580c, #f97316);
--gradient-blue: linear-gradient(135deg, #0284c7, #0ea5e9);
--gradient-indigo: linear-gradient(135deg, #c2410c, #ea580c);
--gradient-green: linear-gradient(135deg, #059669, #10b981);
}