UI设计优化

This commit is contained in:
2025-12-11 23:50:02 +08:00
parent fd5b06845a
commit 8c2ab88790
6 changed files with 1566 additions and 2878 deletions

View File

@@ -1,29 +1,80 @@
<template>
<view class="message-bubble" :class="{ 'is-self': isSelf }">
<view class="msg-row" :class="{ 'is-self': isSelf }">
<!-- 头像 -->
<view v-if="!isSelf" class="bubble-avatar">
<view class="avatar-wrap" @click="$emit('avatar-click', msg.sender_user_id)">
<app-avatar
:src="avatarUrl"
:name="avatarName"
:size="72"
radius="8rpx"
:src="avatar"
:name="avatarName"
:size="80"
radius="24rpx"
/>
</view>
<!-- 消息内容 -->
<view class="bubble-content" :class="bubbleClass">
<slot />
</view>
<!-- 内容 -->
<view class="content-col" :class="{ 'items-end': isSelf }">
<!-- 群聊昵称 -->
<text v-if="senderName" class="sender-name">{{ senderName }}</text>
<!-- 自己的头像 -->
<view v-if="isSelf" class="bubble-avatar">
<app-avatar
:src="selfAvatarUrl"
:name="selfAvatarName"
:size="72"
radius="8rpx"
bg-color="#10aeff"
/>
<!-- 气泡实体 -->
<view
class="bubble"
:class="[
isSelf ? 'bubble-self' : 'bubble-other',
mediaTypeClass
]"
@longpress="$emit('long-press', msg)"
>
<!-- 0. 纯文本 -->
<text v-if="msg.message_type === 0" class="msg-text" selectable>{{ msg.content }}</text>
<!-- 1. 图片 -->
<view v-else-if="msg.message_type === 1" class="image-bubble">
<image
:src="getMediaUrl(msg)"
mode="widthFix"
class="msg-image"
@click="$emit('preview-image', getMediaUrl(msg))"
/>
</view>
<!-- 2. 语音 -->
<view v-else-if="msg.message_type === 2" class="audio-capsule" @click="$emit('play-audio', msg)">
<view class="play-icon-box">
<svg v-if="playingAudioId !== msg.id" viewBox="0 0 24 24" fill="currentColor" class="play-icon"><path d="M12 2a10 10 0 0 0-10 10 10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor" class="play-icon"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
</view>
<view class="wave-visualizer">
<view class="wave-bar" :class="{ animating: playingAudioId === msg.id }"></view>
<view class="wave-bar" :class="{ animating: playingAudioId === msg.id }"></view>
<view class="wave-bar" :class="{ animating: playingAudioId === msg.id }"></view>
<view class="wave-bar" :class="{ animating: playingAudioId === msg.id }"></view>
</view>
<text class="duration-tag">{{ msg.duration || 0 }}"</text>
</view>
<!-- 3. 视频 -->
<view v-else-if="msg.message_type === 3" class="video-preview" @click="$emit('play-video', getMediaUrl(msg))">
<image :src="getVideoThumb(msg)" mode="aspectFill" class="video-thumb" />
<view class="play-overlay">
<view class="play-btn-circle">
<svg viewBox="0 0 24 24" fill="currentColor" class="icon-play"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</view>
</view>
</view>
<!-- 8. 文件 -->
<view v-else-if="msg.message_type === 8" class="file-card" :class="{ 'file-self': isSelf }" @click="$emit('open-file', getMediaUrl(msg))">
<view class="file-icon-box" :class="{ 'icon-self': isSelf }">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</view>
<view class="file-meta">
<text class="fname text-ellipsis">{{ getFileName(msg) }}</text>
<text class="fsize">{{ formatSize(getFileSize(msg)) }}</text>
</view>
</view>
<text v-else class="msg-text">[不支持的消息类型]</text>
</view>
</view>
</view>
</template>
@@ -31,90 +82,147 @@
<script setup lang="ts">
import { computed } from 'vue'
import AppAvatar from '@/components/common/AppAvatar.vue'
import { resolveImageUrl } from '@/utils/image'
import { formatSize } from '@/utils/format'
import type { ChatMessage } from '@/types/api'
interface Props {
isSelf?: boolean
avatarUrl?: string
avatarName?: string
selfAvatarUrl?: string
selfAvatarName?: string
type?: 'text' | 'image' | 'audio' | 'video' | 'file'
const props = defineProps<{
msg: ChatMessage
isSelf: boolean
senderName?: string
avatar?: string
playingAudioId?: number | null
}>()
defineEmits<{
(e: 'avatar-click', id?: string): void
(e: 'play-audio', msg: ChatMessage): void
(e: 'preview-image', url: string): void
(e: 'play-video', url: string): void
(e: 'open-file', url: string): void
(e: 'long-press', msg: ChatMessage): void
}>()
const avatarName = computed(() => props.senderName || 'U')
const mediaTypeClass = computed(() => {
const type = props.msg.message_type
if (type === 1) return 'media-bubble image'
if (type === 2) return 'media-bubble audio'
if (type === 3) return 'media-bubble video'
if (type === 8) return 'media-bubble file'
return ''
})
// Helpers (复用原 index.vue 逻辑)
function getMediaUrl(msg: ChatMessage): string {
let url = ''
if (typeof msg.extra === 'string') {
try { const extra = JSON.parse(msg.extra); url = extra.url || msg.content } catch { url = msg.content }
} else { url = msg.extra?.url || msg.content }
return resolveImageUrl(url)
}
const props = withDefaults(defineProps<Props>(), {
isSelf: false,
avatarUrl: '',
avatarName: '',
selfAvatarUrl: '',
selfAvatarName: '',
type: 'text'
})
function getFileName(msg: ChatMessage) {
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra
return extra?.name || '未知文件'
}
const bubbleClass = computed(() => {
const classes = []
if (props.isSelf) {
classes.push('bubble-self')
} else {
classes.push('bubble-other')
}
if (props.type !== 'text') {
classes.push('bubble-media')
}
return classes
})
function getFileSize(msg: ChatMessage) {
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra
return extra?.size || 0
}
function getVideoThumb(msg: ChatMessage) {
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra
if (extra?.thumb || extra?.cover) return resolveImageUrl(extra.thumb || extra.cover)
return getMediaUrl(msg)
}
</script>
<style lang="scss" scoped>
.message-bubble {
display: flex;
align-items: flex-start;
margin-bottom: 24rpx;
/* * 气泡组件样式
* 完全复用原 index.vue 的 .msg-row, .bubble 及其子元素样式
*/
&.is-self {
flex-direction: row-reverse;
}
/* 布局 */
.msg-row {
display: flex; align-items: flex-start; gap: 20rpx;
&.is-self { flex-direction: row-reverse; }
}
.avatar-wrap { width: 80rpx; height: 80rpx; flex-shrink: 0; border-radius: 24rpx; box-shadow: 0 4rpx 8rpx rgba(0,0,0,0.05); }
.content-col {
max-width: 72%; display: flex; flex-direction: column;
&.items-end { align-items: flex-end; }
.sender-name { font-size: 20rpx; color: #9ca3af; margin-bottom: 8rpx; margin-left: 8rpx; .theme-dark & { color: #78716c; } }
}
.bubble-avatar {
margin: 0 16rpx;
}
.bubble-content {
max-width: 60%;
padding: 20rpx 24rpx;
border-radius: 16rpx;
word-break: break-all;
font-size: 30rpx;
line-height: 1.6;
&.bubble-self {
background: var(--bubble-self);
color: var(--text-primary);
border-top-right-radius: 4rpx;
}
/* 核心气泡 */
.bubble {
padding: 24rpx; border-radius: 32rpx; font-size: 30rpx; line-height: 1.6; position: relative; word-break: break-all; transition: all 0.3s ease;
/* 对方消息 */
&.bubble-other {
background: var(--bubble-other);
color: var(--text-primary);
border-top-left-radius: 4rpx;
background: #ffffff; color: #1f2937; border-bottom-left-radius: 8rpx; box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.05);
.theme-dark & { background: #262626; color: #e7e5e4; border: 2rpx solid #292524; box-shadow: none; }
}
&.bubble-media {
padding: 8rpx;
background: transparent;
/* 自己消息 */
&.bubble-self {
background: linear-gradient(135deg, #6366F1, #4F46E5); color: #ffffff; border-bottom-right-radius: 8rpx; box-shadow: 0 4rpx 16rpx rgba(79, 70, 229, 0.2);
.theme-dark & { background: linear-gradient(135deg, #fb923c, #ea580c); box-shadow: 0 4rpx 16rpx rgba(234, 88, 12, 0.3); }
}
/* 媒体容器 */
&.media-bubble { padding: 0; background: transparent !important; box-shadow: none !important; border: none !important; }
}
/* 媒体类型 */
.image-bubble .msg-image { width: 360rpx; border-radius: 32rpx; background: #f3f4f6; border: 2rpx solid #f3f4f6; .theme-dark & { background: #292524; border-color: #292524; } }
/* 语音胶囊 */
.audio-capsule {
display: flex; align-items: center; gap: 20rpx; min-width: 180rpx; padding: 24rpx; background: #ffffff; border-radius: 32rpx;
.theme-dark & { background: #262626; border: 2rpx solid #292524; }
/* 自己的语音 */
.is-self & { background: linear-gradient(135deg, #6366F1, #4F46E5) !important; border: none !important; .theme-dark & { background: linear-gradient(135deg, #fb923c, #ea580c) !important; } }
.play-icon { width: 32rpx; height: 32rpx; color: #6b7280; .theme-dark & { color: #a8a29e; } .is-self & { color: rgba(255,255,255,0.9); } }
.wave-visualizer {
display: flex; align-items: flex-end; gap: 6rpx; height: 24rpx;
.wave-bar {
width: 6rpx; background: #9ca3af; border-radius: 4rpx; height: 8rpx;
.theme-dark & { background: #78716c; } .is-self & { background: rgba(255,255,255,0.6); }
&.animating { animation: wave 0.8s infinite ease-in-out; }
}
}
.duration-tag { font-size: 26rpx; font-weight: 500; margin-left: auto; color: #6b7280; .theme-dark & { color: #a8a29e; } .is-self & { color: #fff; } }
}
@keyframes wave { 0%, 100% { height: 8rpx; } 50% { height: 24rpx; } }
/* 视频预览 */
.video-preview {
position: relative; width: 360rpx; height: 200rpx; border-radius: 32rpx; overflow: hidden; background: #000;
.video-thumb { width: 100%; height: 100%; opacity: 0.9; }
.play-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.play-btn-circle { width: 80rpx; height: 80rpx; background: rgba(0,0,0,0.3); backdrop-filter: blur(4px); border-radius: 50%; display: flex; align-items: center; justify-content: center; border: 2rpx solid rgba(255,255,255,0.3); color: #fff; svg { width: 32rpx; height: 32rpx; } }
}
/* 文件卡片 */
.file-card {
background: #ffffff; padding: 24rpx; border-radius: 32rpx; display: flex; align-items: center; gap: 24rpx; min-width: 400rpx; border: 2rpx solid #f3f4f6;
.theme-dark & { background: #262626; border-color: #292524; }
&.file-self { background: linear-gradient(135deg, #6366F1, #4F46E5); border: none; .theme-dark & { background: linear-gradient(135deg, #fb923c, #ea580c); } .f-name { color: #fff; } .f-size { color: rgba(255,255,255,0.7); } }
.file-icon-box {
width: 80rpx; height: 80rpx; background: #ffedd5; border-radius: 16rpx; display: flex; align-items: center; justify-content: center; color: #f97316;
.theme-dark & { background: rgba(154, 52, 18, 0.3); }
&.icon-self { background: rgba(255,255,255,0.2); color: #fff; }
svg { width: 40rpx; height: 40rpx; }
}
.file-meta { flex: 1; display: flex; flex-direction: column; overflow: hidden; .f-name { font-size: 28rpx; font-weight: 500; color: #1f2937; .theme-dark & { color: #e7e5e4; } } .f-size { font-size: 22rpx; color: #9ca3af; .theme-dark & { color: #78716c; } } }
}
</style>

View File

@@ -1,51 +1,125 @@
<template>
<view class="message-input" :class="{ 'theme-dark': isDark }">
<!-- @ 提及弹窗 (保持原有功能不变) -->
<wd-popup v-model="showMentionPicker" position="bottom" safe-area-inset-bottom>
<view class="mention-picker">
<view class="mention-header">
<text class="title">选择要 @ 的成员</text>
<view class="close-btn" @click="showMentionPicker = false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
<view class="message-input-container" :class="{ 'theme-dark': isDark }">
<!-- 悬浮输入条 -->
<view class="input-floater">
<!-- 1. @提醒选择器 -->
<mention-picker
v-if="isGroup"
:show="showMentionPicker"
:users="mentionUsers"
@select="handleMentionSelect"
@close="showMentionPicker = false"
/>
<!-- 2. 表情面板 -->
<emoji-picker
:show="showEmoji"
@select="handleEmojiSelect"
@close="showEmoji = false"
/>
<!-- 3. 核心输入栏 (Layout: Mic | Input | Smile | Action) -->
<view class="input-bar-capsule">
<!-- 左侧: 语音按钮 -->
<view
class="icon-btn"
:class="{ 'recording': isRecording }"
@touchstart="startRecording"
@touchmove="onRecordingMove"
@touchend="stopRecording"
@touchcancel="cancelRecording"
>
<!-- Lucide Icon: Mic -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</view>
<!-- 中间: 输入框 -->
<view class="input-field-wrap">
<textarea
v-model="inputValue"
class="text-input"
:auto-height="true"
:maxlength="-1"
:cursor-spacing="20"
placeholder="发送消息..."
placeholder-class="input-placeholder"
:show-confirm-bar="false"
:adjust-position="false"
@focus="$emit('focus')"
@confirm="onSend"
@input="onInput"
/>
</view>
<!-- 右侧: 表情 & 更多/发送 -->
<view class="right-actions">
<!-- 表情按钮 -->
<view class="icon-btn" @click="toggleEmoji">
<!-- Lucide Icon: Smile -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>
</view>
<!-- 发送按钮 (有文字时显示) -->
<view v-if="inputValue.trim()" class="send-btn" @click="onSend">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</view>
<!-- 更多按钮 (无文字时显示) -->
<view v-else class="icon-btn" @click="showMore = true">
<!-- Lucide Icon: Plus Circle -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</svg>
</view>
</view>
<scroll-view scroll-y class="mention-list">
<view
v-for="member in members"
:key="member.user_id"
class="mention-item"
@click="selectMention(member)"
>
<app-avatar
:src="member.user?.avatar"
:name="member.nickname || member.user?.name"
:size="72"
radius="8rpx"
/>
<text class="mention-name">{{ member.nickname || member.user?.name || '未知' }}</text>
</view>
</view>
<!-- 4. 更多功能面板 -->
<wd-popup v-model="showMore" position="bottom" safe-area-inset-bottom :z-index="10000" custom-style="background: var(--bg-card); border-radius: 32rpx 32rpx 0 0;">
<view class="tools-grid">
<view class="tool-item" @click="emitAction('send-image')">
<view class="tool-icon album">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</view>
</scroll-view>
<text>相册</text>
</view>
<view class="tool-item" @click="emitAction('send-camera')">
<view class="tool-icon camera">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</view>
<text>拍摄</text>
</view>
<view class="tool-item" @click="emitAction('send-file')">
<view class="tool-icon file">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
</view>
<text>文件</text>
</view>
</view>
</wd-popup>
<!-- 录音浮层 -->
<!-- 5. 录音遮罩 (保持业务逻辑) -->
<view v-if="isRecording" class="recording-overlay">
<view class="recording-content" :class="{ 'cancel-mode': isCancelRecording }">
<view class="recording-icon">
<svg v-if="!isCancelRecording" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="23" x2="16" y2="23" stroke="currentColor" stroke-width="2"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
<svg v-if="!isCancelRecording" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23" stroke="currentColor" stroke-width="2"/><line x1="8" y1="23" x2="16" y2="23" stroke="currentColor" stroke-width="2"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
</view>
<view class="wave-bars">
<view class="wave-bar" v-for="i in 5" :key="i" :style="{ animationDelay: `${i * 0.1}s` }"></view>
@@ -54,573 +128,299 @@
<text class="recording-tip">{{ isCancelRecording ? '松开取消' : '上滑取消发送' }}</text>
</view>
</view>
<!-- 底部输入栏 (按设计稿布局) -->
<view class="input-bar">
<!-- 左侧: 语音按钮 -->
<view
class="voice-btn"
@touchstart="startRecording"
@touchmove="onRecordingMove"
@touchend="stopRecording"
@touchcancel="cancelRecording"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</view>
<!-- 中间: 输入框 -->
<view class="input-wrap">
<textarea
v-model="inputValue"
class="text-input"
:auto-height="true"
:maxlength="-1"
placeholder="发送消息..."
placeholder-class="input-placeholder"
:adjust-position="true"
@input="onInput"
@confirm="onSend"
/>
</view>
<!-- 右侧: 表情 + 加号/发送 -->
<view class="right-actions">
<view class="action-btn" @click="$emit('emoji')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>
</view>
<!-- 有文字时显示发送按钮否则显示加号 -->
<view v-if="inputValue.trim()" class="send-btn" @click="onSend">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</view>
<view v-else class="action-btn" @click="$emit('more')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</svg>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import { useTheme } from '@/composables/useTheme'
import AppAvatar from '@/components/common/AppAvatar.vue'
import EmojiPicker from '@/components/chat/EmojiPicker.vue'
import MentionPicker from '@/components/chat/MentionPicker.vue'
import type { MentionUser } from '@/components/chat/MentionPicker.vue'
import type { GroupMember } from '@/types/api'
interface Props {
// Props & Emits
const props = withDefaults(defineProps<{
modelValue?: string
isGroup?: boolean
members?: GroupMember[]
}
const props = withDefaults(defineProps<Props>(), {
}>(), {
modelValue: '',
isGroup: false,
members: () => []
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'send', value: string): void
(e: 'sendVoice', data: { path: string; duration: number }): void
(e: 'emoji'): void
(e: 'image'): void
(e: 'file'): void
(e: 'more'): void
(e: 'mention', member: GroupMember): void
(e: 'update:modelValue', val: string): void
(e: 'send', val: string): void
(e: 'send-voice', data: { path: string, duration: number }): void
(e: 'send-image'): void
(e: 'send-camera'): void
(e: 'send-file'): void
(e: 'focus'): void
}>()
const { isDark } = useTheme()
// 文本输入相关
const inputValue = ref(props.modelValue)
const showEmoji = ref(false)
const showMore = ref(false)
const showMentionPicker = ref(false)
// 语音录制相关
// 录音状态
const isRecording = ref(false)
const isCancelRecording = ref(false)
const recordDuration = ref(0)
let recordTimer: ReturnType<typeof setInterval> | null = null
let recorderManager: UniApp.RecorderManager | null = null
let startY = 0
let voicePath = ''
let recordTimer: any = null
let recorderManager: any = null
let recordStartY = 0
watch(() => props.modelValue, (val) => {
inputValue.value = val
// 提及用户
const mentionUsers = computed<MentionUser[]>(() => {
return props.members.map(m => ({
id: m.user_id,
name: m.nickname || m.user?.name || '未知',
avatar: m.user?.avatar
}))
})
watch(inputValue, (val) => {
emit('update:modelValue', val)
})
// 同步
watch(() => props.modelValue, (val) => inputValue.value = val)
watch(inputValue, (val) => emit('update:modelValue', val))
// ========== 文本输入处理 (保持原有逻辑) ==========
// 方法
function onInput(e: any) {
const value = e.detail?.value || e.value || inputValue.value
// 检测 @ 符号
if (props.isGroup && value.endsWith('@')) {
const val = e.detail?.value || inputValue.value
if (props.isGroup && val.endsWith('@')) {
showMentionPicker.value = true
}
}
function openMentionPicker() {
showMentionPicker.value = true
}
function selectMention(member: GroupMember) {
const name = member.nickname || member.user?.name || ''
if (inputValue.value.endsWith('@')) {
inputValue.value = inputValue.value + name + ' '
} else {
inputValue.value = inputValue.value + '@' + name + ' '
}
showMentionPicker.value = false
emit('mention', member)
}
function onSend() {
if (!inputValue.value.trim()) return
emit('send', inputValue.value.trim())
inputValue.value = ''
showEmoji.value = false
}
// ========== 语音录制处理 ==========
function initRecorderManager() {
if (recorderManager) return recorderManager
function toggleEmoji() {
showEmoji.value = !showEmoji.value
showMore.value = false
}
function handleEmojiSelect(emoji: string) {
inputValue.value += emoji
}
function handleMentionSelect(user: MentionUser) {
inputValue.value = inputValue.value.endsWith('@')
? inputValue.value + user.name + ' '
: inputValue.value + '@' + user.name + ' '
showMentionPicker.value = false
}
function emitAction(action: 'send-image' | 'send-camera' | 'send-file') {
showMore.value = false
emit(action)
}
// 录音逻辑 (核心逻辑保持不变,确保事件 emit 正确)
function initRecorder() {
recorderManager = uni.getRecorderManager()
recorderManager.onStart(() => {
console.log('录音开始')
isRecording.value = true
recordDuration.value = 0
recordTimer = setInterval(() => {
recordDuration.value++
// 最长60秒
if (recordDuration.value >= 60) {
stopRecording()
}
}, 1000)
})
recorderManager.onStop((res) => {
console.log('录音结束', res)
clearRecordTimer()
isRecording.value = false
if (!isCancelRecording.value && res.tempFilePath && recordDuration.value >= 1) {
voicePath = res.tempFilePath
emit('sendVoice', {
path: voicePath,
duration: recordDuration.value
})
}
isCancelRecording.value = false
recordDuration.value = 0
})
recorderManager.onError((err) => {
console.error('录音错误', err)
clearRecordTimer()
isRecording.value = false
isCancelRecording.value = false
uni.showToast({ title: '录音失败', icon: 'none' })
})
return recorderManager
}
function clearRecordTimer() {
if (recordTimer) {
recorderManager.onStart(() => { isRecording.value = true; recordDuration.value = 0; recordTimer = setInterval(() => recordDuration.value++, 1000) })
recorderManager.onStop((res: any) => {
clearInterval(recordTimer)
recordTimer = null
}
isRecording.value = false
if (!isCancelRecording.value && res.tempFilePath) {
emit('send-voice', { path: res.tempFilePath, duration: recordDuration.value })
}
isCancelRecording.value = false
})
}
function startRecording(e: TouchEvent) {
startY = e.touches[0].clientY
isCancelRecording.value = false
// 初始化录音管理器
const recorder = initRecorderManager()
// 开始录音
recorder.start({
duration: 60000, // 最长60秒
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
format: 'mp3'
})
recordStartY = e.touches[0].clientY
if (!recorderManager) initRecorder()
recorderManager.start({ format: 'aac' })
}
function onRecordingMove(e: TouchEvent) {
if (!isRecording.value) return
const currentY = e.touches[0].clientY
const diff = startY - currentY
// 上滑超过80px触发取消
isCancelRecording.value = diff > 80
if (recordStartY - e.touches[0].clientY > 80) isCancelRecording.value = true
}
function stopRecording() {
if (!isRecording.value) return
if (recorderManager) {
recorderManager.stop()
}
}
function cancelRecording() {
isCancelRecording.value = true
stopRecording()
}
function formatRecordDuration(seconds: number): string {
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
const s = (seconds % 60).toString().padStart(2, '0')
return `${m}:${s}`
}
// 暴露方法供父组件使用
defineExpose({
openMentionPicker
})
function stopRecording() { recorderManager?.stop() }
function cancelRecording() { isCancelRecording.value = true; stopRecording() }
function formatRecordDuration(s: number) { return `${Math.floor(s/60).toString().padStart(2,'0')}:${(s%60).toString().padStart(2,'0')}` }
</script>
<style lang="scss" scoped>
// ==========================================
// 与设计稿 聊天页面.html 第116-123行 完全一致
// ==========================================
.message-input {
// 亮色模式颜色 (与设计稿一致)
--input-bg: #f3f4f6; // 设计稿: bg-gray-100
--input-text: #1f2937; // 设计稿: text-gray-800
--placeholder-color: #9ca3af; // 设计稿: placeholder-gray-400
--icon-color: #6b7280; // 设计稿: text-gray-500
--icon-hover: #4F46E5; // 设计稿: hover:text-indigo-600
--send-bg: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
--border-color: #f3f4f6; // 设计稿: border-gray-100
--bg-content: #ffffff; // 设计稿: bg-white
position: relative;
background: var(--bg-content);
border-top: 2rpx solid var(--border-color);
transition: all 0.3s;
// 暗色模式 - Warm Stone (与设计稿一致)
/* =================================================================
Message Input Styling - Warm Stone Restoration
基于 聊天页面.html 的设计规范
================================================================= */
.message-input-container {
/* 颜色变量 - 严格对标 HTML */
--bg-floater: #ffffff;
--bg-input: #f3f4f6; /* gray-100 */
--text-input: #1f2937; /* gray-800 */
--text-placeholder: #9ca3af;/* gray-400 */
--icon-normal: #6b7280; /* gray-500 */
--icon-hover: #4f46e5; /* indigo-600 */
--border-color: #f3f4f6; /* gray-100 */
&.theme-dark {
--input-bg: #292524; // 设计稿: dark:bg-warm-800
--input-text: #f5f5f4; // 设计稿: dark:text-warm-100
--placeholder-color: #78716c; // 设计稿: dark:placeholder-warm-500
--icon-color: #a8a29e; // 设计稿: dark:text-warm-400
--icon-hover: #f97316; // 设计稿: dark:hover:text-orange-400
--send-bg: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
--border-color: #292524; // 设计稿: dark:border-warm-800
--bg-content: #1c1917; // 设计稿: dark:bg-warm-900
--bg-floater: #1c1917; /* warm-900 */
--bg-input: #292524; /* warm-800 */
--text-input: #e7e5e4; /* warm-100 */
--text-placeholder: #78716c;/* warm-500 */
--icon-normal: #a8a29e; /* warm-400 */
--icon-hover: #fb923c; /* orange-400 */
--border-color: #292524; /* warm-800 */
}
}
// 底部输入栏 - 设计稿: px-4 py-3 pb-8 flex items-end gap-3
.input-bar {
display: flex;
align-items: flex-end; // 设计稿: items-end
gap: 24rpx; // 设计稿: gap-3 = 12px = 24rpx
padding: 24rpx 32rpx; // 设计稿: px-4 py-3 = 16px 12px = 32rpx 24rpx
padding-bottom: calc(64rpx + env(safe-area-inset-bottom)); // 设计稿: pb-8 = 32px = 64rpx
/* 底部悬浮条容器 */
.input-floater {
position: fixed;
bottom: 0; left: 0; right: 0;
z-index: 200;
background: var(--bg-floater);
border-top: 2rpx solid var(--border-color);
/* 间距还原: px-4 py-3 pb-8 */
padding: 24rpx 32rpx;
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
transition: background-color 0.3s ease, border-color 0.3s ease;
}
// 语音按钮
.voice-btn {
/* 胶囊布局: Flex Row + Gap 3 (24rpx)
修改点align-items: center 实现垂直居中
*/
.input-bar-capsule {
display: flex;
align-items: center; /* 核心修改: 垂直居中对齐 */
gap: 24rpx; /* gap-3 */
}
/* 图标按钮: w-7 h-7 (56rpx) */
.icon-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
color: var(--icon-normal);
flex-shrink: 0;
margin-bottom: 16rpx;
color: var(--icon-color);
transition: color 0.15s;
margin-bottom: 0; /* 修改点: 移除底部 margin因为已经是垂直居中了 */
transition: color 0.2s, transform 0.1s;
svg {
width: 56rpx;
height: 56rpx;
stroke-width: 2; /* 确保图标线条清晰 */
}
&:active {
color: var(--icon-hover);
transform: scale(0.95);
}
&.recording {
color: var(--icon-hover);
transform: scale(1.1);
}
}
// 输入框容器 - 与设计稿完全一致
// 设计稿: flex-1 bg-gray-100 dark:bg-warm-800 rounded-2xl px-4 py-2.5 min-h-[44px]
// border border-transparent dark:border-warm-700
.input-wrap {
/* 输入框容器: 核心高度调整
设计: min-h-[36px] (72rpx) -> 精致
*/
.input-field-wrap {
flex: 1;
min-height: 88rpx; // 设计稿: min-h-[44px] = 44px = 88rpx
background: var(--input-bg);
border-radius: 32rpx; // 设计稿: rounded-2xl = 16px = 32rpx
padding: 20rpx 32rpx; // 设计稿: px-4 py-2.5 = 16px 10px = 32rpx 20rpx
min-height: 72rpx;
background: var(--bg-input);
border-radius: 36rpx; /* 圆角对应高度的一半 */
padding: 16rpx 28rpx;
display: flex;
align-items: center;
border: 2rpx solid transparent; // 设计稿: border border-transparent
transition: all 0.2s;
// 设计稿: dark:border-warm-700
.theme-dark & {
border-color: #44403c; // warm-700
transition: background-color 0.3s;
.text-input {
width: 100%;
max-height: 200rpx;
min-height: 40rpx;
font-size: 30rpx; /* text-sm approx */
line-height: 1.4;
color: var(--text-input);
background: transparent;
}
.input-placeholder {
color: var(--text-placeholder);
}
}
// 设计稿: text-sm text-gray-800 dark:text-warm-100 resize-none h-5
.text-input {
width: 100%;
min-height: 40rpx; // 设计稿: h-5 = 20px = 40rpx
max-height: 200rpx;
font-size: 28rpx; // 设计稿: text-sm = 14px = 28rpx
line-height: 1.5;
color: var(--input-text);
background: transparent;
}
.input-placeholder {
color: var(--placeholder-color);
}
// 右侧操作按钮
/* 右侧操作区 */
.right-actions {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
gap: 24rpx; /* gap-3 between smile and plus */
margin-bottom: 0; /* 修改点: 移除底部 margin */
}
.action-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
color: var(--icon-color);
transition: all 0.15s;
svg {
width: 56rpx;
height: 56rpx;
}
&:active {
color: var(--icon-hover);
}
}
// 发送按钮
/* 发送按钮 - 视觉焦点 */
.send-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: var(--send-bg);
/* 渐变色还原: Indigo / Orange */
background: linear-gradient(135deg, #6366F1, #4F46E5);
.theme-dark & { background: linear-gradient(135deg, #fb923c, #ea580c); }
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(79, 70, 229, 0.3);
transition: all 0.2s;
animation: popIn 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
.theme-dark & {
box-shadow: 0 4rpx 16rpx rgba(249, 115, 22, 0.3);
}
margin-bottom: 0; /* 修改点: 移除底部 margin */
box-shadow: 0 4rpx 12rpx rgba(79, 70, 229, 0.3);
svg {
width: 36rpx;
height: 36rpx;
}
&:active {
transform: scale(0.95);
}
}
@keyframes popIn {
from { transform: scale(0); }
to { transform: scale(1); }
}
// 录音浮层
/* 录音浮层 (复用) */
.recording-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.recording-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 32rpx;
min-width: 300rpx;
.theme-dark & {
background: rgba(41, 37, 36, 0.95);
}
&.cancel-mode {
.recording-icon {
background: #ef4444;
}
.wave-bars .wave-bar {
background: #ef4444;
animation: none;
height: 8rpx;
}
position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 999;
display: flex; align-items: center; justify-content: center;
.recording-content {
background: var(--bg-floater); padding: 64rpx; border-radius: 48rpx;
display: flex; flex-direction: column; align-items: center;
&.cancel-mode { .recording-icon { background: #ef4444; } }
}
.recording-icon { width: 128rpx; height: 128rpx; background: #10b981; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; margin-bottom: 32rpx; svg { width: 64rpx; height: 64rpx; } }
.wave-bars { display: flex; gap: 8rpx; height: 48rpx; margin-bottom: 24rpx; .wave-bar { width: 8rpx; height: 16rpx; background: #10b981; border-radius: 8rpx; animation: wave 1s infinite; } }
.recording-duration { font-size: 36rpx; font-weight: 700; color: var(--text-input); margin-bottom: 16rpx; }
.recording-tip { font-size: 24rpx; color: var(--icon-normal); }
}
@keyframes wave { 0%, 100% { height: 16rpx; } 50% { height: 48rpx; } }
.recording-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: #10b981;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
transition: background 0.2s;
svg {
width: 60rpx;
height: 60rpx;
color: #fff;
}
}
.wave-bars {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
height: 64rpx;
margin-bottom: 24rpx;
}
.wave-bar {
width: 8rpx;
height: 16rpx;
background: #10b981;
border-radius: 8rpx;
animation: wave 0.8s infinite ease-in-out;
@for $i from 1 through 5 {
&:nth-child(#{$i}) {
animation-delay: #{($i - 1) * 0.1}s;
}
}
}
@keyframes wave {
0%, 100% { height: 16rpx; }
50% { height: 48rpx; }
}
.recording-duration {
font-size: 36rpx;
font-weight: 600;
color: var(--input-text);
font-family: monospace;
margin-bottom: 16rpx;
}
.recording-tip {
font-size: 24rpx;
color: var(--placeholder-color);
}
// @ 提及选择器 (保持原有样式)
.mention-picker {
max-height: 60vh;
background: var(--bg-content);
.mention-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid var(--border-color);
.title {
font-size: 32rpx;
font-weight: 600;
color: var(--input-text);
}
.close-btn {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
color: var(--icon-color);
svg {
width: 32rpx;
height: 32rpx;
}
}
}
.mention-list {
max-height: 50vh;
}
.mention-item {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
gap: 20rpx;
&:active {
background: var(--input-bg);
}
.mention-name {
font-size: 30rpx;
color: var(--input-text);
/* 更多面板 Grid (复用) */
.tools-grid {
padding: 48rpx 32rpx; display: grid; grid-template-columns: repeat(4, 1fr); gap: 32rpx;
.tool-item {
display: flex; flex-direction: column; align-items: center; gap: 16rpx;
.tool-icon {
width: 112rpx; height: 112rpx; border-radius: 32rpx; display: flex; align-items: center; justify-content: center;
&.album { background: #10b981; } &.camera { background: #f97316; } &.file { background: #eab308; }
svg { width: 56rpx; height: 56rpx; }
}
text { font-size: 24rpx; color: var(--icon-normal); }
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,123 +1,102 @@
<template>
<view class="detail-container" :class="{ 'theme-dark': isDark }">
<!-- 头部背景 (视差效果) -->
<view class="profile-header">
<view class="blur-bg" :style="headerBgStyle" />
<view class="gradient-mask" />
<!-- 视差背景 (与设计稿一致: blur-xl scale-110 opacity-70) -->
<view class="parallax-bg">
<image
v-if="contact?.user?.avatar"
:src="contact.user.avatar"
class="blur-image"
mode="aspectFill"
/>
<view class="gradient-overlay" />
</view>
<!-- 返回按钮 -->
<!-- 导航栏 (与设计稿一致) -->
<view class="nav-header">
<view class="nav-back" @click="goBack">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</view>
<view class="nav-more">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="1"/>
<circle cx="19" cy="12" r="1"/>
<circle cx="5" cy="12" r="1"/>
</svg>
</view>
</view>
<!-- 主体内容 (浮动卡片) -->
<scroll-view scroll-y class="main-content custom-scrollbar" v-if="!loading && contact">
<view class="content-wrapper">
<!-- 用户身份卡片 -->
<view class="identity-card animate-fade-in-up">
<view class="avatar-wrapper">
<app-avatar
:src="contact.user?.avatar"
:name="contact.remark_name || contact.user?.name"
:size="256"
round
custom-style="border: 12rpx solid var(--bg-card);"
/>
</view>
<view class="info-block">
<text class="user-name">{{ contact.remark_name || contact.user?.name || '未知' }}</text>
<view class="meta-row">
<view class="meta-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<text>ID: {{ contact.user?.id || '-' }}</text>
</view>
<view class="meta-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
<text>{{ contact.user?.region || '未知地区' }}</text>
</view>
</view>
<text class="bio-text">{{ contact.user?.desc || '这个人很懒,什么都没写' }}</text>
</view>
<!-- 主体内容 -->
<scroll-view scroll-y class="main-scroll custom-scrollbar" v-if="!loading && contact">
<!-- 身份卡片区 (与设计稿一致) -->
<view class="identity-section">
<!-- 头像: w-32 h-32 rounded-full border-[6px] border-white shadow-xl -->
<view class="avatar-wrapper">
<app-avatar
:src="contact.user?.avatar"
:name="contact.remark_name || contact.user?.name"
:size="256"
round
/>
</view>
<!-- 设置组资料 -->
<view class="settings-group animate-fade-in-up" style="animation-delay: 100ms;">
<view class="settings-card">
<view class="setting-item" @click="editRemark">
<view class="icon-wrap color-blue">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</view>
<view class="item-body">
<text class="item-title">设置备注</text>
<text class="item-value">{{ contact.remark_name || '未设置' }}</text>
</view>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
</view>
<!-- 名字: text-2xl font-bold -->
<text class="user-name">{{ contact.remark_name || contact.user?.name || '未知' }}</text>
<!-- 标签: text-xs bg-gray-100 px-2 py-0.5 rounded -->
<view class="tag-row">
<view class="info-tag">ID: {{ contact.user?.id || '-' }}</view>
<view class="info-tag">{{ contact.user?.region || '未知' }}</view>
</view>
<!-- 设置组交互 -->
<view class="settings-group animate-fade-in-up" style="animation-delay: 150ms;">
<view class="group-label">隐私与通知</view>
<view class="settings-card">
<view class="setting-item">
<view class="icon-wrap color-orange">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
</view>
<view class="item-body">
<text class="item-title">置顶聊天</text>
</view>
<wd-switch v-model="contact.is_top" size="24px" :active-color="isDark ? '#f97316' : '#4F46E5'" @change="toggleTop" />
</view>
<view class="setting-item">
<view class="icon-wrap color-purple">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</view>
<view class="item-body">
<text class="item-title">消息免打扰</text>
</view>
<wd-switch v-model="contact.is_muted" size="24px" :active-color="isDark ? '#f97316' : '#4F46E5'" @change="toggleMute" />
</view>
<view class="setting-item">
<view class="icon-wrap color-red">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
</view>
<view class="item-body">
<text class="item-title">加入黑名单</text>
</view>
<wd-switch v-model="contact.is_blocked" size="24px" active-color="#ef4444" @change="toggleBlock" />
</view>
</view>
</view>
<view class="spacer" />
<!-- 签名: text-sm text-gray-500 text-center max-w-[200px] -->
<text class="bio-text">{{ contact.user?.desc || '这个人很懒,什么都没写' }}</text>
</view>
<!-- 好友设置卡片 (与设计稿一致) -->
<view class="settings-card animate-fade-in-up">
<!-- 设置备注 -->
<view class="setting-row" @click="editRemark">
<text class="row-label">设置备注</text>
<view class="row-right">
<text class="row-value">{{ contact.remark_name || '未设置' }}</text>
<svg class="chevron-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</view>
</view>
<!-- 置顶聊天 -->
<view class="setting-row">
<text class="row-label">置顶聊天</text>
<view class="toggle-wrap">
<view
class="custom-toggle"
:class="{ active: contact.is_top }"
@click="toggleTopManual"
>
<view class="toggle-thumb" />
</view>
</view>
</view>
<!-- 消息免打扰 -->
<view class="setting-row no-border">
<text class="row-label">消息免打扰</text>
<view class="toggle-wrap">
<view
class="custom-toggle"
:class="{ active: contact.is_muted }"
@click="toggleMuteManual"
>
<view class="toggle-thumb" />
</view>
</view>
</view>
</view>
<view class="bottom-spacer" />
</scroll-view>
<!-- Loading -->
@@ -125,20 +104,23 @@
<wd-loading size="60rpx" :color="isDark ? '#f97316' : '#4F46E5'"/>
</view>
<!-- 底部悬浮操作栏 -->
<!-- 底部悬浮操作栏 (与设计稿一致: rounded-t-[32px] shadow-[0_-10px_40px]) -->
<view class="floating-dock" v-if="contact">
<view class="dock-content">
<view class="dock-buttons">
<!-- 电话: flex-1 -->
<view class="dock-btn secondary" @click="startAudioCall">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72"/>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.05 12.05 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.05 12.05 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
</view>
<!-- 发消息: flex-[2] -->
<view class="dock-btn primary" @click="goChat">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<text>发消息</text>
</view>
<!-- 视频: flex-1 -->
<view class="dock-btn secondary" @click="startVideoCall">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
@@ -146,8 +128,9 @@
</svg>
</view>
</view>
<view class="danger-btn" @click="deleteFriend">
<!-- 删除好友: text-xs text-gray-400 underline -->
<view class="delete-link" @click="deleteFriend">
<text>删除好友</text>
</view>
</view>
@@ -158,7 +141,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import * as contactApi from '@/api/modules/contact'
import { useToast, useMessage } from 'wot-design-uni'
@@ -170,11 +153,6 @@ import type { Contact } from '@/types/api'
const toast = useToast(); const messageBox = useMessage(); const { isDark } = useTheme();
const loading = ref(true); const contact = ref<Contact | null>(null); const contactId = ref(''); const userId = ref('');
const headerBgStyle = computed(() => {
const url = contact.value?.user?.avatar || '';
return url ? `background-image: url(${url});` : 'background: var(--bg-card);';
})
onLoad((options: any) => { contactId.value = options?.id || ''; userId.value = options?.userId || '' })
onMounted(async () => { if (contactId.value) { await loadContact() } else if (userId.value) { await loadContactByUserId() } })
async function loadContact() { loading.value = true; try { contact.value = await contactApi.getContactDetail(contactId.value) } catch (error) { console.error('加载联系人失败:', error); toast.error('加载失败') } finally { loading.value = false } }
@@ -187,50 +165,51 @@ async function editRemark() { if (!contact.value) return; try { const { value }
async function toggleTop() { if (!contact.value) return; try { await contactApi.updateContact(contactId.value, { is_top: contact.value.is_top }); toast.success(contact.value.is_top ? '已置顶' : '已取消置顶') } catch { contact.value.is_top = !contact.value.is_top; toast.error('操作失败') } }
async function toggleMute() { if (!contact.value) return; try { await contactApi.updateContact(contactId.value, { is_muted: contact.value.is_muted }); toast.success(contact.value.is_muted ? '已开启免打扰' : '已关闭免打扰') } catch { contact.value.is_muted = !contact.value.is_muted; toast.error('操作失败') } }
async function toggleBlock() { if (!contact.value) return; try { await contactApi.updateContact(contactId.value, { is_blocked: contact.value.is_blocked }); toast.success(contact.value.is_blocked ? '已加入黑名单' : '已移出黑名单') } catch { contact.value.is_blocked = !contact.value.is_blocked; toast.error('操作失败') } }
// 手动切换方法 (用于自定义开关)
async function toggleTopManual() { if (!contact.value) return; contact.value.is_top = !contact.value.is_top; await toggleTop() }
async function toggleMuteManual() { if (!contact.value) return; contact.value.is_muted = !contact.value.is_muted; await toggleMute() }
async function deleteFriend() { try { await messageBox.confirm({ title: '提示', msg: '确定要删除该好友吗?删除后将无法恢复' }); await contactApi.deleteContact(contactId.value); toast.success('删除成功'); setTimeout(() => { uni.navigateBack() }, 1000) } catch (e: any) { if (e !== 'cancel') { toast.error('删除失败') } } }
</script>
<style lang="scss" scoped>
// ==========================================
// 页面容器 - 浅色模式 (与设计稿完全一致)
// ==========================================
.detail-container {
--bg-base: #ffffff; // 白色页面背景
--bg-page: #f3f4f6; // gray-100 (页面背景)
--bg-card: #ffffff; // 卡片背景
--bg-secondary: #f3f4f6; // gray-100 (次要背景/按钮)
--text-main: #1f2937; // gray-900 (名称)
--text-sub: #6b7280; // gray-500 (签名/标签)
--text-tertiary: #9ca3af; // gray-400 ()
--bg-tag: #f3f4f6; // gray-100 (标签背景)
--bg-toggle: #e5e7eb; // gray-200 (开关关闭)
--text-main: #111827; // gray-900 (名称)
--text-sub: #6b7280; // gray-500 (签名)
--text-tertiary: #9ca3af; // gray-400 (标签/值)
--color-brand: #4F46E5; // indigo-600
--border-color: #f9fafb; // gray-50
--gradient-to: #ffffff; // 渐变终点色
min-height: 100vh;
background: var(--bg-base);
background: var(--bg-page);
position: relative;
// 深色模式 - Warm Stone (与设计稿完全一致)
&.theme-dark {
--bg-base: #0c0a09; // warm-950 (极深背景)
--bg-page: #0c0a09; // warm-950
--bg-card: #1c1917; // warm-900
--bg-secondary: #292524; // warm-800
--text-main: #f5f5f4; // warm-100
--bg-tag: #292524; // warm-800
--bg-toggle: #44403c; // warm-700
--text-main: #fafaf9; // warm-50
--text-sub: #a8a29e; // warm-400
--text-tertiary: #78716c; // warm-500
--color-brand: #f97316; // orange-500
--border-color: rgba(41, 37, 36, 0.5); // warm-800/50
--border-color: rgba(41, 37, 36, 0.5);
--gradient-to: #0c0a09; // warm-950
}
}
// 动画
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up {
@@ -244,304 +223,350 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
scrollbar-width: none;
}
// 视差头部背景
.profile-header {
// ==========================================
// 视差背景 (与设计稿一致: blur-xl scale-110 opacity-70)
// ==========================================
.parallax-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 600rpx;
z-index: 0;
overflow: hidden;
z-index: 0;
.blur-bg {
.blur-image {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
filter: blur(40px);
transform: scale(1.1);
opacity: 0.7;
// blur-xl = 24px, 需要用px单位
filter: blur(24px);
-webkit-filter: blur(24px);
transform: scale(1.1); // scale-110
opacity: 0.7; // opacity-70
.theme-dark & {
opacity: 0.4;
opacity: 0.5;
}
}
.gradient-mask {
.gradient-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 0%, var(--gradient-to) 85%);
}
.nav-back {
position: absolute;
top: calc(var(--status-bar-height) + 16rpx);
left: 24rpx;
width: 72rpx;
height: 72rpx;
background: rgba(0, 0, 0, 0.15);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
z-index: 10;
transition: all 0.15s;
svg {
width: 40rpx;
height: 40rpx;
color: #fff;
}
&:active {
transform: scale(0.95);
background: rgba(0, 0, 0, 0.25);
}
background: linear-gradient(to bottom, transparent 0%, var(--gradient-to) 100%);
}
}
// 主内容
.main-content {
height: 100vh;
// ==========================================
// 导航栏 (与设计稿一致)
// ==========================================
.nav-header {
position: relative;
z-index: 10;
padding: calc(var(--status-bar-height) + 24rpx) 32rpx 16rpx;
display: flex;
justify-content: space-between;
align-items: center;
.nav-back, .nav-more {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.15s;
svg {
width: 44rpx;
height: 44rpx;
color: var(--text-main);
.theme-dark & {
color: #e7e5e4; // warm-200
}
}
&:active {
opacity: 0.6;
}
}
.nav-more svg {
width: 40rpx;
height: 40rpx;
}
}
// ==========================================
// 主体滚动区
// ==========================================
.main-scroll {
height: calc(100vh - 120rpx);
position: relative;
z-index: 1;
}
.content-wrapper {
padding: 0 32rpx;
padding-top: calc(220rpx + var(--status-bar-height));
}
// 身份卡片
.identity-card {
// ==========================================
// 身份卡片区 (与设计稿完全一致)
// ==========================================
.identity-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 60rpx;
padding: 64rpx 48rpx 64rpx;
// 头像: w-32 h-32 rounded-full border-[6px] border-white shadow-xl
.avatar-wrapper {
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.15);
border-radius: 50%;
border: 12rpx solid var(--bg-card); // border-[6px] = 12rpx
box-shadow: 0 20rpx 50rpx rgba(0, 0, 0, 0.2); // shadow-xl
margin-bottom: 32rpx;
}
.info-block {
margin-top: 30rpx;
text-align: center;
.user-name {
font-size: 48rpx;
font-weight: 700;
color: var(--text-main);
margin-bottom: 16rpx;
}
.meta-row {
display: flex;
justify-content: center;
gap: 16rpx;
margin-bottom: 24rpx;
.meta-tag {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 24rpx;
color: var(--text-sub);
background: var(--bg-secondary);
padding: 10rpx 20rpx;
border-radius: 20rpx;
svg {
width: 24rpx;
height: 24rpx;
}
}
}
.bio-text {
font-size: 28rpx;
color: var(--text-sub);
max-width: 500rpx;
line-height: 1.6;
}
}
}
// 设置组
.settings-group {
margin-bottom: 40rpx;
.group-label {
font-size: 26rpx;
color: var(--text-sub);
// 名字: text-2xl font-bold
.user-name {
font-size: 48rpx; // text-2xl = 24px = 48rpx
font-weight: 700; // font-bold
color: var(--text-main);
margin-bottom: 16rpx;
margin-left: 12rpx;
font-weight: 600;
}
// 标签行
.tag-row {
display: flex;
gap: 16rpx;
margin-bottom: 32rpx;
}
// 标签: text-xs bg-gray-100 px-2 py-0.5 rounded
.info-tag {
font-size: 24rpx; // text-xs = 12px = 24rpx
color: var(--text-tertiary);
background: var(--bg-tag);
padding: 8rpx 16rpx; // px-2 py-0.5
border-radius: 8rpx; // rounded
}
// 签名: text-sm text-gray-500 text-center max-w-[200px]
.bio-text {
font-size: 28rpx; // text-sm = 14px = 28rpx
color: var(--text-sub);
text-align: center;
line-height: 1.6; // leading-relaxed
max-width: 400rpx; // max-w-[200px] = 400rpx
}
}
// ==========================================
// 设置卡片 (与设计稿一致: rounded-2xl shadow-sm border)
// ==========================================
.settings-card {
margin: 0 24rpx 48rpx;
background: var(--bg-card);
border-radius: 24rpx;
border-radius: 32rpx; // rounded-2xl = 16px = 32rpx
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); // shadow-sm
border: 2rpx solid var(--border-color);
overflow: hidden;
.theme-dark & {
box-shadow: none;
border-color: rgba(41, 37, 36, 0.5);
}
}
.setting-item {
// 设置行 (与设计稿一致)
.setting-row {
display: flex;
align-items: center;
padding: 28rpx 28rpx;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.04);
justify-content: space-between;
padding: 32rpx; // p-4 = 16px = 32rpx
border-bottom: 2rpx solid var(--border-color);
transition: background 0.15s;
.theme-dark & {
border-bottom-color: rgba(255, 255, 255, 0.06);
border-bottom-color: rgba(41, 37, 36, 0.5);
}
&:last-child {
&.no-border {
border-bottom: none;
}
&:active {
background: rgba(0, 0, 0, 0.02);
.theme-dark & {
background: rgba(255, 255, 255, 0.02);
}
}
.icon-wrap {
width: 60rpx;
height: 60rpx;
border-radius: 16rpx;
.row-label {
font-size: 28rpx; // text-sm = 14px = 28rpx
font-weight: 500; // font-medium
color: var(--text-main);
.theme-dark & {
color: #e7e5e4; // warm-200
}
}
.row-right {
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
svg {
width: 32rpx;
height: 32rpx;
}
&.color-blue { background: #0ea5e9; }
&.color-orange { background: #f97316; }
&.color-purple { background: #8b5cf6; }
&.color-red { background: #ef4444; }
gap: 8rpx;
}
.item-body {
flex: 1;
display: flex;
flex-direction: column;
.row-value {
font-size: 24rpx; // text-xs
color: var(--text-tertiary);
}
.item-title {
font-size: 30rpx;
color: var(--text-main);
font-weight: 500;
}
.item-value {
font-size: 26rpx;
color: var(--text-sub);
margin-top: 4rpx;
}
.chevron {
.chevron-icon {
width: 32rpx;
height: 32rpx;
color: var(--text-sub);
opacity: 0.5;
color: #d1d5db; // gray-300
}
}
.spacer {
height: 320rpx;
// 自定义开关 (与设计稿一致)
.toggle-wrap {
display: flex;
align-items: center;
}
// 底部操作台
.custom-toggle {
width: 80rpx; // w-10 = 40px = 80rpx
height: 48rpx; // h-6 = 24px = 48rpx
background: var(--bg-toggle);
border-radius: 48rpx; // rounded-full
position: relative;
transition: background 0.2s;
.toggle-thumb {
position: absolute;
left: 8rpx;
top: 8rpx;
width: 32rpx; // w-4 = 16px = 32rpx
height: 32rpx; // h-4 = 16px = 32rpx
background: #fff;
border-radius: 50%;
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1); // shadow-sm
transition: transform 0.2s;
}
&.active {
background: var(--color-brand);
.toggle-thumb {
transform: translateX(32rpx); // 移动到右边
}
}
}
// ==========================================
// 底部悬浮操作栏 (与设计稿一致: rounded-t-[32px] shadow-[0_-10px_40px])
// ==========================================
.floating-dock {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 30rpx 40rpx;
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
padding: 32rpx 48rpx; // px-6 py-4
padding-bottom: calc(64rpx + env(safe-area-inset-bottom)); // pb-8
background: var(--bg-card);
border-top: 1rpx solid rgba(0, 0, 0, 0.05);
border-radius: 64rpx 64rpx 0 0;
box-shadow: 0 -20rpx 80rpx rgba(0, 0, 0, 0.05);
border-top: 2rpx solid var(--border-color);
border-radius: 64rpx 64rpx 0 0; // rounded-t-[32px] = 64rpx
box-shadow: 0 -20rpx 80rpx rgba(0, 0, 0, 0.05); // shadow-[0_-10px_40px]
z-index: 100;
.theme-dark & {
border-top-color: rgba(255, 255, 255, 0.05);
border-top-color: #292524; // warm-800
}
}
// 按钮行
.dock-buttons {
display: flex;
gap: 32rpx; // gap-4 = 16px = 32rpx
margin-bottom: 32rpx; // mb-4
}
.dock-btn {
height: 96rpx; // h-12 = 48px = 96rpx
border-radius: 32rpx; // rounded-2xl
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
transition: all 0.15s;
svg {
width: 40rpx; // w-5 h-5 = 20px = 40rpx
height: 40rpx;
}
.dock-content {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 24rpx;
&:active {
transform: scale(0.95);
opacity: 0.9;
}
.dock-btn {
height: 100rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
// 次要按钮: flex-1
&.secondary {
flex: 1;
background: var(--bg-tag);
color: var(--text-main);
svg {
width: 44rpx;
height: 44rpx;
}
.theme-dark & {
background: #292524; // warm-800
color: #e7e5e4; // warm-200
}
&:active {
transform: scale(0.96);
}
&.primary {
flex: 2;
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
color: #fff;
gap: 16rpx;
font-weight: 600;
font-size: 32rpx;
box-shadow: 0 8rpx 24rpx rgba(79, 70, 229, 0.25);
.theme-dark & {
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
box-shadow: 0 8rpx 24rpx rgba(249, 115, 22, 0.25);
}
}
&.secondary {
flex: 1;
background: var(--bg-secondary);
color: var(--text-main);
&:hover {
background: #e5e7eb; // gray-200
.theme-dark & {
background: #44403c; // warm-700
}
}
}
.danger-btn {
text-align: center;
// 主要按钮: flex-[2]
&.primary {
flex: 2;
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
color: #fff;
font-weight: 700; // font-bold
font-size: 28rpx;
box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.3);
text {
color: var(--text-sub);
font-size: 26rpx;
padding: 10rpx 20rpx;
opacity: 0.6;
svg {
stroke: #fff;
}
&:active text {
opacity: 1;
color: #ef4444;
.theme-dark & {
background: linear-gradient(135deg, #fb923c 0%, #ea580c 100%);
box-shadow: 0 8rpx 24rpx rgba(234, 88, 12, 0.3);
}
}
}
// 删除好友链接: text-xs text-gray-400 underline
.delete-link {
text-align: center;
text {
font-size: 24rpx; // text-xs
color: var(--text-tertiary);
text-decoration: underline; // underline
}
&:active text {
color: #ef4444; // hover:text-red-500
}
}
// 底部留白
.bottom-spacer {
height: 360rpx;
}
// 加载中
.center-loading {
position: absolute;
top: 50%;
@@ -549,3 +574,4 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
transform: translate(-50%, -50%);
}
</style>

View File

@@ -32,22 +32,6 @@
</view>
</view>
<!-- 2. 搜索栏 (扁平化设计) -->
<view class="search-wrapper">
<view class="search-bar" @click="goSearch">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="text"
class="search-input"
placeholder="搜索"
disabled
/>
</view>
</view>
<!-- 3. 会话列表 -->
<scroll-view
class="conversation-list custom-scrollbar"
@@ -56,6 +40,22 @@
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- 2. 搜索栏 (扁平化设计) -->
<view class="search-wrapper">
<view class="search-bar" @click="goSearch">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="text"
class="search-input"
placeholder="搜索"
disabled
/>
</view>
</view>
<view v-if="loading && conversations.length === 0" class="loading-state">
<wd-loading size="50rpx" color="var(--color-primary)" />
<text>连接中...</text>