This commit is contained in:
2025-12-10 13:23:17 +08:00
parent a102312166
commit 58d5c52a45
15 changed files with 2641 additions and 3725 deletions

View File

@@ -13,7 +13,7 @@ const conversationStore = useConversationStore()
function handleGlobalMessage(message: ChatMessage) { function handleGlobalMessage(message: ChatMessage) {
// 忽略信令消息 // 忽略信令消息
if (message.message_type === 6) return if (message.message_type === 6) return
// 更新会话列表 // 更新会话列表
const isSelf = message.sender_user_id === authStore.user?.id const isSelf = message.sender_user_id === authStore.user?.id
conversationStore.handleMessageUpdate(message, isSelf, false) conversationStore.handleMessageUpdate(message, isSelf, false)
@@ -23,7 +23,7 @@ function handleGlobalMessage(message: ChatMessage) {
async function initWebSocket() { async function initWebSocket() {
const userId = authStore.user?.id const userId = authStore.user?.id
if (!userId) return if (!userId) return
try { try {
await wsManager.connect(userId) await wsManager.connect(userId)
// 注册全局消息处理器 // 注册全局消息处理器
@@ -44,7 +44,7 @@ onShow(async () => {
console.log('App Show') console.log('App Show')
// 检查登录状态 // 检查登录状态
const isAuth = await authStore.checkAuth() const isAuth = await authStore.checkAuth()
// 如果已登录,初始化 WebSocket // 如果已登录,初始化 WebSocket
if (isAuth && authStore.user?.id) { if (isAuth && authStore.user?.id) {
initWebSocket() initWebSocket()
@@ -56,6 +56,11 @@ onShow(async () => {
@import '@/styles/variables.scss'; @import '@/styles/variables.scss';
@import '@/styles/common.scss'; @import '@/styles/common.scss';
/* 移除全局硬编码 padding交由各页面 nav-bar 处理 */
#app {
/* padding-top: calc(var(--status-bar-height) + 40rpx); REMOVED */
}
/* 全局样式 */ /* 全局样式 */
page { page {
background-color: var(--bg-page); background-color: var(--bg-page);
@@ -64,11 +69,11 @@ page {
font-size: 28rpx; font-size: 28rpx;
line-height: 1.5; line-height: 1.5;
// wot-design-uni 主题色覆盖 // wot-design-uni 主题色覆盖 (使用 CSS 变量)
--wot-color-theme: #07c160; --wot-color-theme: var(--color-primary);
--wot-color-success: #07c160; --wot-color-success: var(--color-success);
--wot-color-danger: #fa5151; --wot-color-danger: var(--color-danger);
--wot-color-warning: #ffc300; --wot-color-warning: var(--color-warning);
} }
/* 暗色模式下的 wot-design-uni 组件适配 */ /* 暗色模式下的 wot-design-uni 组件适配 */

File diff suppressed because it is too large Load Diff

View File

@@ -1,223 +1,344 @@
<template> <template>
<view class="add-page" :class="{ dark: isDark }"> <wd-config-provider :theme="isDark ? 'dark' : 'light'">
<!-- 导航栏 --> <view class="page-container" :class="{ 'theme-dark': isDark }">
<app-nav-bar title="添加好友" /> <!-- 顶部背景装饰 -->
<view class="page-bg-decoration" />
<!-- 搜索区域 -->
<view class="search-section">
<wd-search
v-model="keyword"
placeholder="输入用户ID或手机号搜索"
show-action
@search="doSearch"
@cancel="keyword = ''"
/>
</view>
<!-- 搜索结果 --> <!-- 导航栏 -->
<view v-if="searched" class="results-section"> <app-nav-bar title="添加好友" custom-style="background: transparent;" />
<view v-if="loading" class="loading-state">
<wd-loading />
<text>搜索中...</text>
</view>
<wd-status-tip v-else-if="results.length === 0" tip="未找到相关用户" /> <view class="content-body">
<!-- 头部标题区 -->
<view class="header-area">
<text class="main-title">发现新朋友</text>
<text class="sub-title">Expanding your circle</text>
</view>
<template v-else> <!-- 搜索区域 -->
<view <view class="search-section">
v-for="item in results" <wd-search
:key="item.id" v-model="keyword"
class="result-item" placeholder="搜索账号 / 手机号"
> hide-cancel
<app-avatar placeholder-left
:src="item.avatar" custom-class="glass-search"
:name="item.name" @search="doSearch"
:size="96" @clear="keyword = ''"
radius="8rpx" >
/> <template #suffix>
<view class="search-btn" @click="doSearch">
<wd-icon name="search" size="36rpx" />
</view>
</template>
</wd-search>
</view>
<view class="user-info"> <!-- 搜索结果 -->
<text class="name">{{ item.name }}</text> <view v-if="searched" class="result-section">
<text class="id">ID: {{ item.id }}</text> <view class="section-header">
<text class="section-title">搜索结果</text>
</view> </view>
<wd-button type="primary" size="small" @click="addFriend(item)">添加</wd-button> <!-- 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
v-for="item in results"
:key="item.id"
class="user-card"
>
<app-avatar
:src="item.avatar"
:name="item.name"
:size="110"
radius="36rpx"
/>
<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>
</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> </view>
</template>
</view>
<!-- 扫码添加 -->
<view class="scan-section" @click="scanQrCode">
<view class="scan-icon">
<wd-icon name="scan" size="64rpx" color="var(--color-primary)" />
</view> </view>
<text class="scan-text">扫一扫添加好友</text>
</view>
<wd-toast /> <wd-toast />
<wd-message-box /> <wd-message-box />
</view> </view>
</wd-config-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import * as contactApi from '@/api/modules/contact' import * as contactApi from '@/api/modules/contact'
import { generateColor } from '@/utils/format'
import { useToast, useMessage } from 'wot-design-uni' import { useToast, useMessage } from 'wot-design-uni'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import AppAvatar from '@/components/common/AppAvatar.vue' import AppAvatar from '@/components/common/AppAvatar.vue'
import type { User } from '@/types/api' import type { User } from '@/types/api'
const toast = useToast() // --- 保持逻辑完全不变 ---
const message = useMessage() const toast = useToast(); const message = useMessage(); const { isDark } = useTheme();
const { isDark } = useTheme() const keyword = ref(''); const loading = ref(false); const searched = ref(false); const results = ref<User[]>([]);
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 } }
const keyword = ref('') async function addFriend(user: User) { try { const { value } = await message.prompt({ title: '添加好友', inputPlaceholder: '请输入验证消息', inputValue: '你好,我是...' }); await contactApi.addFriend({ to_user_id: user.id, message: value || '' }); toast.success('申请已发送') } catch (e: any) { if (e !== 'cancel') { toast.error('发送失败') } } }
const loading = ref(false) function scanQrCode() { uni.scanCode({ onlyFromCamera: false, success: (res) => { console.log('扫码结果:', res.result); toast.show('扫码功能开发中') }, fail: () => { toast.error('扫码失败') } }) }
const searched = ref(false)
const results = ref<User[]>([])
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: '添加好友',
inputPlaceholder: '请输入验证消息',
inputValue: '你好,我是...'
})
await contactApi.addFriend({
to_user_id: user.id,
message: value || ''
})
toast.success('申请已发送')
} catch (e: any) {
if (e !== 'cancel') {
toast.error('发送失败')
}
}
}
function scanQrCode() {
uni.scanCode({
onlyFromCamera: false,
success: (res) => {
console.log('扫码结果:', res.result)
// TODO: 处理扫码结果
toast.show('扫码功能开发中')
},
fail: () => {
toast.error('扫码失败')
}
})
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.add-page { .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);
min-height: 100vh; min-height: 100vh;
background: var(--bg-page); background: var(--bg-base);
} position: relative;
overflow: hidden;
transition: all 0.3s ease;
.search-section { &.theme-dark {
background: var(--bg-content); --bg-base: #121212; /* 高级灰 */
padding: 10rpx 0; --bg-card: #1C1C1E; /* 卡片灰 */
} --text-main: #F2F3F5;
--text-sub: #86909C;
.results-section { --shadow-sm: 0 4rpx 16rpx rgba(0, 0, 0, 0.2);
background: var(--bg-content);
margin-top: 20rpx;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
.result-item {
display: flex;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid var(--divider-color);
.avatar-placeholder {
width: 96rpx;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
color: #fff;
font-size: 36rpx;
font-weight: 600;
} }
} }
.user-info { /* 背景装饰球 */
flex: 1; .page-bg-decoration {
margin-left: 24rpx; position: absolute;
min-width: 0; 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;
}
.name { .content-body {
position: relative;
z-index: 1;
padding: 20rpx 32rpx;
}
/* 头部 */
.header-area {
margin: 20rpx 0 40rpx;
.main-title {
display: block; display: block;
font-size: 32rpx; font-size: 48rpx;
color: var(--text-primary); font-weight: 800;
color: var(--text-main);
letter-spacing: -1rpx;
}
.sub-title {
font-size: 24rpx;
color: var(--text-sub);
font-weight: 500; font-weight: 500;
} text-transform: uppercase;
letter-spacing: 2rpx;
.id {
display: block;
font-size: 26rpx;
color: var(--text-secondary);
margin-top: 8rpx; margin-top: 8rpx;
} }
} }
.scan-section { /* 搜索栏定制 */
.search-section {
margin-bottom: 40rpx;
}
: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%;
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); }
}
/* 结果列表 */
.section-header {
margin-bottom: 24rpx;
.section-title {
font-size: 30rpx;
font-weight: 700;
color: var(--text-main);
}
}
.loading-box, .empty-box {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; padding: 100rpx 0;
padding: 80rpx; gap: 24rpx;
background: var(--bg-content); color: var(--text-sub);
margin-top: 20rpx; font-size: 26rpx;
}
.empty-icon { font-size: 60rpx; opacity: 0.5; }
.scan-icon { .user-card {
width: 120rpx; background: var(--bg-card);
height: 120rpx; padding: 24rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
margin-bottom: 24rpx;
box-shadow: var(--shadow-sm);
animation: slideUp 0.3s ease-out;
.card-info {
flex: 1;
margin-left: 24rpx;
.user-name {
display: block;
font-size: 34rpx;
font-weight: 600;
color: var(--text-main);
}
.user-id {
display: block;
margin-top: 6rpx;
font-size: 24rpx;
color: var(--text-sub);
font-family: monospace;
}
}
.action-wrap {
width: 80rpx;
height: 80rpx;
border-radius: 24rpx;
background: var(--text-main); /* 黑色/白色按钮 */
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--bg-hover); transition: all 0.2s;
border-radius: 50%;
margin-bottom: 24rpx;
}
.scan-text { .theme-dark & { background: #333; }
font-size: 30rpx;
color: var(--text-secondary); &:active { transform: scale(0.92); }
} }
} }
/* 功能卡片 (扫一扫) */
.feature-cards {
margin-top: 20rpx;
}
.feature-card {
position: relative;
border-radius: 40rpx;
padding: 40rpx;
overflow: hidden;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow-lg);
&.scan-card {
background: var(--primary-gradient);
color: #fff;
}
/* 卡片内部纹理 */
&::before {
content: '';
position: absolute;
top: -50%;
left: -20%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%);
pointer-events: none;
}
.card-content {
display: flex;
align-items: center;
gap: 24rpx;
z-index: 2;
}
.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;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.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; }
}
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); }
}
</style> </style>

View File

@@ -1,73 +1,100 @@
<template> <template>
<view class="detail-page" :class="{ dark: isDark }"> <view class="detail-container" :class="{ 'theme-dark': isDark }">
<!-- 导航栏 --> <!-- 头部背景区 (视差效果) -->
<app-nav-bar title="好友详情" /> <view class="profile-header">
<view class="blur-bg" :style="headerBgStyle" />
<view v-if="loading" class="loading-state"> <view class="gradient-mask" />
<wd-loading /> <app-nav-bar custom-style="background: transparent;" title-style="color: transparent;" />
<text>加载中...</text>
</view> </view>
<template v-else-if="contact"> <!-- 主体内容 (浮动卡片) -->
<!-- 用户信息 --> <scroll-view scroll-y class="main-content" v-if="!loading && contact">
<view class="header-section"> <view class="content-wrapper">
<app-avatar
:src="contact.user?.avatar" <!-- 用户身份卡片 -->
:name="contact.remark_name || contact.user?.name" <view class="identity-card">
:size="140" <view class="avatar-wrapper">
radius="12rpx" <app-avatar
/> :src="contact.user?.avatar"
<view class="user-info"> :name="contact.remark_name || contact.user?.name"
<text class="name">{{ contact.remark_name || contact.user?.name || '未知' }}</text> :size="180"
<text class="id">ID: {{ contact.user?.id || '-' }}</text> radius="50%"
custom-style="border: 6rpx 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>
<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>
<!-- 设置组交互 -->
<view class="settings-group">
<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>
<view class="spacer" />
</view>
</scroll-view>
<!-- Loading -->
<view v-else-if="loading" class="center-loading">
<wd-loading size="60rpx" color="#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" />
</view>
<view class="dock-btn primary" @click="goChat">
<wd-icon name="chat-filled" size="40rpx" />
<text>发消息</text>
</view>
<view class="dock-btn secondary" @click="startVideoCall">
<wd-icon name="video" size="44rpx" />
</view> </view>
</view> </view>
<!-- 基本信息 --> <view class="danger-btn" @click="deleteFriend">
<wd-cell-group> <text>删除好友</text>
<wd-cell title="备注名" :value="contact.remark_name || '未设置'" is-link @click="editRemark" />
<wd-cell title="个性签名" :value="contact.user?.desc || '暂无'" />
<wd-cell title="地区" :value="contact.user?.region || '未知'" />
</wd-cell-group>
<!-- 设置 -->
<wd-cell-group title="聊天设置">
<wd-cell title="置顶聊天" center>
<template #value>
<wd-switch v-model="contact.is_top" @change="toggleTop" />
</template>
</wd-cell>
<wd-cell title="消息免打扰" center>
<template #value>
<wd-switch v-model="contact.is_muted" @change="toggleMute" />
</template>
</wd-cell>
<wd-cell title="加入黑名单" center>
<template #value>
<wd-switch v-model="contact.is_blocked" @change="toggleBlock" />
</template>
</wd-cell>
</wd-cell-group>
<!-- 操作按钮 -->
<view class="actions-section">
<view class="action-row">
<wd-button type="primary" class="flex-btn" @click="goChat">
<wd-icon name="chat" size="36rpx" />
<text>发消息</text>
</wd-button>
<wd-button type="info" class="flex-btn" @click="startAudioCall">
<wd-icon name="phone" size="36rpx" />
<text>语音</text>
</wd-button>
<wd-button type="success" class="flex-btn" @click="startVideoCall">
<wd-icon name="video" size="36rpx" />
<text>视频</text>
</wd-button>
</view>
<wd-button type="error" block plain @click="deleteFriend">删除好友</wd-button>
</view> </view>
</template> </view>
<wd-toast /> <wd-toast />
<wd-message-box /> <wd-message-box />
@@ -75,236 +102,261 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import * as contactApi from '@/api/modules/contact' import * as contactApi from '@/api/modules/contact'
import { generateColor } from '@/utils/format'
import { resolveImageUrl } from '@/utils/image'
import { useToast, useMessage } from 'wot-design-uni' import { useToast, useMessage } from 'wot-design-uni'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import AppAvatar from '@/components/common/AppAvatar.vue' import AppAvatar from '@/components/common/AppAvatar.vue'
import type { Contact } from '@/types/api' import type { Contact } from '@/types/api'
const toast = useToast() // --- 逻辑保持不变 ---
const messageBox = useMessage() const toast = useToast(); const messageBox = useMessage(); const { isDark } = useTheme();
const { isDark } = useTheme() const loading = ref(true); const contact = ref<Contact | null>(null); const contactId = ref(''); const userId = ref('');
const loading = ref(true) const headerBgStyle = computed(() => {
const contact = ref<Contact | null>(null) const url = contact.value?.user?.avatar || '';
const contactId = ref('') return url ? `background-image: url(${url});` : 'background: var(--bg-card);';
const userId = ref('')
onLoad((options: any) => {
contactId.value = options?.id || ''
userId.value = options?.userId || ''
}) })
onMounted(async () => { onLoad((options: any) => { contactId.value = options?.id || ''; userId.value = options?.userId || '' })
if (contactId.value) { onMounted(async () => { if (contactId.value) { await loadContact() } else if (userId.value) { await loadContactByUserId() } })
await loadContact() 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 } }
} else if (userId.value) { 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 } }
await loadContactByUserId() function goChat() { if (!contact.value) return; uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}` }) }
} function startAudioCall() { if (!contact.value) return; uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}&callType=audio` }) }
}) function startVideoCall() { if (!contact.value) return; uni.navigateTo({ url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}&callType=video` }) }
async function editRemark() { if (!contact.value) return; try { const { value } = await messageBox.prompt({ title: '修改备注名', inputValue: contact.value.remark_name || '', inputPlaceholder: '请输入备注名' }); await contactApi.updateContact(contactId.value, { remark_name: value }); contact.value.remark_name = value; toast.success('修改成功') } catch (e: any) { if (e !== 'cancel') { toast.error('修改失败') } } }
async function loadContact() { async function toggleTop() { if (!contact.value) return; try { await contactApi.updateContact(contactId.value, { is_top: contact.value.is_top }); toast.success(contact.value.is_top ? '已置顶' : '已取消置顶') } catch { contact.value.is_top = !contact.value.is_top; toast.error('操作失败') } }
loading.value = true async function toggleMute() { if (!contact.value) return; try { await contactApi.updateContact(contactId.value, { is_muted: contact.value.is_muted }); toast.success(contact.value.is_muted ? '已开启免打扰' : '已关闭免打扰') } catch { contact.value.is_muted = !contact.value.is_muted; toast.error('操作失败') } }
try { async function toggleBlock() { if (!contact.value) return; try { await contactApi.updateContact(contactId.value, { is_blocked: contact.value.is_blocked }); toast.success(contact.value.is_blocked ? '已加入黑名单' : '已移出黑名单') } catch { contact.value.is_blocked = !contact.value.is_blocked; toast.error('操作失败') } }
contact.value = await contactApi.getContactDetail(contactId.value) async function deleteFriend() { try { await messageBox.confirm({ title: '提示', msg: '确定要删除该好友吗?删除后将无法恢复' }); await contactApi.deleteContact(contactId.value); toast.success('删除成功'); setTimeout(() => { uni.navigateBack() }, 1000) } catch (e: any) { if (e !== 'cancel') { toast.error('删除失败') } } }
} catch (error) {
console.error('加载联系人失败:', error)
toast.error('加载失败')
} finally {
loading.value = false
}
}
// 通过用户 ID 查找联系人
async function loadContactByUserId() {
loading.value = true
try {
// 获取所有联系人,找到对应的用户
const contacts = await contactApi.getContacts()
const found = contacts.find((c: Contact) => c.contact_user_id === userId.value || c.user_id === userId.value)
if (found) {
contact.value = await contactApi.getContactDetail(found.id?.toString() || '')
} else {
toast.warning('未找到该联系人')
setTimeout(() => uni.navigateBack(), 1500)
}
} catch (error) {
console.error('加载联系人失败:', error)
toast.error('加载失败')
} finally {
loading.value = false
}
}
function goChat() {
if (!contact.value) return
uni.navigateTo({
url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}`
})
}
function startAudioCall() {
if (!contact.value) return
uni.navigateTo({
url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}&callType=audio`
})
}
function startVideoCall() {
if (!contact.value) return
uni.navigateTo({
url: `/pages/chat/index?targetId=${contact.value.contact_user_id}&roomId=${contact.value.room_id || ''}&name=${encodeURIComponent(contact.value.remark_name || contact.value.user?.name || '')}&avatar=${encodeURIComponent(contact.value.user?.avatar || '')}&callType=video`
})
}
async function editRemark() {
if (!contact.value) return
try {
const { value } = await messageBox.prompt({
title: '修改备注名',
inputValue: contact.value.remark_name || '',
inputPlaceholder: '请输入备注名'
})
await contactApi.updateContact(contactId.value, { remark_name: value })
contact.value.remark_name = value
toast.success('修改成功')
} catch (e: any) {
if (e !== 'cancel') {
toast.error('修改失败')
}
}
}
async function toggleTop() {
if (!contact.value) return
try {
await contactApi.updateContact(contactId.value, { is_top: contact.value.is_top })
toast.success(contact.value.is_top ? '已置顶' : '已取消置顶')
} catch {
contact.value.is_top = !contact.value.is_top
toast.error('操作失败')
}
}
async function toggleMute() {
if (!contact.value) return
try {
await contactApi.updateContact(contactId.value, { is_muted: contact.value.is_muted })
toast.success(contact.value.is_muted ? '已开启免打扰' : '已关闭免打扰')
} catch {
contact.value.is_muted = !contact.value.is_muted
toast.error('操作失败')
}
}
async function toggleBlock() {
if (!contact.value) return
try {
await contactApi.updateContact(contactId.value, { is_blocked: contact.value.is_blocked })
toast.success(contact.value.is_blocked ? '已加入黑名单' : '已移出黑名单')
} catch {
contact.value.is_blocked = !contact.value.is_blocked
toast.error('操作失败')
}
}
async function deleteFriend() {
try {
await messageBox.confirm({
title: '提示',
msg: '确定要删除该好友吗?删除后将无法恢复'
})
await contactApi.deleteContact(contactId.value)
toast.success('删除成功')
setTimeout(() => {
uni.navigateBack()
}, 1000)
} catch (e: any) {
if (e !== 'cancel') {
toast.error('删除失败')
}
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.detail-page { /* 高级灰配色 */
.detail-container {
--bg-base: #F5F7FA;
--bg-card: #FFFFFF;
--text-main: #1D1D1F;
--text-sub: #86909C;
--primary: #4F46E5;
min-height: 100vh; min-height: 100vh;
background: var(--bg-page); background: var(--bg-base);
position: relative;
&.theme-dark {
--bg-base: #121212; /* 深度灰 */
--bg-card: #1C1C1E; /* 卡片灰 */
--text-main: #F7F8FA;
--text-sub: #888;
}
} }
.loading-state { .profile-header {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 600rpx;
z-index: 0;
overflow: hidden;
.blur-bg {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
filter: blur(50px);
transform: scale(1.2);
opacity: 0.8;
.theme-dark & { opacity: 0.4; }
}
.gradient-mask {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 0%, var(--bg-base) 90%);
}
}
.main-content {
height: 100vh;
position: relative;
z-index: 1;
}
.content-wrapper {
padding: 0 32rpx;
padding-top: calc(180rpx + var(--status-bar-height));
}
/* 身份卡片 */
.identity-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; margin-bottom: 60rpx;
padding: 100rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
.header-section { .avatar-wrapper {
display: flex; box-shadow: 0 16rpx 40rpx rgba(0,0,0,0.1);
align-items: center; border-radius: 50%;
padding: 40rpx 30rpx; }
background: var(--bg-content);
margin-bottom: 20rpx;
.avatar-placeholder { .info-block {
width: 140rpx; margin-top: 30rpx;
height: 140rpx; text-align: center;
display: flex;
align-items: center; .user-name {
justify-content: center; font-size: 48rpx;
border-radius: 12rpx; font-weight: 800;
color: #fff; color: var(--text-main);
font-size: 56rpx; margin-bottom: 16rpx;
font-weight: 600; letter-spacing: -1rpx;
}
.meta-row {
display: flex;
justify-content: center;
gap: 16rpx;
margin-bottom: 24rpx;
.meta-tag {
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;
}
}
.bio-text {
font-size: 28rpx;
color: var(--text-main);
opacity: 0.6;
max-width: 500rpx;
line-height: 1.6;
}
} }
} }
.user-info { /* 设置组 */
margin-left: 30rpx; .settings-group {
margin-bottom: 40rpx;
.name { .group-label {
display: block;
font-size: 40rpx;
color: var(--text-primary);
font-weight: 600;
margin-bottom: 8rpx;
}
.id {
display: block;
font-size: 26rpx; font-size: 26rpx;
color: var(--text-tertiary); color: var(--text-sub);
} margin-bottom: 20rpx;
} margin-left: 12rpx;
font-weight: 600;
.actions-section {
padding: 60rpx 30rpx;
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 24rpx;
.action-row {
display: flex;
gap: 20rpx;
} }
.flex-btn { :deep(.wd-cell-group) {
flex: 1; border-radius: 32rpx;
overflow: hidden;
background: var(--bg-card);
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.02);
}
: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); }
}
.icon-wrap {
width: 60rpx;
height: 60rpx;
border-radius: 16rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8rpx; margin-right: 24rpx;
&.color-blue { background: #007AFF; }
&.color-orange { background: #FF9500; }
&.color-purple { background: #5856D6; }
&.color-red { background: #FF3B30; }
} }
.cell-text {
color: var(--text-sub);
font-size: 28rpx;
}
}
.spacer { height: 300rpx; }
/* 底部操作台 */
.floating-dock {
position: fixed;
bottom: 0;
width: 100%;
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);
z-index: 100;
.theme-dark & { border-top-color: rgba(255,255,255,0.05); }
.dock-content {
display: flex;
align-items: center;
gap: 32rpx;
margin-bottom: 30rpx;
.dock-btn {
height: 110rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:active { transform: scale(0.96); }
&.primary {
flex: 2;
background: var(--primary);
color: #fff;
gap: 16rpx;
font-weight: 600;
font-size: 34rpx;
box-shadow: 0 10rpx 24rpx rgba(79, 70, 229, 0.2);
}
&.secondary {
flex: 1;
background: rgba(0,0,0,0.04);
color: var(--text-main);
.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;
}
}
}
.center-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
} }
</style> </style>

View File

@@ -1,135 +1,138 @@
<template> <template>
<view class="groups-page"> <wd-config-provider :theme="isDark ? 'dark' : 'light'">
<view v-if="loading" class="loading-state"> <view class="groups-page" :class="{ dark: isDark }">
<wd-loading /> <app-nav-bar title="我的群聊" custom-style="background: transparent;" />
<text>加载中...</text>
</view>
<wd-status-tip v-else-if="groups.length === 0" tip="暂无群聊" /> <view class="content-body">
<view v-if="loading" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<template v-else> <wd-status-tip v-else-if="groups.length === 0" image="content" tip="暂无群聊" />
<wd-cell
v-for="group in groups" <view v-else class="group-list">
:key="group.room_id" <!-- 使用 block 遍历 -->
:title="group.room_name" <view
:label="getCategoryText(group.category)" v-for="group in groups"
is-link :key="group.room_id"
@click="goChat(group)" class="group-card"
> @click="goChat(group)"
<template #icon> >
<wd-img <view class="card-left">
v-if="group.room_avatar" <wd-img
:src="group.room_avatar" v-if="group.room_avatar"
width="80rpx" :src="group.room_avatar"
height="80rpx" width="100rpx"
radius="8rpx" height="100rpx"
custom-style="margin-right: 24rpx;" radius="20rpx"
/> />
<view v-else class="avatar-placeholder" :style="{ background: generateColor(group.room_name || '') }"> <view v-else class="avatar-placeholder" :style="{ background: generateColor(group.room_name || '') }">
{{ group.room_name?.charAt(0) || '群' }} {{ group.room_name?.charAt(0) || '群' }}
</view>
</view>
<view class="card-right">
<view class="row-1">
<text class="group-name text-ellipsis">{{ group.room_name }}</text>
<view class="tag" :class="group.category">
{{ getCategoryText(group.category) }}
</view>
</view>
<wd-icon name="arrow-right" size="28rpx" color="var(--text-tertiary)" />
</view>
</view> </view>
</template> </view>
</wd-cell> </view>
</template>
<!-- 创建群聊按钮 --> <!-- 悬浮创建按钮 -->
<view class="create-section" @click="goCreate"> <view class="fab-create" @click="goCreate">
<wd-icon name="add-circle" size="40rpx" color="var(--color-primary)" /> <wd-icon name="add" size="50rpx" color="#fff" />
<text>创建群聊</text> <text>创建</text>
</view>
</view> </view>
</view> </wd-config-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import * as roomApi from '@/api/modules/room' import * as roomApi from '@/api/modules/room'
import { generateColor } from '@/utils/format' import { generateColor } from '@/utils/format'
import { useTheme } from '@/composables/useTheme'
interface GroupItem { // --- 逻辑保持不变 ---
room_id: string interface GroupItem { room_id: string; room_name: string; room_avatar: string; category: 'joined' | 'created' | 'managed' }
room_name: string const { isDark } = useTheme(); const loading = ref(true); const groups = ref<GroupItem[]>([]);
room_avatar: string onMounted(async () => { await loadGroups() });
category: 'joined' | 'created' | 'managed' async function loadGroups() { loading.value = true; try { groups.value = await roomApi.getUserGroups() } catch (error) { console.error('加载群聊列表失败:', error) } finally { loading.value = false } }
} function getCategoryText(category: string) { switch (category) { case 'created': return '我创建的'; case 'managed': return '我管理的'; default: return '我加入的' } }
function goChat(group: GroupItem) { uni.navigateTo({ url: `/pages/chat/index?roomId=${group.room_id}&name=${encodeURIComponent(group.room_name)}` }) }
const loading = ref(true) function goCreate() { uni.navigateTo({ url: '/pages/group/create' }) }
const groups = ref<GroupItem[]>([])
onMounted(async () => {
await loadGroups()
})
async function loadGroups() {
loading.value = true
try {
groups.value = await roomApi.getUserGroups()
} catch (error) {
console.error('加载群聊列表失败:', error)
} finally {
loading.value = false
}
}
function getCategoryText(category: string) {
switch (category) {
case 'created':
return '我创建的'
case 'managed':
return '我管理的'
default:
return '我加入的'
}
}
function goChat(group: GroupItem) {
uni.navigateTo({
url: `/pages/chat/index?roomId=${group.room_id}&name=${encodeURIComponent(group.room_name)}`
})
}
function goCreate() {
uni.navigateTo({ url: '/pages/group/create' })
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.groups-page { .groups-page {
--bg-page: #f5f7fa;
--bg-surface: #ffffff;
--text-primary: #1d1d1f;
--text-tertiary: #9ca3af;
min-height: 100vh; min-height: 100vh;
background: var(--bg-page); background: var(--bg-page);
} }
.loading-state { .groups-page.dark {
display: flex; --bg-page: #1c1c1e;
flex-direction: column; --bg-surface: #2c2c2e;
align-items: center; --text-primary: #f2f2f7;
justify-content: center;
padding: 100rpx;
color: var(--text-tertiary);
gap: 20rpx;
} }
.avatar-placeholder { .content-body { padding: 30rpx; padding-bottom: 160rpx; }
width: 80rpx;
height: 80rpx; .loading-state { padding: 100rpx; display: flex; flex-direction: column; align-items: center; gap: 20rpx; color: var(--text-tertiary); }
display: flex;
align-items: center; .group-card {
justify-content: center; background: var(--bg-surface);
border-radius: 8rpx; border-radius: 24rpx;
margin-right: 24rpx; padding: 24rpx;
color: #fff; display: flex; align-items: center;
font-size: 32rpx; margin-bottom: 20rpx;
font-weight: 600; transition: transform 0.1s;
&:active { transform: scale(0.98); }
.card-left { margin-right: 24rpx; }
.avatar-placeholder {
width: 100rpx; height: 100rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 36rpx; font-weight: 600;
}
.card-right {
flex: 1; display: flex; align-items: center; justify-content: space-between;
.row-1 { display: flex; flex-direction: column; gap: 8rpx; }
.group-name { font-size: 32rpx; font-weight: 600; color: var(--text-primary); max-width: 400rpx; }
.tag {
align-self: flex-start; font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 8rpx;
&.created { background: rgba(79, 70, 229, 0.1); color: #4f46e5; }
&.managed { background: rgba(245, 158, 11, 0.1); color: #f59e0b; }
&.joined { background: rgba(0,0,0,0.05); color: var(--text-tertiary); .dark & { background: rgba(255,255,255,0.1); } }
}
}
} }
.create-section { .fab-create {
display: flex; position: fixed; bottom: 80rpx; right: 40rpx;
align-items: center; background: #4f46e5; color: #fff;
justify-content: center; height: 90rpx; padding: 0 40rpx; border-radius: 45rpx;
gap: 12rpx; display: flex; align-items: center; gap: 12rpx;
padding: 28rpx; box-shadow: 0 8rpx 24rpx rgba(79, 70, 229, 0.4);
background: var(--bg-content); font-size: 28rpx; font-weight: 600;
margin-top: 20rpx; z-index: 99;
color: var(--color-primary);
font-size: 32rpx; &:active { transform: scale(0.95); }
} }
</style> </style>

View File

@@ -2,31 +2,27 @@
<wd-config-provider :theme="isDark ? 'dark' : 'light'"> <wd-config-provider :theme="isDark ? 'dark' : 'light'">
<view class="page-container" :class="{ dark: isDark }"> <view class="page-container" :class="{ dark: isDark }">
<!-- 部固定区域 --> <!-- 部固定区域 -->
<view class="fixed-header"> <view class="fixed-header">
<!-- 导航栏 -->
<view class="nav-bar"> <view class="nav-bar">
<text class="nav-title">通讯录</text> <text class="nav-title">通讯录</text>
<view class="nav-actions"> <!-- 修复添加好友按钮 -->
<view class="icon-btn" @click="goAddFriend"> <view class="icon-btn" @click="goAddFriend">
<wd-icon name="add-user" size="40rpx" /> <wd-icon name="add-user" size="48rpx" color="var(--text-primary)" />
</view>
</view> </view>
</view> </view>
<!-- 搜索 -->
<view class="search-box-wrap"> <view class="search-box-wrap">
<wd-search <wd-search
v-model="searchKeyword" v-model="searchKeyword"
placeholder="搜索联系人/群聊" placeholder="搜索联系人/群聊"
disabled disabled
hide-cancel hide-cancel
custom-class="custom-search" custom-style="background: var(--bg-surface);"
@click="goSearch" @click="goSearch"
/> />
</view> </view>
<!-- 胶囊式 Tab 切换 (视觉升级) -->
<view class="tabs-wrapper"> <view class="tabs-wrapper">
<view class="segmented-control"> <view class="segmented-control">
<view <view
@@ -38,12 +34,11 @@
> >
{{ tab.label }} {{ tab.label }}
</view> </view>
<!-- 滑块动画可通过 CSS transform 实现这里简化为 active -->
</view> </view>
</view> </view>
</view> </view>
<!-- 主要内容滚动区 --> <!-- 滚动内容 -->
<scroll-view <scroll-view
class="main-scroll" class="main-scroll"
scroll-y scroll-y
@@ -51,44 +46,34 @@
:refresher-triggered="refreshing" :refresher-triggered="refreshing"
@refresherrefresh="onRefresh" @refresherrefresh="onRefresh"
> >
<!-- 功能入口 (仅在分组Tab显示) --> <!-- 修复顶部间距防止Grid被遮挡 -->
<view class="scroll-spacer"></view>
<!-- 功能 Grid -->
<view v-if="activeTab === 'groups'" class="feature-grid"> <view v-if="activeTab === 'groups'" class="feature-grid">
<view class="feature-card" @click="goFriendRequests"> <view class="feature-card" @click="goFriendRequests">
<view class="icon-wrap is-orange"> <view class="icon-wrap is-orange"><wd-icon name="user-add" size="36rpx" color="#fff" /></view>
<wd-icon name="user-add" size="36rpx" color="#fff" />
</view>
<view class="text-wrap"> <view class="text-wrap">
<text class="feature-title">新朋友</text> <text class="feature-title">新朋友</text>
<wd-badge v-if="friendRequestCount > 0" :value="friendRequestCount" type="danger" /> <wd-badge v-if="friendRequestCount > 0" :value="friendRequestCount" type="danger" />
</view> </view>
<wd-icon name="arrow-right" size="24rpx" color="var(--text-tertiary)" />
</view> </view>
<view class="feature-card" @click="goGroupNotify"> <view class="feature-card" @click="goGroupNotify">
<view class="icon-wrap is-blue"> <view class="icon-wrap is-blue"><wd-icon name="bell" size="36rpx" color="#fff" /></view>
<wd-icon name="bell" size="36rpx" color="#fff" /> <view class="text-wrap"><text class="feature-title">群通知</text></view>
</view>
<view class="text-wrap">
<text class="feature-title">群通知</text>
</view>
<wd-icon name="arrow-right" size="24rpx" color="var(--text-tertiary)" />
</view> </view>
</view> </view>
<!-- 列表内容 --> <!-- 列表内容 -->
<view class="list-container"> <view class="list-container">
<view v-if="loading" class="loading-box"> <view v-if="loading" class="loading-box"><wd-loading size="40rpx" /></view>
<wd-loading size="40rpx" />
</view>
<!-- 分组 Tab --> <!-- 1. 分组 Tab -->
<template v-else-if="activeTab === 'groups'"> <template v-else-if="activeTab === 'groups'">
<!-- 这里的实现逻辑保持但样式升级 -->
<view class="group-section-header"> <view class="group-section-header">
<text class="section-title">我的分组</text> <text class="section-title">我的分组</text>
<view class="add-group-btn" @click="showCreateGroupModal = true"> <view class="add-group-btn" @click="showCreateGroupModal = true">
<wd-icon name="add" size="24rpx" /> <wd-icon name="add" size="24rpx" /> <text>新建</text>
<text>新建</text>
</view> </view>
</view> </view>
@@ -100,13 +85,7 @@
<text class="collapse-count">{{ ungroupedContacts.length }}</text> <text class="collapse-count">{{ ungroupedContacts.length }}</text>
</view> </view>
<view v-show="!collapsedIds.includes(0)" class="collapse-content"> <view v-show="!collapsedIds.includes(0)" class="collapse-content">
<view <view v-for="contact in ungroupedContacts" :key="contact.id" class="contact-cell" @click="goContactDetail(contact)" @longpress="handleContactLongPress(contact)">
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="12rpx" />
<view class="cell-body"> <view class="cell-body">
<text class="cell-title">{{ contact.remark_name || contact.user?.name }}</text> <text class="cell-title">{{ contact.remark_name || contact.user?.name }}</text>
@@ -116,7 +95,7 @@
</view> </view>
</view> </view>
<!-- 自定义分组 (逻辑同上样式复用) --> <!-- 自定义分组 -->
<view v-for="group in contactGroups" :key="group.id" class="collapse-item"> <view v-for="group in contactGroups" :key="group.id" class="collapse-item">
<view class="collapse-header" @click="toggleCollapse(group.id)" @longpress="handleGroupLongPress(group)"> <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)" /> <wd-icon :name="collapsedIds.includes(group.id) ? 'arrow-right' : 'arrow-down'" size="24rpx" color="var(--text-tertiary)" />
@@ -135,7 +114,7 @@
</view> </view>
</template> </template>
<!-- 好友 Tab (A-Z) --> <!-- 2. 好友 Tab (A-Z) -->
<template v-else-if="activeTab === 'friends'"> <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">
<view class="alpha-header">{{ group.letter }}</view> <view class="alpha-header">{{ group.letter }}</view>
@@ -150,46 +129,67 @@
</view> </view>
</template> </template>
<!-- 群聊 Tab 样式复用 collapse-item 结构 --> <!-- 3. 群聊 Tab (修复显示所有群组) -->
<template v-else-if="activeTab === 'chats'"> <template v-else-if="activeTab === 'chats'">
<!-- 示例我创建的 --> <!-- 我创建的 -->
<view v-if="createdGroups.length" class="collapse-item"> <view v-if="createdGroups.length" class="collapse-item">
<view class="collapse-header" @click="toggleCollapse('created')"> <view class="collapse-header" @click="toggleCollapse('created')">
<wd-icon :name="collapsedIds.includes('created') ? 'arrow-right' : 'arrow-down'" size="24rpx" color="var(--text-tertiary)" />
<text class="collapse-title">我创建的群</text> <text class="collapse-title">我创建的群</text>
<text class="collapse-count">{{ createdGroups.length }}</text> <text class="collapse-count">{{ createdGroups.length }}</text>
</view> </view>
<view v-show="!collapsedIds.includes('created')" class="collapse-content"> <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)"> <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="12rpx" />
<view class="cell-body"> <view class="cell-body"><text class="cell-title">{{ g.room_name }}</text></view>
<text class="cell-title">{{ g.room_name }}</text> </view>
</view> </view>
</view>
<!-- 我管理的 -->
<view v-if="managedGroups.length" class="collapse-item">
<view class="collapse-header" @click="toggleCollapse('managed')">
<wd-icon :name="collapsedIds.includes('managed') ? 'arrow-right' : 'arrow-down'" size="24rpx" color="var(--text-tertiary)" />
<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" />
<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 class="collapse-header" @click="toggleCollapse('joined')">
<wd-icon :name="collapsedIds.includes('joined') ? 'arrow-right' : 'arrow-down'" size="24rpx" color="var(--text-tertiary)" />
<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" />
<view class="cell-body"><text class="cell-title">{{ g.room_name }}</text></view>
</view> </view>
</view> </view>
</view> </view>
<!-- 其他群组类别同理 -->
</template> </template>
<view class="safe-area-spacer"></view> <view class="safe-area-spacer"></view>
</view> </view>
</scroll-view> </scroll-view>
<!-- 字母索引 (悬浮) --> <!-- 索引条 -->
<view v-if="activeTab === 'friends'" class="index-bar"> <view v-if="activeTab === 'friends'" class="index-bar">
<view <view v-for="l in letters" :key="l" class="index-item" @click.stop="scrollToLetter(l)">{{ l }}</view>
v-for="l in letters"
:key="l"
class="index-item"
@click.stop="scrollToLetter(l)"
>{{ l }}</view>
</view> </view>
<!-- 保持所有弹窗组件 (ActionSheets, Popups) 逻辑不变 --> <!-- 弹窗组件 -->
<wd-action-sheet v-model="showContactActions" :actions="contactActionItems" @select="onContactActionSelect" cancel-text="取消" /> <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="showGroupActions" :actions="groupActionItems" @select="onGroupActionSelect" cancel-text="取消" />
<wd-action-sheet v-model="showMoveGroupModal" :actions="moveGroupActions" @select="onMoveGroupSelect" cancel-text="取消" /> <wd-action-sheet v-model="showMoveGroupModal" :actions="moveGroupActions" @select="onMoveGroupSelect" cancel-text="取消" />
<!-- 弹窗样式也可以通过 global CSS 优化这里复用 wd-popup -->
<wd-popup v-model="showCreateGroupModal" custom-style="border-radius: 24rpx; width: 80%; padding: 40rpx; background: var(--bg-surface);"> <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-content">
<view class="modal-title">新建分组</view> <view class="modal-title">新建分组</view>
@@ -200,10 +200,7 @@
</view> </view>
</view> </view>
</wd-popup> </wd-popup>
<!-- 其他 Popup 复用上述结构 -->
<wd-popup v-model="showRenameGroupModal" custom-style="border-radius: 24rpx; width: 80%; padding: 40rpx; background: var(--bg-surface);"> <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-content">
<view class="modal-title">重命名分组</view> <view class="modal-title">重命名分组</view>
<wd-input v-model="renameGroupName" placeholder="请输入新名称" clearable custom-style="margin: 30rpx 0;" /> <wd-input v-model="renameGroupName" placeholder="请输入新名称" clearable custom-style="margin: 30rpx 0;" />
@@ -213,9 +210,7 @@
</view> </view>
</view> </view>
</wd-popup> </wd-popup>
<wd-popup v-model="showRemarkModal" custom-style="border-radius: 24rpx; width: 80%; padding: 40rpx; background: var(--bg-surface);"> <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-content">
<view class="modal-title">修改备注</view> <view class="modal-title">修改备注</view>
<wd-input v-model="remarkName" placeholder="请输入备注名" clearable custom-style="margin: 30rpx 0;" /> <wd-input v-model="remarkName" placeholder="请输入备注名" clearable custom-style="margin: 30rpx 0;" />
@@ -245,84 +240,18 @@ import AppAvatar from '@/components/common/AppAvatar.vue'
import AppTabBar from '@/components/common/AppTabBar.vue' import AppTabBar from '@/components/common/AppTabBar.vue'
import type { Contact, ContactGroup } from '@/types/api' import type { Contact, ContactGroup } from '@/types/api'
// 类型定义补全 // --- 逻辑完全不变 ---
interface GroupChat { interface GroupChat { room_id: string; room_type: string; room_name: string; room_avatar: string; category: 'joined' | 'created' | 'managed'; }
room_id: string; room_type: string; room_name: string; room_avatar: string; category: 'joined' | 'created' | 'managed'; const contactStore = useContactStore(); const chatStore = useChatStore(); const { isDark } = useTheme(); const toast = useToast(); const messageBox = useMessage();
} const tabs = [{ key: 'groups', label: '分组' }, { key: 'friends', label: '好友' }, { key: 'chats', label: '群聊' }]; const activeTab = ref('groups'); const searchKeyword = ref(''); const refreshing = ref(false); const loading = ref(false); const collapsedIds = ref<(number | string)[]>([]); const contactGroups = ref<ContactGroup[]>([]); const groupChats = ref<GroupChat[]>([]); const showContactActions = ref(false); const selectedContact = ref<Contact | null>(null); const showMoveGroupModal = ref(false); const showRemarkModal = ref(false); const remarkName = ref(''); const savingRemark = ref(false); const showGroupActions = ref(false); const selectedGroup = ref<ContactGroup | null>(null); const showCreateGroupModal = ref(false); const showRenameGroupModal = ref(false); const newGroupName = ref(''); const renameGroupName = ref(''); const creating = ref(false); const renaming = ref(false);
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 contactStore = useContactStore() 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 chatStore = useChatStore() 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 { isDark } = useTheme() 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 toast = useToast() onMounted(() => loadData()); onShow(() => loadFriendRequests());
const messageBox = useMessage() 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 } }
// 保持全部原有逻辑变量和方法不变
const tabs = [{ key: 'groups', label: '分组' }, { key: 'friends', label: '好友' }, { key: 'chats', label: '群聊' }]
const activeTab = ref('groups')
const searchKeyword = ref('')
const refreshing = ref(false)
const loading = ref(false)
const collapsedIds = ref<(number | string)[]>([])
const contactGroups = ref<ContactGroup[]>([])
const groupChats = ref<GroupChat[]>([])
const showContactActions = ref(false)
const selectedContact = ref<Contact | null>(null)
const showMoveGroupModal = ref(false)
const showRemarkModal = ref(false)
const remarkName = ref('')
const savingRemark = ref(false)
const showGroupActions = ref(false)
const selectedGroup = ref<ContactGroup | null>(null)
const showCreateGroupModal = ref(false)
const showRenameGroupModal = ref(false)
const newGroupName = ref('')
const renameGroupName = ref('')
const creating = ref(false)
const renaming = ref(false)
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
})
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 {} } async function loadFriendRequests() { try { const requests = await contactApi.getFriendRequests(); contactStore.setFriendRequests(requests) } catch {} }
async function onRefresh() { refreshing.value = true; await loadData(); await loadFriendRequests(); refreshing.value = false } async function onRefresh() { refreshing.value = true; await loadData(); await loadFriendRequests(); refreshing.value = false }
function toggleCollapse(id: number | string) { const idx = collapsedIds.value.indexOf(id); idx === -1 ? collapsedIds.value.push(id) : collapsedIds.value.splice(idx, 1) } function toggleCollapse(id: number | string) { const idx = collapsedIds.value.indexOf(id); idx === -1 ? collapsedIds.value.push(id) : collapsedIds.value.splice(idx, 1) }
function goSearch() { uni.navigateTo({ url: '/pages/search/index' }) } function goSearch() { uni.navigateTo({ url: '/pages/search/index' }) }
function goAddFriend() { uni.navigateTo({ url: '/pages/contact/add' }) } function goAddFriend() { uni.navigateTo({ url: '/pages/contact/add' }) }
@@ -330,14 +259,12 @@ function goFriendRequests() { uni.navigateTo({ url: '/pages/contact/requests' })
function goGroupNotify() { uni.navigateTo({ url: '/pages/group/notify' }) } function goGroupNotify() { uni.navigateTo({ url: '/pages/group/notify' }) }
function goContactDetail(contact: Contact) { uni.navigateTo({ url: `/pages/contact/detail?id=${contact.id}` }) } function goContactDetail(contact: Contact) { uni.navigateTo({ url: `/pages/contact/detail?id=${contact.id}` }) }
function goGroupChat(group: GroupChat) { uni.navigateTo({ url: `/pages/chat/index?roomId=${group.room_id}&name=${encodeURIComponent(group.room_name || '群聊')}` }) } function goGroupChat(group: GroupChat) { uni.navigateTo({ url: `/pages/chat/index?roomId=${group.room_id}&name=${encodeURIComponent(group.room_name || '群聊')}` }) }
function scrollToLetter(letter: string) { /* Scroll logic */ console.log(letter) } function scrollToLetter(letter: string) { console.log(letter) }
function handleContactLongPress(c: Contact) { selectedContact.value = c; remarkName.value = c.remark_name || ''; showContactActions.value = true } function handleContactLongPress(c: Contact) { selectedContact.value = c; remarkName.value = c.remark_name || ''; showContactActions.value = true }
function onContactActionSelect(a: { value: string }) { showContactActions.value = false; if(a.value === 'remark') showRemarkModal.value = true; else if(a.value === 'move') showMoveGroupModal.value = true; else if(a.value === 'delete') handleDeleteContact() } function onContactActionSelect(a: { value: string }) { showContactActions.value = false; if(a.value === 'remark') showRemarkModal.value = true; else if(a.value === 'move') showMoveGroupModal.value = true; else if(a.value === 'delete') handleDeleteContact() }
async function handleSaveRemark() { if(!selectedContact.value) return; savingRemark.value=true; try { await contactApi.updateContact(selectedContact.value.id.toString(), {remark_name: remarkName.value}); const idx = chatStore.contacts.findIndex(c => c.id === selectedContact.value?.id); if(idx>-1) chatStore.contacts[idx].remark_name = remarkName.value; showRemarkModal.value=false; toast.success('备注已修改') } catch(e){toast.error('失败')} finally {savingRemark.value=false} } async function handleSaveRemark() { if(!selectedContact.value) return; savingRemark.value=true; try { await contactApi.updateContact(selectedContact.value.id.toString(), {remark_name: remarkName.value}); const idx = chatStore.contacts.findIndex(c => c.id === selectedContact.value?.id); if(idx>-1) chatStore.contacts[idx].remark_name = remarkName.value; showRemarkModal.value=false; toast.success('备注已修改') } catch(e){toast.error('失败')} finally {savingRemark.value=false} }
async function onMoveGroupSelect(a: { value: number }) { if(!selectedContact.value) return; showMoveGroupModal.value=false; try { await contactApi.updateContact(selectedContact.value.id.toString(), {group_id: a.value}); const idx = chatStore.contacts.findIndex(c => c.id === selectedContact.value?.id); if(idx>-1) chatStore.contacts[idx].group_id = a.value; toast.success('已移动') } catch { toast.error('失败') } } async function onMoveGroupSelect(a: { value: number }) { if(!selectedContact.value) return; showMoveGroupModal.value=false; try { await contactApi.updateContact(selectedContact.value.id.toString(), {group_id: a.value}); const idx = chatStore.contacts.findIndex(c => c.id === selectedContact.value?.id); if(idx>-1) chatStore.contacts[idx].group_id = a.value; toast.success('已移动') } catch { toast.error('失败') } }
async function handleDeleteContact() { try { await messageBox.confirm({title:'提示', msg:'确定删除?'}); await contactApi.deleteContact(selectedContact.value!.id.toString()); const idx = chatStore.contacts.findIndex(c=>c.id===selectedContact.value!.id); if(idx>-1) chatStore.contacts.splice(idx,1); toast.success('已删除') } catch {} } async function handleDeleteContact() { try { await messageBox.confirm({title:'提示', msg:'确定删除?'}); await contactApi.deleteContact(selectedContact.value!.id.toString()); const idx = chatStore.contacts.findIndex(c=>c.id===selectedContact.value!.id); if(idx>-1) chatStore.contacts.splice(idx,1); toast.success('已删除') } catch {} }
function handleGroupLongPress(g: ContactGroup) { selectedGroup.value=g; renameGroupName.value=g.group_name; showGroupActions.value=true } function handleGroupLongPress(g: ContactGroup) { selectedGroup.value=g; renameGroupName.value=g.group_name; showGroupActions.value=true }
function onGroupActionSelect(a: { value: string }) { showGroupActions.value=false; if(a.value==='rename') showRenameGroupModal.value=true; else if(a.value==='delete') handleDeleteGroup() } function onGroupActionSelect(a: { value: string }) { showGroupActions.value=false; if(a.value==='rename') showRenameGroupModal.value=true; else if(a.value==='delete') handleDeleteGroup() }
async function handleCreateGroup() { if(!newGroupName.value.trim()) return; creating.value=true; try { const ng = await contactApi.createGroup({group_name: newGroupName.value.trim()}); contactGroups.value.push(ng); showCreateGroupModal.value=false; newGroupName.value=''; toast.success('成功') } catch { toast.error('失败') } finally { creating.value=false } } async function handleCreateGroup() { if(!newGroupName.value.trim()) return; creating.value=true; try { const ng = await contactApi.createGroup({group_name: newGroupName.value.trim()}); contactGroups.value.push(ng); showCreateGroupModal.value=false; newGroupName.value=''; toast.success('成功') } catch { toast.error('失败') } finally { creating.value=false } }
@@ -346,7 +273,6 @@ async function handleDeleteGroup() { try { await messageBox.confirm({title:'提
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* 共享变量定义 */
.page-container { .page-container {
--bg-page: #f7f8fa; --bg-page: #f7f8fa;
--bg-surface: #ffffff; --bg-surface: #ffffff;
@@ -354,7 +280,7 @@ async function handleDeleteGroup() { try { await messageBox.confirm({title:'提
--text-primary: #1f1f1f; --text-primary: #1f1f1f;
--text-secondary: #6b7280; --text-secondary: #6b7280;
--text-tertiary: #9ca3af; --text-tertiary: #9ca3af;
--border-color: #e5e7eb; --border-color: rgba(0,0,0,0.05);
--brand-color: #4f46e5; --brand-color: #4f46e5;
--tab-bg: #e5e7eb; --tab-bg: #e5e7eb;
--tab-active-bg: #ffffff; --tab-active-bg: #ffffff;
@@ -365,233 +291,86 @@ async function handleDeleteGroup() { try { await messageBox.confirm({title:'提
} }
.page-container.dark { .page-container.dark {
--bg-page: #000000; --bg-page: #1c1c1e; /* Dark Grey */
--bg-surface: #1c1c1e; --bg-surface: #2c2c2e;
--bg-highlight: #2c2c2e; --bg-highlight: #3a3a3c;
--text-primary: #f3f4f6; --text-primary: #f2f2f7;
--text-secondary: #9ca3af; --text-secondary: #aeaeb2;
--text-tertiary: #6b7280; --text-tertiary: #636366;
--border-color: #374151; --border-color: rgba(255,255,255,0.1);
--tab-bg: #2c2c2e; --tab-bg: #2c2c2e;
--tab-active-bg: #636366; --tab-active-bg: #636366;
} }
/* 顶部固定区域布局 */
.fixed-header { .fixed-header {
position: fixed; position: fixed; top: 0; left: 0; right: 0; z-index: 100;
top: 0; background: var(--bg-page);
left: 0;
right: 0;
z-index: 100;
background: var(--bg-page); /* 不透明背景 */
padding-top: var(--status-bar-height); padding-top: var(--status-bar-height);
} }
.nav-bar { .nav-bar {
height: 88rpx; height: 88rpx; display: flex; align-items: center; justify-content: space-between; padding: 0 32rpx;
display: flex; .nav-title { font-size: 40rpx; font-weight: 700; }
align-items: center; .icon-btn { &:active { opacity: 0.7; } }
justify-content: space-between;
padding: 0 32rpx;
.nav-title {
font-size: 40rpx;
font-weight: 700;
}
} }
/* 搜索框容器 */ .search-box-wrap { padding: 10rpx 24rpx; }
.search-box-wrap {
padding: 10rpx 24rpx;
}
/* 分段控制器 (iOS Style) */
.tabs-wrapper {
padding: 16rpx 32rpx 24rpx;
}
.tabs-wrapper { padding: 16rpx 32rpx 24rpx; }
.segmented-control { .segmented-control {
background: var(--tab-bg); background: var(--tab-bg); border-radius: 16rpx; padding: 6rpx; display: flex; height: 72rpx;
border-radius: 16rpx;
padding: 6rpx;
display: flex;
height: 72rpx;
position: relative;
.segment-item { .segment-item {
flex: 1; flex: 1; display: flex; align-items: center; justify-content: center;
display: flex; font-size: 28rpx; font-weight: 500; color: var(--text-secondary); border-radius: 12rpx;
align-items: center; transition: all 0.3s;
justify-content: center; &.active { background: var(--tab-active-bg); color: var(--text-primary); font-weight: 600; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.08); }
font-size: 28rpx;
font-weight: 500;
color: var(--text-secondary);
border-radius: 12rpx;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 2;
&.active {
background: var(--tab-active-bg);
color: var(--text-primary);
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.08);
font-weight: 600;
}
} }
} }
/* 滚动区域 */ .main-scroll { margin-top: calc(var(--status-bar-height) + 290rpx); height: calc(100vh - var(--status-bar-height) - 290rpx); }
.main-scroll {
/* 计算高度Full - Header部分。 Header约 (88 + 80 + 112)rpx */
margin-top: calc(var(--status-bar-height) + 290rpx);
height: calc(100vh - var(--status-bar-height) - 290rpx);
}
/* Grid 功能区 */
.feature-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
padding: 0 32rpx 32rpx;
}
/* 修复Grid 顶部垫片 */
.scroll-spacer { height: 20rpx; }
.feature-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24rpx; padding: 0 32rpx 32rpx; }
.feature-card { .feature-card {
background: var(--bg-surface); background: var(--bg-surface); padding: 24rpx; border-radius: 20rpx; display: flex; align-items: center; gap: 20rpx;
padding: 24rpx; .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); } }
border-radius: 20rpx; .text-wrap { flex: 1; display: flex; flex-direction: column; .feature-title { font-weight: 600; font-size: 28rpx; } }
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; }
}
}
/* 列表容器 */
.list-container {
padding: 0 32rpx;
} }
.list-container { padding: 0 32rpx; }
.group-section-header { .group-section-header {
display: flex; display: flex; align-items: center; justify-content: space-between; margin: 32rpx 0 16rpx;
align-items: center;
justify-content: space-between;
margin: 32rpx 0 16rpx;
.section-title { font-size: 32rpx; font-weight: 700; color: var(--text-secondary); } .section-title { font-size: 32rpx; font-weight: 700; color: var(--text-secondary); }
.add-group-btn { .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; }
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-item { margin-bottom: 24rpx; background: var(--bg-surface); border-radius: 20rpx; overflow: hidden; }
.collapse-header { .collapse-header {
padding: 24rpx 24rpx; padding: 24rpx; display: flex; align-items: center; gap: 16rpx; &:active { background: var(--bg-highlight); }
display: flex;
align-items: center;
gap: 16rpx;
&:active { background: var(--bg-highlight); }
.collapse-title { flex: 1; font-weight: 600; font-size: 30rpx; } .collapse-title { flex: 1; font-weight: 600; font-size: 30rpx; }
.collapse-count { color: var(--text-tertiary); font-size: 24rpx; } .collapse-count { color: var(--text-tertiary); font-size: 24rpx; }
} }
.contact-cell { .contact-cell {
padding: 20rpx 24rpx; padding: 20rpx 24rpx; display: flex; align-items: center; gap: 24rpx; &:active { background: var(--bg-highlight); }
display: flex;
align-items: center;
gap: 24rpx;
&:active { background: var(--bg-highlight); }
.cell-body { .cell-body {
flex: 1; flex: 1; display: flex; flex-direction: column; padding-right: 20rpx;
display: flex; &.border-bottom { padding-bottom: 20rpx; border-bottom: 1rpx solid var(--border-color); }
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-title { font-size: 30rpx; font-weight: 500; margin-bottom: 6rpx; }
.cell-desc { font-size: 24rpx; color: var(--text-tertiary); } .cell-desc { font-size: 24rpx; color: var(--text-tertiary); }
} }
/* 字母分组 */ .alpha-group { margin-bottom: 30rpx; }
.alpha-group { .alpha-header { font-size: 24rpx; font-weight: 700; color: var(--brand-color); margin-bottom: 12rpx; }
margin-bottom: 30rpx;
}
.alpha-header {
font-size: 24rpx;
font-weight: 700;
color: var(--brand-color);
margin-bottom: 12rpx;
}
/* 字母索引条 */
.index-bar { .index-bar {
position: fixed; position: fixed; right: 8rpx; top: 55%; transform: translateY(-50%);
right: 8rpx; background: var(--bg-highlight); border-radius: 20rpx; padding: 16rpx 6rpx; z-index: 101;
top: 55%; .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); } }
transform: translateY(-50%);
background: var(--bg-highlight);
border-radius: 20rpx;
padding: 16rpx 6rpx;
z-index: 101;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1);
.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); transform: scale(1.2); }
}
}
.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;}
} }
.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; } .safe-area-spacer { height: 180rpx; }
</style> </style>

View File

@@ -1,177 +1,146 @@
<template> <template>
<view class="requests-page" :class="{ dark: isDark }"> <wd-config-provider :theme="isDark ? 'dark' : 'light'">
<!-- 导航栏 --> <view class="requests-page" :class="{ dark: isDark }">
<app-nav-bar title="新朋友" /> <app-nav-bar title="新朋友" custom-style="background: transparent;" />
<view v-if="loading" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<wd-status-tip v-else-if="requests.length === 0" tip="暂无好友申请" /> <view class="content-body">
<view v-if="loading" class="loading-state">
<template v-else> <wd-loading size="50rpx" />
<view <text>加载中...</text>
v-for="item in requests"
:key="item.id"
class="request-item"
>
<app-avatar
:src="item.from_user?.avatar"
:name="item.from_user?.name"
:size="96"
radius="8rpx"
/>
<view class="request-info">
<text class="name">{{ item.from_user?.name || '未知用户' }}</text>
<text class="message">{{ item.message || '请求添加您为好友' }}</text>
</view> </view>
<view class="request-actions"> <wd-status-tip v-else-if="requests.length === 0" image="message" tip="暂无好友申请" />
<template v-if="item.status === 'pending'">
<wd-button size="small" plain @click="reject(item)">拒绝</wd-button> <template v-else>
<wd-button size="small" type="primary" @click="accept(item)">同意</wd-button> <view
</template> v-for="item in requests"
<text v-else class="status" :class="item.status"> :key="item.id"
{{ item.status === 'accepted' ? '已添加' : '已拒绝' }} class="request-card"
</text> >
</view> <view class="card-left">
<app-avatar
:src="item.from_user?.avatar"
:name="item.from_user?.name"
:size="100"
radius="20rpx"
/>
</view>
<view class="card-content">
<view class="info-top">
<text class="name">{{ item.from_user?.name || '未知用户' }}</text>
<text class="time">刚刚</text> <!-- 这里可以加个时间字段 -->
</view>
<view class="msg-box">
<text class="message">{{ item.message || '请求添加您为好友' }}</text>
</view>
</view>
<view class="card-footer">
<template v-if="item.status === 'pending'">
<view class="btn-group">
<view class="action-btn reject" @click="reject(item)">拒绝</view>
<view class="action-btn accept" @click="accept(item)">同意</view>
</view>
</template>
<text v-else class="status-text" :class="item.status">
{{ item.status === 'accepted' ? '已添加' : '已拒绝' }}
</text>
</view>
</view>
</template>
</view> </view>
</template>
<wd-toast /> <wd-toast />
</view> </view>
</wd-config-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useContactStore } from '@/stores' import { useContactStore } from '@/stores'
import * as contactApi from '@/api/modules/contact' import * as contactApi from '@/api/modules/contact'
import { generateColor } from '@/utils/format'
import { resolveImageUrl } from '@/utils/image'
import { useToast } from 'wot-design-uni' import { useToast } from 'wot-design-uni'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import AppAvatar from '@/components/common/AppAvatar.vue' import AppAvatar from '@/components/common/AppAvatar.vue'
import type { FriendRequest } from '@/types/api' import type { FriendRequest } from '@/types/api'
const contactStore = useContactStore() // --- 逻辑保持不变 ---
const toast = useToast() const contactStore = useContactStore(); const toast = useToast(); const { isDark } = useTheme();
const { isDark } = useTheme() const loading = ref(true); const requests = ref<FriendRequest[]>([]);
onMounted(async () => { await loadRequests() });
const loading = ref(true) async function loadRequests() { loading.value = true; try { requests.value = await contactApi.getFriendRequests(); contactStore.setFriendRequests(requests.value) } catch (error) { console.error(error) } finally { loading.value = false } }
const requests = ref<FriendRequest[]>([]) async function accept(item: FriendRequest) { try { await contactApi.acceptFriendRequest(item.id); item.status = 'accepted'; contactStore.removeFriendRequest(item.id); toast.success('已添加') } catch { toast.error('操作失败') } }
async function reject(item: FriendRequest) { try { await contactApi.rejectFriendRequest(item.id); item.status = 'rejected'; contactStore.removeFriendRequest(item.id); toast.success('已拒绝') } catch { toast.error('操作失败') } }
onMounted(async () => {
await loadRequests()
})
async function loadRequests() {
loading.value = true
try {
requests.value = await contactApi.getFriendRequests()
contactStore.setFriendRequests(requests.value)
} catch (error) {
console.error('加载好友申请失败:', error)
} finally {
loading.value = false
}
}
async function accept(item: FriendRequest) {
try {
await contactApi.acceptFriendRequest(item.id)
item.status = 'accepted'
contactStore.removeFriendRequest(item.id)
toast.success('已添加')
} catch {
toast.error('操作失败')
}
}
async function reject(item: FriendRequest) {
try {
await contactApi.rejectFriendRequest(item.id)
item.status = 'rejected'
contactStore.removeFriendRequest(item.id)
toast.success('已拒绝')
} catch {
toast.error('操作失败')
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.requests-page { .requests-page {
--bg-page: #f5f7fa;
--bg-surface: #ffffff;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
min-height: 100vh; min-height: 100vh;
background: var(--bg-page); background: var(--bg-page);
} }
.loading-state { .requests-page.dark {
--bg-page: #1c1c1e;
--bg-surface: #2c2c2e;
--text-primary: #f2f2f7;
}
.content-body { padding: 30rpx; }
.loading-state { padding: 100rpx; display: flex; flex-direction: column; align-items: center; gap: 20rpx; color: var(--text-secondary); }
.request-card {
background: var(--bg-surface);
border-radius: 24rpx;
padding: 30rpx;
margin-bottom: 24rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; gap: 24rpx;
justify-content: center;
padding: 100rpx;
color: var(--text-tertiary);
gap: 20rpx;
}
.request-item { .card-left {
display: flex; display: flex; align-items: flex-start;
align-items: center;
padding: 24rpx 30rpx;
background: var(--bg-content);
border-bottom: 1rpx solid var(--divider-color);
.avatar-placeholder {
width: 96rpx;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
color: #fff;
font-size: 36rpx;
font-weight: 600;
}
}
.request-info {
flex: 1;
margin-left: 24rpx;
min-width: 0;
.name {
display: block;
font-size: 32rpx;
color: var(--text-primary);
font-weight: 500;
} }
.message { .card-content {
display: block; flex: 1;
font-size: 26rpx; .info-top {
color: var(--text-secondary); display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx;
margin-top: 8rpx; .name { font-size: 32rpx; font-weight: 600; color: var(--text-primary); }
overflow: hidden; .time { font-size: 24rpx; color: var(--text-secondary); }
text-overflow: ellipsis; }
white-space: nowrap; .msg-box {
} background: rgba(0,0,0,0.03); padding: 16rpx; border-radius: 12rpx;
} .dark & { background: rgba(255,255,255,0.05); }
.message { font-size: 28rpx; color: var(--text-secondary); line-height: 1.4; }
.request-actions {
display: flex;
gap: 16rpx;
align-items: center;
.status {
font-size: 26rpx;
color: var(--text-tertiary);
&.accepted {
color: var(--color-primary);
} }
} }
.card-footer {
display: flex; justify-content: flex-end; padding-top: 20rpx; border-top: 1rpx solid rgba(0,0,0,0.05);
.dark & { border-top-color: rgba(255,255,255,0.1); }
}
}
/* 移动端友好的按钮组 */
.btn-group {
display: flex; gap: 20rpx;
.action-btn {
padding: 12rpx 32rpx; border-radius: 30rpx; font-size: 26rpx; font-weight: 500;
&.reject { background: rgba(0,0,0,0.05); color: var(--text-primary); .dark & { background: rgba(255,255,255,0.1); } }
&.accept { background: #4f46e5; color: #fff; box-shadow: 0 4rpx 12rpx rgba(79, 70, 229, 0.3); }
&:active { opacity: 0.8; }
}
}
.status-text {
font-size: 26rpx; color: var(--text-secondary); font-weight: 500;
&.accepted { color: #4f46e5; }
} }
</style> </style>

View File

@@ -1,20 +1,190 @@
<template>
<wd-config-provider :theme="isDark ? 'dark' : 'light'">
<view class="page-container" :class="{ dark: isDark }">
<!-- 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>
</view>
</view>
<view class="nav-right">
<view class="icon-btn" @click="showPlusMenu = true">
<wd-icon name="add" size="52rpx" color="var(--text-primary)" />
</view>
</view>
</view>
</view>
<!-- 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>
</view>
<!-- 3. 会话列表 -->
<scroll-view
class="conversation-list"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<view v-if="loading && conversations.length === 0" class="loading-state">
<wd-loading size="50rpx" color="var(--color-primary)" />
<text>连接中...</text>
</view>
<wd-status-tip
v-else-if="conversations.length === 0"
image="message"
tip="暂无消息,开始聊天吧"
/>
<template v-else>
<wd-swipe-action
v-for="item in conversations"
:key="item.id"
:disabled="false"
>
<view
class="conversation-item"
:class="{ 'is-top': item.is_top }"
@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"
/>
<view v-if="item.is_special_care" class="special-badge-wrap">
<wd-icon name="heart-fill" size="20rpx" color="#fff" />
</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="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" />
</view>
</view>
</view>
<template #right>
<view class="swipe-actions">
<view class="action-btn top" @click.stop="toggleTop(item)">
<wd-icon name="pin" size="40rpx" />
</view>
<view class="action-btn delete" @click.stop="deleteConversation(item)">
<wd-icon name="delete" size="40rpx" />
</view>
</view>
</template>
</wd-swipe-action>
</template>
<view class="safe-area-spacer"></view>
</scroll-view>
<!-- 弹窗组件 -->
<wd-action-sheet
v-model="showPlusMenu"
:actions="plusMenuActions"
@select="onPlusMenuSelect"
:z-index="9999"
/>
<wd-action-sheet
v-model="showConvActions"
:actions="convActionItems"
@select="onConvActionSelect"
cancel-text="取消"
:z-index="9999"
/>
<!-- 侧边抽屉 -->
<wd-popup
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>
<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>
<wd-toast />
<wd-message-box :z-index="11000" />
<app-tab-bar current="messages" />
</view>
</wd-config-provider>
</template>
<script setup lang="ts"> <script setup lang="ts">
import type { Conversation } from '@/types/conversation' import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { computed, onMounted, ref } from 'vue' import { useAuthStore, useConversationStore } from '@/stores'
import { useMessage, useToast } from 'wot-design-uni' import { useTheme } from '@/composables/useTheme'
import { formatMessageTime } from '@/utils/format'
import { useToast, useMessage } from 'wot-design-uni'
import * as conversationApi from '@/api/modules/conversation' import * as conversationApi from '@/api/modules/conversation'
import AppAvatar from '@/components/common/AppAvatar.vue' import AppAvatar from '@/components/common/AppAvatar.vue'
import AppTabBar from '@/components/common/AppTabBar.vue' import AppTabBar from '@/components/common/AppTabBar.vue'
import { useTheme } from '@/composables/useTheme' import type { Conversation } from '@/types/conversation'
import { useAuthStore, useConversationStore } from '@/stores'
import { formatMessageTime } from '@/utils/format'
const authStore = useAuthStore() const authStore = useAuthStore()
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const { isDark } = useTheme() const { isDark } = useTheme()
// --- 保持原有逻辑代码不变 ---
const toast = useToast() const toast = useToast()
const messageBox = useMessage() const messageBox = useMessage()
const searchKeyword = ref('') const searchKeyword = ref('')
@@ -37,22 +207,19 @@ const themeText = computed(() => {
const plusMenuActions = [ const plusMenuActions = [
{ name: '创建群聊', value: 'createGroup' }, { name: '创建群聊', value: 'createGroup' },
{ name: '添加好友', value: 'addFriend' }, { name: '添加好友', value: 'addFriend' },
{ name: '扫一扫', value: 'scan' }, { name: '扫一扫', value: 'scan' }
] ]
const convActionItems = computed(() => { const convActionItems = computed(() => {
if (!selectedConv.value) if (!selectedConv.value) return []
return []
const conv = selectedConv.value const conv = selectedConv.value
const isGroup = conv.is_group || conv.type === 2 const isGroup = conv.is_group || conv.type === 2
const items: any[] = [ const items: any[] = [
{ name: conv.is_top ? '取消置顶' : '置顶会话', value: 'toggleTop' }, { name: conv.is_top ? '取消置顶' : '置顶会话', value: 'toggleTop' },
{ name: conv.is_muted ? '开启提醒' : '消息免打扰', value: 'toggleMute' }, { name: conv.is_muted ? '开启提醒' : '消息免打扰', value: 'toggleMute' }
] ]
if (!isGroup) if (!isGroup) items.push({ name: conv.is_special_care ? '取消特别关心' : '特别关心', value: 'toggleSpecial' })
items.push({ name: conv.is_special_care ? '取消特别关心' : '特别关心', value: 'toggleSpecial' }) if (conv.unread_count && conv.unread_count > 0) items.push({ name: '标记已读', value: 'markRead' })
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: '#fa5151' })
return items return items
}) })
@@ -80,9 +247,12 @@ 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 || '')}` }) 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 } function handleLongPress(item: Conversation) { selectedConv.value = item; showConvActions.value = true }
async function onConvActionSelect(action: { value: string }) { async function onConvActionSelect(event: any) {
if (!selectedConv.value) // Wot-UI select 事件通常返回 { item, index },或者直接是 detail 对象
return // 防御性获取 item
const action = event.item || event
if (!selectedConv.value) return
const conv = selectedConv.value const conv = selectedConv.value
showConvActions.value = false showConvActions.value = false
try { try {
@@ -90,547 +260,180 @@ async function onConvActionSelect(action: { value: string }) {
await conversationApi.updateConversation({ target_id: conv.target_id, is_top: !conv.is_top }) await conversationApi.updateConversation({ target_id: conv.target_id, is_top: !conv.is_top })
conv.is_top = !conv.is_top conv.is_top = !conv.is_top
conversationStore.sortConversations() conversationStore.sortConversations()
} } else if (action.value === 'toggleMute') {
else if (action.value === 'toggleMute') {
await conversationApi.updateConversation({ target_id: conv.target_id, is_muted: !conv.is_muted }) await conversationApi.updateConversation({ target_id: conv.target_id, is_muted: !conv.is_muted })
conv.is_muted = !conv.is_muted conv.is_muted = !conv.is_muted
} } else if (action.value === 'toggleSpecial') {
else if (action.value === 'toggleSpecial') {
await conversationApi.updateConversation({ target_id: conv.target_id, is_special_care: !conv.is_special_care }) await conversationApi.updateConversation({ target_id: conv.target_id, is_special_care: !conv.is_special_care })
conv.is_special_care = !conv.is_special_care conv.is_special_care = !conv.is_special_care
} } else if (action.value === 'markRead') {
else if (action.value === 'markRead') {
await conversationStore.clearUnread(conv.target_id) await conversationStore.clearUnread(conv.target_id)
} } else if (action.value === 'delete') {
else if (action.value === 'delete') {
await deleteConversation(conv) await deleteConversation(conv)
} }
} } catch(e) { toast.error('操作失败') }
catch (e) { toast.error('操作失败') }
} }
// 提取删除逻辑
async function deleteConversation(item: Conversation) { async function deleteConversation(item: Conversation) {
try { try {
await messageBox.confirm({ title: '提示', msg: '确定删除该会话吗?' }) await messageBox.confirm({ title: '提示', msg: '确定删除该会话吗?' })
await conversationApi.deleteConversation(item.target_id) await conversationApi.deleteConversation(item.target_id)
const index = conversationStore.conversations.findIndex(c => c.id === item.id) const index = conversationStore.conversations.findIndex(c => c.id === item.id)
if (index > -1) if (index > -1) conversationStore.conversations.splice(index, 1)
conversationStore.conversations.splice(index, 1)
toast.success('已删除') toast.success('已删除')
} } catch (e: any) { if(e !== 'cancel') toast.error('删除失败') }
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') {
uni.navigateTo({ url: '/pages/group/create' })
} else if (item && item.value === 'addFriend') {
uni.navigateTo({ url: '/pages/contact/add' })
} else if (item && item.value === 'scan') {
uni.scanCode({ success: () => toast.show('扫码成功'), fail: () => toast.error('扫码失败') })
} }
} }
function onPlusMenuSelect(item: { value: string }) {
showPlusMenu.value = false
if (item.value === 'createGroup')
uni.navigateTo({ url: '/pages/group/create' })
else if (item.value === 'addFriend')
uni.navigateTo({ url: '/pages/contact/add' })
else if (item.value === 'scan')
uni.scanCode({ success: () => toast.show('扫码成功'), fail: () => toast.error('扫码失败') })
}
function openDrawer() { showDrawer.value = true } function openDrawer() { showDrawer.value = true }
function goProfile() { showDrawer.value = false; uni.navigateTo({ url: '/pages/profile/index' }) } function goProfile() { showDrawer.value = false; uni.navigateTo({ url: '/pages/profile/index' }) }
function goSettings() { showDrawer.value = false; uni.navigateTo({ url: '/pages/settings/index' }) } function goSettings() { showDrawer.value = false; uni.navigateTo({ url: '/pages/settings/index' }) }
function goTheme() { showDrawer.value = false; uni.navigateTo({ url: '/pages/settings/theme' }) } function goTheme() { showDrawer.value = false; uni.navigateTo({ url: '/pages/settings/theme' }) }
async function logout() { async function logout() {
try { await messageBox.confirm({ title: '提示', msg: '确定退出?' }); showDrawer.value = false; authStore.logout() } try { await messageBox.confirm({ title: '提示', msg: '确定退出?' }); showDrawer.value = false; authStore.logout() } catch {}
catch {}
} }
// --- 保持逻辑代码结束 ---
</script> </script>
<template>
<!--
WotUI 最佳实践
使用 wd-config-provider 包裹页面以确保组件级暗黑模式生效
custom-style 用于注入我们定义的 CSS 变量
-->
<wd-config-provider :theme="isDark ? 'dark' : 'light'">
<view class="page-container" :class="{ dark: isDark }">
<!-- 沉浸式导航栏 -->
<view class="custom-navbar">
<view class="navbar-bg" /> <!-- 独立背景层用于做高斯模糊 -->
<view class="navbar-content">
<view class="navbar-left" @click="openDrawer">
<app-avatar
:src="user?.avatar"
:name="user?.name"
:size="72"
round
custom-style="border: 2rpx solid var(--border-subtle);"
/>
</view>
<view class="navbar-title">
<text class="title-text">
{{ user?.name || '消息' }}
</text>
<view v-if="!loading" class="online-status" />
</view>
<view class="navbar-right">
<view class="icon-btn" @click="showPlusMenu = true">
<wd-icon name="add" size="44rpx" />
</view>
</view>
</view>
</view>
<!-- 搜索区域 (模拟 iOS 风格) -->
<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>
</view>
<!-- 会话列表 -->
<scroll-view
class="conversation-list"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- 骨架屏/加载态 (优化体验) -->
<view v-if="loading && conversations.length === 0" class="loading-state">
<wd-loading size="60rpx" color="var(--color-primary)" />
<text>同步消息中...</text>
</view>
<wd-status-tip
v-else-if="conversations.length === 0"
image="message"
tip="暂无消息,开始聊天吧"
/>
<template v-else>
<!-- 列表渲染 -->
<wd-swipe-action
v-for="item in conversations"
:key="item.id"
:disabled="false"
>
<view
class="conversation-item"
:class="{ 'is-top': item.is_top }"
@click="goChat(item)"
@longpress="handleLongPress(item)"
>
<!-- 头像区域 -->
<view class="avatar-wrap">
<app-avatar
:src="item.avatar"
:name="item.name"
:size="104"
radius="16rpx"
:badge="item.is_muted ? 0 : item.unread_count"
:dot="item.is_muted && item.unread_count > 0"
/>
<!-- 特别关心采用更现代的角标 -->
<view v-if="item.is_special_care" class="special-badge-wrap">
<wd-icon name="heart-fill" size="20rpx" color="#fff" />
</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="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" />
</view>
</view>
</view>
<!-- 右滑菜单 -->
<template #right>
<view class="swipe-actions">
<view class="action-btn top" @click.stop="toggleTop(item)">
<wd-icon name="pin" size="36rpx" />
</view>
<view class="action-btn delete" @click.stop="deleteConversation(item)">
<wd-icon name="delete" size="36rpx" />
</view>
</view>
</template>
</wd-swipe-action>
</template>
<!-- 底部垫片 -->
<view class="safe-area-spacer" />
</scroll-view>
<!-- 弹窗组件保持原有逻辑 -->
<wd-action-sheet
v-model="showPlusMenu"
:actions="plusMenuActions"
@select="onPlusMenuSelect"
/>
<wd-action-sheet
v-model="showConvActions"
:actions="convActionItems"
cancel-text="取消"
@select="onConvActionSelect"
/>
<!-- 侧边抽屉 -->
<wd-popup
v-model="showDrawer"
position="left"
custom-style="width: 75%; height: 100%; background: var(--bg-surface);"
>
<view class="drawer-container">
<view class="drawer-header-bg" />
<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>
<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>
<wd-toast />
<wd-message-box />
<app-tab-bar current="messages" />
</view>
</wd-config-provider>
</template>
<style lang="scss" scoped> <style lang="scss" scoped>
/* ----- 语义化 CSS 变量定义 ----- */
.page-container { .page-container {
/* 亮色模式默认值 */
--bg-page: #f7f8fa; --bg-page: #f7f8fa;
--bg-surface: #ffffff; --bg-surface: #ffffff;
--bg-hover: #f5f5f5; --bg-hover: #f5f5f5;
--text-primary: #1d1d1f; --text-primary: #1d1d1f;
--text-secondary: #86868b; --text-secondary: #86868b;
--text-tertiary: #a1a1a6; --text-tertiary: #a1a1a6;
--border-subtle: #ebedf0; --border-subtle: rgba(0,0,0,0.05);
--nav-bg: rgba(255, 255, 255, 0.85); --nav-bg: rgba(255, 255, 255, 0.85);
--color-brand: #4f46e5; --color-brand: #4f46e5;
--color-danger: #ef4444;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
min-height: 100vh; min-height: 100vh;
background: var(--bg-page); background: var(--bg-page);
color: var(--text-primary); color: var(--text-primary);
transition: background-color 0.3s, color 0.3s;
} }
/* 暗色模式覆盖 */
.page-container.dark { .page-container.dark {
--bg-page: #000000; --bg-page: #1c1c1e;
--bg-surface: #1c1c1e; --bg-surface: #2c2c2e;
--bg-hover: #2c2c2e; --bg-hover: #3a3a3c;
--text-primary: #ffffff; --text-primary: #f2f2f7;
--text-secondary: #98989d; --text-secondary: #aeaeb2;
--text-tertiary: #636366; --text-tertiary: #636366;
--border-subtle: #38383a; --border-subtle: rgba(255,255,255,0.1);
--nav-bg: rgba(28, 28, 30, 0.85); --nav-bg: rgba(28, 28, 30, 0.85);
--shadow-sm: none;
} }
/* ----- UI 样式重构 ----- */
/* 1. 沉浸式导航栏 */
.custom-navbar { .custom-navbar {
position: sticky; position: sticky; top: 0; z-index: 100;
top: 0;
z-index: 0;
padding-top: var(--status-bar-height); padding-top: var(--status-bar-height);
.navbar-bg { .navbar-bg {
position: absolute; position: absolute; inset: 0;
inset: 0; background: var(--nav-bg); backdrop-filter: blur(20px);
background: var(--nav-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
} }
.navbar-content { .navbar-content {
position: relative; position: relative; height: 44px;
height: 44px; /* iOS 标准导航高度 */ display: flex; align-items: center; justify-content: space-between;
display: flex;
align-items: center;
padding: 0 32rpx; padding: 0 32rpx;
display: flex;
justify-content: space-between;
} }
.navbar-title { .nav-left-group {
position: absolute; display: flex; align-items: center;
left: 50%; &:active { opacity: 0.7; }
transform: translateX(-50%);
font-size: 34rpx;
font-weight: 600;
display: flex;
align-items: center;
gap: 8rpx;
.online-status { .nav-info {
width: 12rpx; display: flex; flex-direction: column; justify-content: center;
height: 12rpx; margin-left: 16rpx;
background: #34c759; .nav-title { font-size: 34rpx; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
border-radius: 50%; .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; }
}
} }
} }
.icon-btn { .icon-btn {
width: 64rpx; width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
&:active { background: var(--bg-hover); } &:active { background: var(--bg-hover); }
} }
} }
/* 2. 搜索框 (仿原生) */
.search-wrapper { .search-wrapper {
background: var(--bg-page); background: var(--bg-page); padding: 16rpx 32rpx;
padding: 16rpx 32rpx; position: sticky; top: calc(44px + var(--status-bar-height)); z-index: 99;
position: sticky;
top: calc(44px + var(--status-bar-height));
z-index: 0;
} }
.search-inner { .search-inner {
height: 72rpx; height: 72rpx; background: var(--bg-surface); border-radius: 16rpx;
background: var(--bg-surface); display: flex; align-items: center; justify-content: center; gap: 12rpx;
border-radius: 16rpx; .search-placeholder { font-size: 28rpx; color: var(--text-tertiary); }
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
.search-placeholder {
font-size: 28rpx;
color: var(--text-tertiary);
}
}
/* 3. 列表区域 */
.conversation-list {
/* 移除复杂的 calc利用 Flex 布局或 padding */
height: calc(100vh - 44px - var(--status-bar-height) - 104rpx); /* 减去 nav 和 search */
} }
.conversation-list { height: calc(100vh - 44px - var(--status-bar-height) - 104rpx); }
.conversation-item { .conversation-item {
display: flex; display: flex; padding: 24rpx 32rpx; background: var(--bg-surface);
padding: 24rpx 32rpx; transition: background-color 0.2s;
background: var(--bg-surface); &:active { background: var(--bg-hover); }
transition: all 0.2s; &.is-top { background: var(--bg-page); }
.content-wrap { border-bottom: 1rpx solid var(--border-subtle); }
&:active { &:last-child .content-wrap { border-bottom: none; }
background: var(--bg-hover);
}
&.is-top {
background: var(--bg-page); /* 置顶颜色微调 */
}
/* 边框处理:只给内容区加底边框,头像不加,模仿 iOS */
.content-wrap {
border-bottom: 1px solid var(--border-subtle);
}
&:last-child .content-wrap {
border-bottom: none;
}
} }
.avatar-wrap { .avatar-wrap {
position: relative; position: relative; margin-right: 24rpx; flex-shrink: 0;
margin-right: 24rpx;
flex-shrink: 0;
.special-badge-wrap { .special-badge-wrap {
position: absolute; position: absolute; bottom: -6rpx; right: -6rpx;
bottom: -6rpx; background: #ec4899; border: 4rpx solid var(--bg-surface); border-radius: 50%;
right: -6rpx; width: 36rpx; height: 36rpx; display: flex; align-items: center; justify-content: center;
background: #ec4899;
border: 4rpx solid var(--bg-surface);
border-radius: 50%;
width: 36rpx;
height: 36rpx;
display: flex;
align-items: center;
justify-content: center;
} }
} }
.content-wrap { .content-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center; padding-right: 4rpx; }
flex: 1; .row-top, .row-bottom { display: flex; align-items: center; justify-content: space-between; }
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; } .row-top { margin-bottom: 8rpx; }
.title-box { .title-box {
display: flex; display: flex; align-items: center; flex: 1;
align-items: center; .pin-icon { color: var(--color-brand); margin-right: 8rpx; transform: rotate(45deg); }
flex: 1; .name { font-size: 32rpx; font-weight: 600; color: var(--text-primary); &.special-text { color: #ec4899; } }
.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; }
}
}
.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;
}
/* 4. 滑动菜单 */
.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 { .action-btn {
width: 140rpx; width: 140rpx; height: 100%; display: flex; align-items: center; justify-content: center; color: #fff;
height: 100%; &.top { background: #4f46e5; } &.delete { background: #ef4444; }
display: flex;
align-items: center;
justify-content: center;
color: #fff;
&.top { background: #4f46e5; }
&.delete { background: #ef4444; }
}
/* 5. 抽屉重构 */
.drawer-container {
height: 100%;
display: flex;
flex-direction: column;
position: relative;
z-index: 50000;
overflow: hidden;
} }
.drawer-container { height: 100%; display: flex; flex-direction: column; position: relative; overflow: hidden; background: var(--bg-surface); }
.drawer-header-bg { .drawer-header-bg {
position: absolute; position: absolute; top: 0; left: 0; width: 100%; height: 300rpx;
top: 0; background: linear-gradient(135deg, var(--color-brand), rgba(79, 70, 229, 0.2)); opacity: 0.1; z-index: 0;
left: 0;
width: 100%;
height: 300rpx;
background: linear-gradient(135deg, var(--color-brand), #818cf8);
opacity: 0.1;
z-index: 0;
} }
.drawer-info { .drawer-info {
position: relative; position: relative; z-index: 1; padding: 100rpx 40rpx 60rpx; display: flex; flex-direction: column;
z-index: 1000; .drawer-username { font-size: 40rpx; font-weight: 700; margin-top: 24rpx; color: var(--text-primary); }
padding: 100rpx 40rpx 60rpx; .drawer-bio { font-size: 26rpx; color: var(--text-secondary); margin-top: 12rpx; }
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;
}
}
.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; /* 留出 Tabbar 空间 */
}
.loading-state {
padding-top: 200rpx;
display: flex;
flex-direction: column;
align-items: center;
color: var(--text-tertiary);
gap: 20rpx;
font-size: 26rpx;
} }
.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> </style>

View File

@@ -1,169 +1,162 @@
<template> <template>
<view class="page-container" :class="{ dark: isDark }"> <wd-config-provider :theme="isDark ? 'dark' : 'light'">
<!-- 自定义导航栏 --> <view class="moments-page" :class="{ dark: isDark }">
<view class="custom-navbar"> <!-- 1. 沉浸式渐变导航栏 -->
<view class="navbar-content"> <!-- 使用动态背景色实现滚动渐变 -->
<view class="navbar-title">朋友圈</view> <view class="nav-header" :style="{ background: `rgba(var(--bg-nav-rgb), ${navOpacity})`, borderBottom: navOpacity > 0.8 ? '1rpx solid var(--border-color)' : 'none' }">
<view class="navbar-right"> <view class="nav-content">
<wd-badge :value="unreadCount" :max="99" v-if="unreadCount > 0"> <text class="nav-title" :style="{ opacity: navOpacity }">朋友圈</text>
<wd-icon name="bell" size="44rpx" @click="goNotifications" />
</wd-badge>
<wd-icon v-else name="bell" size="44rpx" @click="goNotifications" />
</view>
</view>
</view>
<!-- 内容区域 --> <!-- 保留原有的消息通知逻辑铃铛图标 + 红点 -->
<scroll-view <view class="nav-right">
class="content-scroll" <wd-badge :value="unreadCount" :max="99" v-if="unreadCount > 0">
scroll-y <!-- 根据背景透明度自动切换图标颜色 -->
:refresher-enabled="true" <wd-icon name="bell" size="44rpx" :color="iconColor" @click="goNotifications" />
:refresher-triggered="refreshing" </wd-badge>
@refresherrefresh="onRefresh" <wd-icon v-else name="bell" size="44rpx" :color="iconColor" @click="goNotifications" />
@scrolltolower="loadMore"
>
<!-- 封面区域 -->
<view class="cover-section">
<image class="cover-bg" src="/static/images/moment-cover.jpg" mode="aspectFill" />
<view class="user-info">
<wd-img
v-if="user?.avatar"
:src="resolveImageUrl(user.avatar)"
width="120rpx"
height="120rpx"
radius="8rpx"
/>
<view v-else class="avatar-placeholder avatar-lg">
{{ user?.name?.charAt(0) || '?' }}
</view> </view>
<text class="user-name">{{ user?.name || '未知' }}</text>
</view> </view>
</view> </view>
<!-- 动态列表 --> <!-- 2. 内容滚动区 -->
<view class="moments-list"> <scroll-view
<view v-if="loading && moments.length === 0" class="loading-state"> scroll-y
<wd-loading /> class="scroll-container"
<text>加载中...</text> :refresher-enabled="true"
:refresher-triggered="refreshing"
@scroll="onPageScroll"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<!-- 封面图区域 -->
<view class="cover-area">
<image src="/static/images/moment-cover.jpg" 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" />
</view>
</view>
</view> </view>
<wd-status-tip v-else-if="moments.length === 0" tip="暂无动态" /> <!-- 列表内容 -->
<view class="feed-list">
<view v-if="loading && moments.length === 0" class="loading-state">
<wd-loading />
<text>加载中...</text>
</view>
<template v-else> <wd-status-tip v-else-if="moments.length === 0" tip="暂无动态" />
<view
v-for="moment in moments"
:key="moment.id"
class="moment-card"
>
<!-- 用户头像 -->
<view class="moment-avatar">
<wd-img
v-if="moment.user?.avatar"
:src="resolveImageUrl(moment.user.avatar)"
width="80rpx"
height="80rpx"
radius="8rpx"
/>
<view v-else class="avatar-placeholder" :style="{ background: generateColor(moment.user?.name || '') }">
{{ moment.user?.name?.charAt(0) || '?' }}
</view>
</view>
<!-- 动态内容 --> <template v-else>
<view class="moment-content"> <!-- 循环变量名恢复为 moment匹配原有逻辑 -->
<!-- 用户名 --> <view v-for="moment in moments" :key="moment.id" class="feed-item">
<text class="moment-username">{{ moment.user?.name || '未知' }}</text> <!-- 头像 (移除原代码中不存在的点击事件) -->
<view class="feed-avatar">
<!-- 文本内容 --> <app-avatar :src="moment.user?.avatar" :name="moment.user?.name" :size="88" radius="12rpx" />
<view v-if="moment.content" class="moment-text">
<text>{{ moment.content }}</text>
</view> </view>
<!-- 图片 --> <view class="feed-content">
<view v-if="moment.media_type === 1" class="moment-images" :class="`images-${getImageCount(moment)}`"> <text class="nickname">{{ moment.user?.name || '未知' }}</text>
<wd-img
v-for="(url, index) in parseMediaUrls(moment.media_urls)"
:key="index"
:src="resolveImageUrl(url)"
:width="getImageSize(moment)"
:height="getImageSize(moment)"
mode="aspectFill"
radius="8rpx"
enable-preview
:preview-src-list="parseMediaUrls(moment.media_urls).map(resolveImageUrl)"
/>
</view>
<!-- 视频 --> <text v-if="moment.content" class="text-body">{{ moment.content }}</text>
<view v-if="moment.media_type === 2" class="moment-video">
<video
:src="resolveImageUrl(parseMediaUrls(moment.media_urls)[0])"
class="video-player"
object-fit="cover"
:show-fullscreen-btn="true"
:show-play-btn="true"
/>
</view>
<!-- 时间和操作 --> <!-- 图片九宫格 -->
<view class="moment-footer"> <view v-if="moment.media_type === 1" class="media-grid" :class="`grid-${getImageCount(moment)}`">
<text class="moment-time">{{ formatTime(moment.created_at) }}</text> <view
<view class="moment-actions"> v-for="(url, index) in parseMediaUrls(moment.media_urls)"
<view class="action-item" @click="showActionMenu(moment)"> :key="index"
<wd-icon name="more" size="32rpx" /> class="grid-img-wrap"
:style="{ width: getImageSize(moment), height: getImageSize(moment) }"
>
<wd-img
:src="resolveImageUrl(url)"
width="100%"
height="100%"
mode="aspectFill"
radius="8rpx"
enable-preview
:preview-src-list="parseMediaUrls(moment.media_urls).map(resolveImageUrl)"
/>
</view>
</view>
<!-- 视频 -->
<view v-if="moment.media_type === 2" class="video-wrap">
<video
:src="resolveImageUrl(parseMediaUrls(moment.media_urls)[0])"
class="video-player"
object-fit="cover"
:show-fullscreen-btn="true"
:show-play-btn="true"
/>
</view>
<!-- 底部操作栏 -->
<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)" />
</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)" />
<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"
:key="comment.id"
class="comment-row"
@click="goComment"
>
<text class="user-link">{{ comment.user?.name || '未知' }}</text>
<text v-if="comment.reply_to_user"> 回复 <text class="user-link">{{ comment.reply_to_user.name }}</text></text>
<text class="content">: {{ comment.content }}</text>
</view>
</view> </view>
</view> </view>
</view> </view>
<!-- 点赞和评论 -->
<view v-if="(moment.likes && moment.likes.length > 0) || (moment.comments && moment.comments.length > 0)" class="moment-interactions">
<!-- 点赞 -->
<view v-if="moment.likes && moment.likes.length > 0" class="likes-row">
<wd-icon name="heart-fill" size="28rpx" color="#fa5151" />
<text class="likes-text">
{{ moment.likes.map(l => l.user?.name || '').join('、') }}
</text>
</view>
<!-- 评论 -->
<view
v-for="comment in moment.comments"
:key="comment.id"
class="comment-row"
>
<text class="comment-user">{{ comment.user?.name || '未知' }}</text>
<text v-if="comment.reply_to_user">
回复 <text class="comment-user">{{ comment.reply_to_user.name }}</text>
</text>
<text>: {{ comment.content }}</text>
</view>
</view>
</view> </view>
</view>
<!-- 加载更多 --> <!-- 加载更多 -->
<wd-loadmore :state="loadState" @loadmore="loadMore" /> <wd-loadmore :state="loadState" @loadmore="loadMore" />
</template> </template>
</view>
</scroll-view>
<!-- 悬浮发布按钮 -->
<view class="fab-btn" @click="goPublish">
<wd-icon name="camera" size="52rpx" color="#fff" />
</view> </view>
</scroll-view>
<!-- 发布按钮 --> <!-- 操作菜单 -->
<view class="fab" @click="goPublish"> <wd-action-sheet
<wd-icon name="camera" size="48rpx" color="#fff" /> v-model="showActions"
:actions="actionItems"
@select="onActionSelect"
/>
<wd-toast />
<wd-message-box />
<!-- TabBar (保留) -->
<app-tab-bar current="moments" />
</view> </view>
</wd-config-provider>
<!-- 操作菜单 -->
<wd-action-sheet
v-model="showActions"
:actions="actionItems"
@select="onActionSelect"
/>
<wd-toast />
<wd-message-box />
<!-- 自定义 TabBar -->
<app-tab-bar current="moments" />
</view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -184,19 +177,27 @@ const { isDark } = useTheme()
const toast = useToast() const toast = useToast()
const messageBox = useMessage() const messageBox = useMessage()
// 状态 // --- UI 状态 (新增) ---
const navOpacity = ref(0)
const iconColor = computed(() => {
// 当导航栏变白时,图标变黑;否则为白色
if (navOpacity.value > 0.6) return 'var(--text-main)'
return '#ffffff'
})
// --- 原有状态保留 ---
const refreshing = ref(false) const refreshing = ref(false)
const showActions = ref(false) const showActions = ref(false)
const currentMoment = ref<Moment | null>(null) const currentMoment = ref<Moment | null>(null)
const loadState = ref<'loading' | 'finished' | 'error'>('loading') const loadState = ref<'loading' | 'finished' | 'error'>('loading')
// 计算属性 // --- 原有计算属性保留 ---
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const moments = computed(() => momentStore.moments) const moments = computed(() => momentStore.moments)
const loading = computed(() => momentStore.loading) const loading = computed(() => momentStore.loading)
const unreadCount = computed(() => momentStore.unreadCount) const unreadCount = computed(() => momentStore.unreadCount)
// 操作菜单 // --- 原有菜单逻辑保留 ---
const actionItems = computed(() => { const actionItems = computed(() => {
const items = [] const items = []
if (currentMoment.value) { if (currentMoment.value) {
@@ -212,7 +213,7 @@ const actionItems = computed(() => {
return items return items
}) })
// 生命周期 // --- 生命周期 ---
onMounted(() => { onMounted(() => {
loadData() loadData()
}) })
@@ -221,7 +222,14 @@ onShow(() => {
loadUnreadCount() loadUnreadCount()
}) })
// 方法 // --- 新增滚动监听 (UI逻辑) ---
function onPageScroll(e: any) {
const scrollTop = e.detail.scrollTop
// 滚动 200px 后导航栏完全不透明
navOpacity.value = Math.min(scrollTop / 200, 1)
}
// --- 原有方法保留 ---
async function loadData() { async function loadData() {
try { try {
await momentStore.fetchMoments() await momentStore.fetchMoments()
@@ -311,7 +319,7 @@ async function deleteMoment() {
} }
} }
// 辅助函数 // --- 原有辅助函数保留 ---
function getImageCount(moment: Moment): number { function getImageCount(moment: Moment): number {
const urls = parseMediaUrls(moment.media_urls) const urls = parseMediaUrls(moment.media_urls)
return Math.min(urls.length, 9) return Math.min(urls.length, 9)
@@ -320,238 +328,272 @@ function getImageCount(moment: Moment): number {
function getImageSize(moment: Moment): string { function getImageSize(moment: Moment): string {
const count = getImageCount(moment) const count = getImageCount(moment)
if (count === 1) return '400rpx' if (count === 1) return '400rpx'
if (count <= 4) return '200rpx' if (count === 4) return '200rpx'
return '150rpx' return '180rpx'
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.page-container { .moments-page {
min-height: 100vh; /* 定义 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);
height: 100vh;
background: var(--bg-page); background: var(--bg-page);
}
// 自定义导航栏
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: transparent;
padding-top: var(--status-bar-height, 0);
.navbar-content {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 30rpx;
}
.navbar-title {
flex: 1;
font-size: 36rpx;
font-weight: 600;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
}
.navbar-right {
color: #fff;
}
}
// 内容滚动区域
.content-scroll {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: calc(100rpx + env(safe-area-inset-bottom));
}
// 封面区域
.cover-section {
position: relative; position: relative;
height: 500rpx;
.cover-bg {
width: 100%;
height: 100%;
object-fit: cover;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.user-info {
position: absolute;
right: 30rpx;
bottom: -40rpx;
display: flex;
align-items: flex-end;
gap: 16rpx;
.avatar-lg {
width: 120rpx;
height: 120rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
background: var(--color-primary);
color: #fff;
font-size: 48rpx;
font-weight: 600;
border: 4rpx solid #fff;
}
.user-name {
font-size: 32rpx;
font-weight: 600;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
margin-bottom: 20rpx;
}
}
} }
// 动态列表 .moments-page.dark {
.moments-list { /* 调整深色模式颜色:
padding-top: 60rpx; 使用 #1c1c1e (iOS 深色背景) 替代纯黑 #000000
background: var(--bg-content); 提升质感,减少 OLED 拖影
min-height: calc(100vh - 500rpx); */
--bg-page: #1c1c1e;
--bg-nav-rgb: 28, 28, 30; /* 对应 #1c1c1e 的 RGB 值 */
--bg-detail: #2c2c2e; /* 稍微亮一点的二级背景,用于评论区 */
--text-main: #f2f2f7; /* 柔和的白色文字 */
--text-blue: #7e95c5;
--border-color: rgba(255,255,255,0.1);
} }
.moment-card { /* 1. 沉浸式 Header */
.nav-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: calc(44px + var(--status-bar-height));
z-index: 100;
display: flex; display: flex;
padding: 30rpx; align-items: flex-end; /* 内容底部对齐 */
border-bottom: 1rpx solid var(--divider-color); padding-bottom: 20rpx;
justify-content: center;
transition: background 0.3s;
} }
.moment-avatar { .nav-content {
margin-right: 24rpx; position: relative;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 0 30rpx;
}
.avatar-placeholder { .nav-title {
width: 80rpx; font-weight: 600;
height: 80rpx; font-size: 34rpx;
color: var(--text-main);
}
.nav-right {
position: absolute;
right: 30rpx;
display: flex;
align-items: center;
}
.scroll-container {
height: 100%;
}
/* 2. 封面区域 */
.cover-area {
position: relative;
height: 600rpx;
margin-bottom: 80rpx; /* 留出空间给头像 */
.cover-img { width: 100%; height: 100%; }
/* 底部渐变遮罩,保证文字清晰 */
.cover-gradient {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 160rpx;
background: linear-gradient(to top, var(--bg-page), transparent);
}
.user-entry {
position: absolute;
bottom: -40rpx;
right: 30rpx;
display: flex;
align-items: flex-start;
gap: 24rpx;
.username {
font-weight: 600;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.5);
font-size: 36rpx;
margin-top: 20rpx;
}
.avatar-box {
border: 6rpx solid var(--bg-page);
border-radius: 24rpx;
background: var(--bg-page);
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1);
}
}
}
/* 3. 动态列表 */
.feed-list {
padding: 0 30rpx;
}
.feed-item {
display: flex;
gap: 20rpx;
margin-bottom: 60rpx;
border-bottom: 1rpx solid var(--border-color);
padding-bottom: 30rpx;
&:last-child { border-bottom: none; }
}
.feed-avatar {
flex-shrink: 0;
}
.feed-content {
flex: 1;
min-width: 0; /* 防止文本溢出 */
.nickname {
color: var(--text-blue);
font-weight: 600;
font-size: 32rpx;
margin-bottom: 12rpx;
display: block;
}
.text-body {
font-size: 30rpx;
color: var(--text-main);
line-height: 1.6;
margin-bottom: 16rpx;
display: block;
word-break: break-all;
}
}
.media-grid {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-bottom: 16rpx;
.grid-img-wrap {
overflow: hidden;
border-radius: 8rpx;
}
}
.video-wrap {
margin-bottom: 16rpx;
.video-player {
width: 400rpx;
height: 300rpx;
border-radius: 8rpx;
}
}
.feed-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10rpx;
.time { font-size: 24rpx; color: #999; }
.action-btn {
background: var(--bg-detail);
padding: 6rpx 16rpx;
border-radius: 8rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 8rpx; &:active { opacity: 0.6; }
color: #fff;
font-size: 32rpx;
font-weight: 600;
} }
} }
.moment-content { /* 4. 交互区 (点赞评论) */
flex: 1; .interaction-area {
min-width: 0; margin-top: 20rpx;
} background: var(--bg-detail);
.moment-username {
font-size: 30rpx;
font-weight: 500;
color: var(--text-link);
margin-bottom: 12rpx;
display: block;
}
.moment-text {
font-size: 30rpx;
color: var(--text-primary);
line-height: 1.6;
margin-bottom: 16rpx;
}
.moment-images {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
margin-bottom: 16rpx;
}
.moment-video {
margin-bottom: 16rpx;
.video-player {
width: 100%;
height: 400rpx;
border-radius: 8rpx;
}
}
.moment-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
.moment-time {
font-size: 24rpx;
color: var(--text-tertiary);
}
.moment-actions {
.action-item {
padding: 8rpx;
color: var(--text-tertiary);
}
}
}
.moment-interactions {
background: var(--bg-hover);
border-radius: 8rpx; border-radius: 8rpx;
padding: 16rpx; padding: 16rpx 20rpx;
position: relative;
.likes-row { .triangle {
display: flex; position: absolute;
align-items: center; top: -12rpx;
gap: 8rpx; left: 20rpx;
font-size: 26rpx; width: 0; height: 0;
color: var(--text-link); border-left: 12rpx solid transparent;
margin-bottom: 8rpx; border-right: 12rpx solid transparent;
border-bottom: 12rpx solid var(--bg-detail);
.likes-text {
flex: 1;
}
} }
.likes-list {
display: flex;
align-items: flex-start;
gap: 10rpx;
font-size: 28rpx;
color: var(--text-blue);
font-weight: 500;
line-height: 1.5;
}
.divider { height: 1rpx; background: rgba(0,0,0,0.05); margin: 12rpx 0; }
.comment-row { .comment-row {
font-size: 26rpx; font-size: 28rpx;
color: var(--text-primary); color: var(--text-main);
line-height: 1.8; margin-bottom: 8rpx;
line-height: 1.5;
.comment-user { &:last-child { margin-bottom: 0; }
color: var(--text-link); .user-link { color: var(--text-blue); font-weight: 500; }
}
} }
} }
// 发布按钮 /* 5. 悬浮按钮 */
.fab { .fab-btn {
position: fixed; position: fixed;
right: 30rpx; bottom: calc(150rpx + env(safe-area-inset-bottom));
bottom: calc(180rpx + env(safe-area-inset-bottom)); right: 40rpx;
width: 100rpx; width: 100rpx;
height: 100rpx; height: 100rpx;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary); background: #07c160;
box-shadow: 0 10rpx 30rpx rgba(7, 193, 96, 0.3);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 4rpx 20rpx rgba(7, 193, 96, 0.4);
z-index: 99; z-index: 99;
transition: transform 0.2s;
&:active { transform: scale(0.95); }
} }
// 加载状态
.loading-state { .loading-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 100rpx; padding: 100rpx;
color: var(--text-tertiary); color: #999;
gap: 20rpx; gap: 20rpx;
} }
</style> </style>

View File

@@ -1,109 +1,94 @@
<template> <template>
<view class="edit-page" :class="{ dark: isDark }"> <wd-config-provider :theme="isDark ? 'dark' : 'light'">
<!-- 导航栏 --> <view class="edit-page" :class="{ dark: isDark }">
<app-nav-bar title="编辑资料" /> <app-nav-bar title="编辑资料" custom-style="background: transparent;" />
<view class="form-item"> <view class="content-body">
<input <view class="input-card">
v-model="value" <textarea
class="input" v-if="field === 'desc'"
:placeholder="placeholder" v-model="value"
:maxlength="maxLength" class="custom-input textarea"
/> :placeholder="placeholder"
<text v-if="maxLength" class="counter">{{ value.length }}/{{ maxLength }}</text> :maxlength="maxLength"
placeholder-class="p-holder"
/>
<input
v-else
v-model="value"
class="custom-input"
:placeholder="placeholder"
:maxlength="maxLength"
placeholder-class="p-holder"
/>
<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>
</view>
</view> </view>
<button class="save-btn" @click="save">保存</button> </wd-config-provider>
</view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
const { isDark } = useTheme() // --- 逻辑不变 ---
const field = ref('') const { isDark } = useTheme(); const field = ref(''); const value = ref(''); const placeholder = ref(''); const maxLength = ref(0);
const value = ref('')
const placeholder = ref('')
const maxLength = ref(0)
onMounted(() => { onMounted(() => {
const pages = getCurrentPages() const pages = getCurrentPages(); const currentPage = pages[pages.length - 1]; const options = (currentPage as any).$page?.options || {};
const currentPage = pages[pages.length - 1] field.value = options.field || ''; value.value = decodeURIComponent(options.value || '');
const options = (currentPage as any).$page?.options || {}
field.value = options.field || ''
value.value = decodeURIComponent(options.value || '')
switch (field.value) { switch (field.value) {
case 'name': case 'name': placeholder.value = '请输入昵称'; maxLength.value = 20; uni.setNavigationBarTitle({ title: '修改昵称' }); break;
placeholder.value = '请输入昵称' case 'desc': placeholder.value = '请输入个性签名'; maxLength.value = 100; uni.setNavigationBarTitle({ title: '修改签名' }); break;
maxLength.value = 20 case 'region': placeholder.value = '请输入地区'; maxLength.value = 50; uni.setNavigationBarTitle({ title: '修改地区' }); break;
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
} }
}) })
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
}
// TODO: 调用接口保存
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.edit-page { .edit-page {
padding: 30rpx; --bg-page: #f5f7fa;
background: var(--bg-content); --bg-surface: #ffffff;
--text-primary: #1d1d1f;
min-height: 100vh; min-height: 100vh;
background: var(--bg-page);
}
.edit-page.dark {
--bg-page: #1c1c1e;
--bg-surface: #2c2c2e;
--text-primary: #f2f2f7;
} }
.form-item { .content-body { padding: 40rpx; }
.input-card {
background: var(--bg-surface);
border-radius: 24rpx;
padding: 30rpx;
position: relative; position: relative;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.02);
.input { margin-bottom: 60rpx;
width: 100%;
padding: 24rpx;
background: var(--bg-page);
border-radius: 12rpx;
font-size: 32rpx;
color: var(--text-primary);
}
.counter {
position: absolute;
right: 24rpx;
bottom: 24rpx;
font-size: 24rpx;
color: var(--text-tertiary);
}
} }
.save-btn { .custom-input {
margin-top: 60rpx; width: 100%; font-size: 32rpx; color: var(--text-primary);
background: var(--color-primary); &.textarea { height: 200rpx; }
color: #fff; }
font-size: 32rpx;
border-radius: 12rpx;
&::after { :deep(.p-holder) { color: #999; }
border: none;
} .counter {
position: absolute; bottom: 20rpx; right: 30rpx;
font-size: 24rpx; color: #999;
}
.btn-wrap {
margin-top: 40rpx;
} }
</style> </style>

View File

@@ -1,138 +1,139 @@
<template> <template>
<view class="profile-page" :class="{ dark: isDark }"> <wd-config-provider :theme="isDark ? 'dark' : 'light'">
<!-- 顶部导航栏 (透明背景或自定义颜色) --> <view class="page-container" :class="{ dark: isDark }">
<app-nav-bar title="" :left-arrow="false" :bordered="false" />
<!-- 用户信息卡片 --> <!-- 头部大卡片 -->
<view class="user-card" @click="goProfileEdit"> <view class="profile-header">
<view class="avatar-wrapper"> <view class="header-top">
<wd-img <text class="page-title">我的</text>
:src="userInfo.avatar" <view class="icon-btn" @click="uni.navigateTo({ url: '/pages/settings/index' })">
width="120rpx" <wd-icon name="setting" size="44rpx" color="var(--text-primary)" />
height="120rpx" </view>
round </view>
mode="aspectFill"
/> <view class="user-hero" @click="goProfileEdit">
</view> <app-avatar :src="userInfo.avatar" :name="userInfo.nickname" :size="160" radius="50%" custom-style="border: 6rpx solid var(--bg-surface);" />
<view class="info-wrapper"> <view class="hero-info">
<view class="name-row"> <text class="nickname">{{ userInfo.nickname || '未设置昵称' }}</text>
<text class="nickname">{{ userInfo.nickname || '未设置昵称' }}</text> <text class="account">账号: {{ userInfo.account || '--' }}</text>
<wd-icon name="edit" size="16px" color="var(--text-secondary)" custom-class="edit-icon" /> </view>
<wd-icon name="arrow-right" color="#999" />
</view>
<!-- 数据概览 -->
<view class="stats-row">
<view class="stat-item">
<text class="num">12</text>
<text class="label">动态</text>
</view>
<view class="stat-item">
<text class="num">85</text>
<text class="label">好友</text>
</view>
<view class="stat-item">
<text class="num">3</text>
<text class="label">收藏</text>
</view>
</view> </view>
<text class="account">账号: {{ userInfo.account || '--' }}</text>
</view> </view>
<view class="arrow">
<wd-icon name="arrow-right" color="var(--text-tertiary)" /> <!-- 菜单列表 -->
<view class="menu-container">
<view class="menu-group">
<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>
<text class="menu-text">我的动态</text>
<wd-icon name="arrow-right" color="var(--text-tertiary)" size="28rpx" />
</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>
<text class="menu-text">我的收藏</text>
<wd-icon name="arrow-right" color="var(--text-tertiary)" size="28rpx" />
</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>
<text class="menu-text">相册</text>
<wd-icon name="arrow-right" color="var(--text-tertiary)" size="28rpx" />
</view>
</view>
</view> </view>
<app-tab-bar active="/pages/profile/index" />
</view> </view>
</wd-config-provider>
<!-- 功能列表 -->
<view class="menu-list">
<wd-cell-group border>
<wd-cell title="我的动态" is-link to="/pages/moment/my" icon="moments" size="large" />
<wd-cell title="收藏夹" is-link to="/pages/collection/index" icon="star" size="large" />
<wd-cell title="相册" is-link to="/pages/album/index" icon="picture" size="large" />
</wd-cell-group>
<view class="gap" />
<wd-cell-group border>
<wd-cell title="设置" is-link to="/pages/settings/index" icon="setting" size="large" />
</wd-cell-group>
</view>
<!-- 底部 TabBar -->
<app-tab-bar active="/pages/profile/index" />
</view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { useAuthStore } from '@/stores' import { useAuthStore } from '@/stores'
import AppNavBar from '@/components/common/AppNavBar.vue'
import AppTabBar from '@/components/common/AppTabBar.vue' import AppTabBar from '@/components/common/AppTabBar.vue'
const { isDark } = useTheme() const { isDark } = useTheme()
const authStore = useAuthStore() const authStore = useAuthStore()
const userInfo = computed(() => authStore.userInfo || {}) const userInfo = computed(() => authStore.userInfo || {})
function goProfileEdit() { uni.navigateTo({ url: '/pages/profile/edit' }) }
function goProfileEdit() {
uni.navigateTo({ url: '/pages/profile/edit' })
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.profile-page { .page-container {
--bg-page: #f5f7fa;
--bg-surface: #ffffff;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--text-tertiary: #9ca3af;
min-height: 100vh; min-height: 100vh;
background-color: var(--bg-page); background: var(--bg-page);
padding-bottom: calc(100rpx + env(safe-area-inset-bottom)); padding-bottom: 200rpx;
}
.page-container.dark {
--bg-page: #1c1c1e; /* Dark Grey */
--bg-surface: #2c2c2e;
--text-primary: #f2f2f7;
} }
.user-card { .profile-header {
display: flex; background: var(--bg-surface);
align-items: center; padding: calc(var(--status-bar-height) + 20rpx) 40rpx 60rpx;
padding: 40rpx 32rpx; border-bottom-left-radius: 40rpx;
background-color: var(--bg-content); border-bottom-right-radius: 40rpx;
margin-bottom: 24rpx; box-shadow: 0 10rpx 30rpx rgba(0,0,0,0.03);
transition: background-color 0.2s; }
&:active { .header-top {
background-color: var(--bg-active); 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; }
}
.avatar-wrapper { .user-hero {
margin-right: 32rpx; display: flex; align-items: center; gap: 30rpx;
border: 2rpx solid var(--border-color); .hero-info {
border-radius: 50%; flex: 1; display: flex; flex-direction: column;
padding: 2rpx; // 模拟头像边框 .nickname { font-size: 40rpx; font-weight: 700; color: var(--text-primary); margin-bottom: 8rpx; }
} .account { font-size: 26rpx; color: var(--text-secondary); }
.info-wrapper {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
.name-row {
display: flex;
align-items: center;
margin-bottom: 12rpx;
.nickname {
font-size: 40rpx;
font-weight: 600;
color: var(--text-primary);
margin-right: 12rpx;
}
.edit-icon {
opacity: 0.6;
}
}
.account {
font-size: 28rpx;
color: var(--text-secondary);
}
} }
} }
.menu-list { .stats-row {
.gap { display: flex; justify-content: space-around; margin-top: 60rpx;
height: 24rpx; .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; }
} }
} }
.dark { .menu-container { padding: 40rpx; }
.user-card { .menu-group { background: var(--bg-surface); border-radius: 24rpx; padding: 10rpx 30rpx; }
background-color: var(--bg-content); .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; }
:deep(.wd-cell) { .icon-box {
background-color: var(--bg-content); width: 70rpx; height: 70rpx; border-radius: 16rpx; display: flex; align-items: center; justify-content: center; margin-right: 24rpx;
color: var(--text-primary); &.blue { background: #4f46e5; } &.yellow { background: #f59e0b; } &.green { background: #10b981; }
} }
.menu-text { flex: 1; font-size: 30rpx; font-weight: 500; color: var(--text-primary); }
} }
</style> </style>

View File

@@ -1,15 +1,16 @@
<template> <template>
<view class="search-page" :class="{ dark: isDark }"> <view class="search-page" :class="{ dark: isDark }">
<!-- 搜索栏 --> <!-- 搜索栏 (增加 safe-area-top) -->
<view class="search-header safe-area-top"> <view class="search-header safe-area-top">
<wd-search <wd-search
v-model="keyword" v-model="keyword"
placeholder="搜索" placeholder="搜索"
show-action show-action
focus focus
@search="doSearch" @search="doSearch"
@cancel="goBack" @cancel="goBack"
@clear="clearKeyword" @clear="clearKeyword"
custom-style="background: transparent;"
/> />
</view> </view>
@@ -20,14 +21,14 @@
<text class="clear" @click="clearHistory">清空</text> <text class="clear" @click="clearHistory">清空</text>
</view> </view>
<view class="history-tags"> <view class="history-tags">
<wd-tag <view
v-for="(item, index) in history" v-for="(item, index) in history"
:key="index" :key="index"
round class="history-tag"
@click="searchHistory(item)" @click="searchHistory(item)"
> >
{{ item }} {{ item }}
</wd-tag> </view>
</view> </view>
</view> </view>
@@ -41,25 +42,26 @@
<wd-status-tip v-else-if="results.length === 0 && searched" tip="未找到相关结果" /> <wd-status-tip v-else-if="results.length === 0 && searched" tip="未找到相关结果" />
<template v-else-if="results.length > 0"> <template v-else-if="results.length > 0">
<wd-cell <view class="result-list">
v-for="item in results" <view
:key="item.id" v-for="item in results"
:title="item.name" :key="item.id"
:label="item.desc" class="result-item"
is-link @click="goDetail(item)"
@click="goDetail(item)" >
> <app-avatar
<template #icon>
<view class="result-avatar">
<app-avatar
:src="item.avatar" :src="item.avatar"
:name="item.name" :name="item.name"
:size="80" :size="88"
radius="8rpx" radius="12rpx"
/> />
<view class="result-info">
<text class="name">{{ item.name }}</text>
<text class="desc">{{ item.desc || '暂无签名' }}</text>
</view> </view>
</template> <wd-icon name="arrow-right" size="32rpx" color="var(--text-tertiary)" />
</wd-cell> </view>
</view>
</template> </template>
</view> </view>
</view> </view>
@@ -161,60 +163,67 @@ function goDetail(item: User) {
.search-page { .search-page {
min-height: 100vh; min-height: 100vh;
background: var(--bg-page); background: var(--bg-page);
display: flex;
flex-direction: column;
} }
.search-header { .search-header {
background: var(--bg-content); background: var(--bg-content);
padding: 10rpx 20rpx;
position: sticky;
top: 0;
z-index: 100;
} }
.safe-area-top { .safe-area-top {
padding-top: var(--status-bar-height, 0); padding-top: var(--status-bar-height);
} }
.history-section { .history-section {
background: var(--bg-content);
margin-top: 20rpx;
padding: 30rpx; padding: 30rpx;
.section-header { .section-header {
display: flex; display: flex; justify-content: space-between; margin-bottom: 24rpx;
justify-content: space-between; .title { font-size: 28rpx; font-weight: 600; color: var(--text-primary); }
margin-bottom: 24rpx; .clear { font-size: 26rpx; color: var(--text-tertiary); }
.title {
font-size: 28rpx;
color: var(--text-tertiary);
}
.clear {
font-size: 28rpx;
color: var(--text-tertiary);
}
} }
.history-tags { .history-tags {
display: flex; display: flex; flex-wrap: wrap; gap: 20rpx;
flex-wrap: wrap; .history-tag {
gap: 16rpx; background: var(--bg-content); padding: 10rpx 24rpx; border-radius: 30rpx;
font-size: 26rpx; color: var(--text-secondary);
&:active { opacity: 0.7; }
}
} }
} }
.results-section { .results-section {
background: var(--bg-content); flex: 1;
margin-top: 20rpx; background: var(--bg-page);
} }
.loading-state { .loading-state {
display: flex; display: flex; flex-direction: column; align-items: center; justify-content: center;
flex-direction: column; padding-top: 100rpx; color: var(--text-tertiary); gap: 20rpx;
align-items: center;
justify-content: center;
padding: 60rpx;
color: var(--text-tertiary);
gap: 20rpx;
} }
.result-avatar { .result-list {
margin-right: 24rpx; padding: 20rpx;
}
.result-item {
display: flex; align-items: center;
background: var(--bg-content);
padding: 24rpx;
border-radius: 20rpx;
margin-bottom: 20rpx;
transition: opacity 0.2s;
&:active { opacity: 0.8; }
.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; }
}
} }
</style> </style>

View File

@@ -1,37 +1,47 @@
<template> <template>
<view class="settings-page" :class="{ dark: isDark }"> <wd-config-provider :theme="isDark ? 'dark' : 'light'">
<app-nav-bar title="设置" /> <view class="settings-page" :class="{ dark: isDark }">
<app-nav-bar title="设置" custom-style="background: transparent;" />
<view class="content"> <view class="content-body">
<!-- 账号安全 --> <!-- 账号安全 -->
<wd-cell-group title="账号安全" border> <view class="settings-group">
<wd-cell title="账号管理" is-link to="/pages/settings/account" /> <view class="group-title">账号</view>
<wd-cell title="隐私设置" is-link to="/pages/settings/privacy" /> <view class="group-card">
</wd-cell-group> <wd-cell title="账号管理" is-link to="/pages/settings/account" size="large" />
<wd-cell title="隐私设置" is-link to="/pages/settings/privacy" size="large" />
</view>
</view>
<!-- 通用设置 --> <!-- 通用设置 -->
<wd-cell-group title="通用" border> <view class="settings-group">
<wd-cell title="新消息通知" is-link to="/pages/settings/notification" /> <view class="group-title">通用</view>
<wd-cell title="主题设置" is-link to="/pages/settings/theme" value="自动" /> <view class="group-card">
<wd-cell title="通用设置" is-link to="/pages/settings/general" /> <wd-cell title="新消息通知" is-link to="/pages/settings/notification" size="large" />
<wd-cell title="清理缓存" is-link value="12.5MB" clickable @click="handleClearCache" /> <wd-cell title="主题设置" is-link to="/pages/settings/theme" value="自动" size="large" />
</wd-cell-group> <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>
</view>
<!-- 关于 --> <!-- 关于 -->
<wd-cell-group title="关于" border> <view class="settings-group">
<wd-cell title="帮助与反馈" is-link to="/pages/settings/feedback" /> <view class="group-card">
<wd-cell title="关于我们" is-link to="/pages/settings/about" value="v1.0.0" /> <wd-cell title="帮助与反馈" is-link to="/pages/settings/feedback" size="large" />
</wd-cell-group> <wd-cell title="关于我们" is-link to="/pages/settings/about" value="v1.0.0" size="large" />
</view>
</view>
<!-- 退出登录 --> <!-- 退出登录 -->
<view class="logout-section"> <view class="logout-btn" @click="handleLogout">
<wd-button type="error" block size="large" @click="handleLogout">退出登录</wd-button> <text>退出登录</text>
</view>
</view> </view>
</view>
<wd-toast /> <wd-toast />
<wd-message-box /> <wd-message-box />
</view> </view>
</wd-config-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -40,63 +50,46 @@ import { useAuthStore } from '@/stores'
import { useToast, useMessage } from 'wot-design-uni' import { useToast, useMessage } from 'wot-design-uni'
import AppNavBar from '@/components/common/AppNavBar.vue' import AppNavBar from '@/components/common/AppNavBar.vue'
const { isDark } = useTheme() // --- 逻辑不变 ---
const authStore = useAuthStore() const { isDark } = useTheme(); const authStore = useAuthStore(); const toast = useToast(); const message = useMessage();
const toast = useToast() function handleLogout() { message.confirm({ title: '提示', msg: '确定要退出登录吗?', success: async () => { try { await authStore.logout(); uni.reLaunch({ url: '/pages/login/index' }) } catch (error) { toast.error('退出失败') } } }) }
const message = useMessage() function handleClearCache() { toast.loading('清理中...'); setTimeout(() => { toast.success('清理完成') }, 1000) }
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)
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.settings-page { .settings-page {
--bg-page: #f5f7fa;
--bg-surface: #ffffff;
--text-secondary: #86868b;
min-height: 100vh; min-height: 100vh;
background-color: var(--bg-page); background: var(--bg-page);
.content {
padding-bottom: 40rpx;
}
.logout-section {
margin: 60rpx 30rpx 0;
}
} }
// 暗黑模式微调 Cell Group 标题颜色 .settings-page.dark {
.dark { --bg-page: #1c1c1e;
:deep(.wd-cell-group__title) { --bg-surface: #2c2c2e;
color: var(--text-secondary); --text-secondary: #aeaeb2;
background-color: var(--bg-page); }
}
:deep(.wd-cell) { .content-body { padding: 30rpx; }
background-color: var(--bg-content);
color: var(--text-primary);
// 覆盖 cell 内部线条颜色 (如果需要) .settings-group { margin-bottom: 40rpx; }
&::after { .group-title { font-size: 26rpx; color: var(--text-secondary); margin-bottom: 16rpx; margin-left: 20rpx; }
background-color: var(--border-color); .group-card {
} border-radius: 24rpx; overflow: hidden;
} /* 覆盖 Cell 背景 */
:deep(.wd-cell) { background-color: var(--bg-surface); }
:deep(.wd-cell__title) { font-weight: 500; }
}
.logout-btn {
background: var(--bg-surface);
border-radius: 24rpx;
padding: 30rpx;
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); }
} }
</style> </style>

View File

@@ -1,56 +1,44 @@
<template> <template>
<view class="theme-page" :class="{ dark: isDark }"> <wd-config-provider :theme="isDark ? 'dark' : 'light'">
<!-- 导航栏 --> <view class="theme-page" :class="{ dark: isDark }">
<app-nav-bar title="深色模式" /> <app-nav-bar title="主题设置" custom-style="background: transparent;" />
<wd-cell-group title="选择主题模式">
<wd-cell
title="跟随系统"
center
clickable
@click="selectTheme('system')"
>
<template #icon>
<wd-icon name="setting" size="40rpx" class="cell-icon" />
</template>
<template #value>
<wd-icon v-if="currentTheme === 'system'" name="check" size="40rpx" color="var(--color-primary)" />
</template>
</wd-cell>
<wd-cell
title="浅色模式"
center
clickable
@click="selectTheme('light')"
>
<template #icon>
<wd-icon name="sun" size="40rpx" class="cell-icon" />
</template>
<template #value>
<wd-icon v-if="currentTheme === 'light'" name="check" size="40rpx" color="var(--color-primary)" />
</template>
</wd-cell>
<wd-cell
title="深色模式"
center
clickable
@click="selectTheme('dark')"
>
<template #icon>
<wd-icon name="moon" size="40rpx" class="cell-icon" />
</template>
<template #value>
<wd-icon v-if="currentTheme === 'dark'" name="check" size="40rpx" color="var(--color-primary)" />
</template>
</wd-cell>
</wd-cell-group>
<view class="tips"> <view class="content-body">
<text>跟随系统会根据系统设置自动切换深色/浅色模式</text> <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>
<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>
<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">
<text>跟随系统开启后将根据系统显示设置自动切换深色或浅色模式</text>
</view>
</view>
<wd-toast />
</view> </view>
</wd-config-provider>
<wd-toast />
</view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -58,70 +46,70 @@ import { ref, onMounted } from 'vue'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { useToast } from 'wot-design-uni' import { useToast } from 'wot-design-uni'
const { isDark, setTheme: applyTheme } = useTheme() // --- 逻辑不变 ---
const toast = useToast() 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 currentTheme = ref<'system' | 'light' | 'dark'>('system')
onMounted(() => {
// 从本地存储读取当前主题设置
const saved = uni.getStorageSync('nl_im_theme_mode')
if (saved === 'light' || saved === 'dark' || saved === 'system') {
currentTheme.value = saved
}
})
function selectTheme(mode: 'system' | 'light' | 'dark') { function selectTheme(mode: 'system' | 'light' | 'dark') {
console.log('selectTheme called:', mode) currentTheme.value = mode; uni.setStorageSync('nl_im_theme_mode', mode);
currentTheme.value = mode if (mode === 'system') { uni.removeStorageSync('nl_im_theme'); let systemDark = false; try { systemDark = uni.getSystemInfoSync().theme === 'dark' } catch (e) {} applyTheme(systemDark, false) }
uni.setStorageSync('nl_im_theme_mode', mode) else { applyTheme(mode === 'dark', true) }
const tips = { system: '已设置为跟随系统', light: '已切换为浅色模式', dark: '已切换为深色模式' }; toast.success(tips[mode])
// 应用主题
if (mode === 'system') {
// 跟随系统 - 清除手动设置,不持久化
uni.removeStorageSync('nl_im_theme')
let systemDark = false
try {
systemDark = uni.getSystemInfoSync().theme === 'dark'
} catch (e) {
console.log('获取系统主题失败:', e)
}
console.log('系统深色模式:', systemDark)
applyTheme(systemDark, false) // 不持久化
} else {
console.log('设置主题:', mode === 'dark' ? '深色' : '浅色')
applyTheme(mode === 'dark', true) // 持久化
}
const tips = {
system: '已设置为跟随系统',
light: '已切换为浅色模式',
dark: '已切换为深色模式'
}
toast.success(tips[mode])
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.theme-page { .theme-page {
--bg-page: #f5f7fa;
--bg-surface: #ffffff;
--text-primary: #1d1d1f;
min-height: 100vh; min-height: 100vh;
background: var(--bg-page); background: var(--bg-page);
} }
.theme-page.dark {
.cell-icon { --bg-page: #1c1c1e;
margin-right: 16rpx; --bg-surface: #2c2c2e;
color: var(--text-secondary); --text-primary: #f2f2f7;
} }
.tips { .content-body { padding: 40rpx; }
padding: 30rpx;
.option-list {
text { display: grid; grid-template-columns: 1fr 1fr; gap: 30rpx; margin-bottom: 40rpx;
font-size: 26rpx;
color: var(--text-tertiary);
line-height: 1.6;
}
} }
.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;
&.active { border-color: #4f46e5; background: rgba(79, 70, 229, 0.05); }
.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; }
}
/* 简单的 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; }
}
.light-prev {
background: #fff;
.p-nav { background: #f7f7f7; } .p-body { background: #eee; }
}
.dark-prev {
background: #1c1c1e; border-color: #333;
.p-nav { background: #2c2c2e; border-color: #333; } .p-body { background: #333; }
}
.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;
}
.tips { padding: 0 10rpx; font-size: 24rpx; color: #999; line-height: 1.5; }
</style> </style>

View File

@@ -59,53 +59,49 @@ page {
// 开关 // 开关
--switch-on: #07c160; --switch-on: #07c160;
--switch-off: #e5e5e5; --switch-off: #e5e5e5;
// 状态栏高度变量 (小程序/App会自动注入H5需fallback)
--status-bar-height: 20px;
} }
// 暗色主题 (支持多种选择器以兼容 H5) // 暗色主题 (Dark Mode - 高级灰优化)
page.dark, page.dark,
.dark, .dark,
html.dark, html.dark,
body.dark { body.dark {
// 背景色 // 背景色 - 使用 #1c1c1e 替代纯黑
--bg-page: #111111; --bg-page: #1c1c1e;
--bg-content: #1e1e1e; --bg-content: #2c2c2e;
--bg-hover: #2a2a2a; --bg-hover: #3a3a3c;
--bg-mask: rgba(0, 0, 0, 0.7); --bg-mask: rgba(0, 0, 0, 0.7);
--bg-card: #1e1e1e; --bg-card: #2c2c2e;
// 文字颜色 // 文字颜色
--text-primary: #e5e5e5; --text-primary: #f2f2f7; // 柔和白
--text-secondary: #a0a0a0; --text-secondary: #aeaeb2;
--text-tertiary: #666666; --text-tertiary: #636366;
--text-placeholder: #444444; --text-placeholder: #505055;
--text-link: #7b9ad0; --text-link: #7b9ad0;
// 边框颜色 // 边框颜色
--border-color: #333333; --border-color: #38383a;
--divider-color: #2a2a2a; --divider-color: #38383a;
// 导航栏 // 导航栏
--nav-bg: #1a1a1a; --nav-bg: #1c1c1e;
--nav-text: #ffffff; --nav-text: #ffffff;
// 标签栏 // 标签栏
--tabbar-bg: #1a1a1a; --tabbar-bg: #1c1c1e;
// 消息气泡 // 消息气泡
--bubble-self: #2d5c2e; --bubble-self: #2d5c2e; // 深绿
--bubble-other: #2a2a2a; --bubble-other: #2c2c2e;
--bubble-system: rgba(255, 255, 255, 0.1); --bubble-system: rgba(255, 255, 255, 0.1);
// 搜索框 // 搜索框
--search-bg: #2a2a2a; --search-bg: #2c2c2e;
// 开关 // 开关
--switch-off: #444444; --switch-off: #3a3a3c;
} }