upload project

This commit is contained in:
unknown 2025-12-27 17:16:03 +08:00
commit 06961cae04
422 changed files with 110626 additions and 0 deletions

View file

@ -0,0 +1,550 @@
// 🎨 媒体预览组件逻辑
const mediaManager = require('../../subpackages/media/utils/media-manager.js');
Component({
properties: {
// 是否显示预览
visible: {
type: Boolean,
value: false
},
// 媒体列表
mediaList: {
type: Array,
value: []
},
// 当前索引
currentIndex: {
type: Number,
value: 0
},
// 是否可以分享
canShare: {
type: Boolean,
value: true
},
// 是否可以编辑
canEdit: {
type: Boolean,
value: false
},
// 是否可以删除
canDelete: {
type: Boolean,
value: false
},
// 是否显示底部操作栏
showFooter: {
type: Boolean,
value: true
},
// 是否显示手势提示
showGestureTips: {
type: Boolean,
value: true
}
},
data: {
// 当前媒体
currentMedia: {},
// 音频播放状态
audioPlaying: false,
audioProgress: 0,
audioCurrentTime: 0,
// 手势提示定时器
gestureTimer: null
},
observers: {
'mediaList, currentIndex': function(mediaList, currentIndex) {
if (mediaList && mediaList.length > 0 && currentIndex >= 0 && currentIndex < mediaList.length) {
this.setData({
currentMedia: mediaList[currentIndex]
});
}
}
},
lifetimes: {
attached() {
},
detached() {
this.cleanup();
}
},
methods: {
// 🎨 ===== 基础操作 =====
// 阻止事件冒泡
stopPropagation() {
// 阻止点击事件冒泡到遮罩层
},
// 遮罩点击
onMaskTap() {
this.closePreview();
},
// 关闭预览
closePreview() {
this.setData({
visible: false
});
this.triggerEvent('close');
this.cleanup();
},
// 清理资源
cleanup() {
// 停止音频播放
if (this.data.audioPlaying) {
this.stopAudio();
}
// 清理定时器
if (this.data.gestureTimer) {
clearTimeout(this.data.gestureTimer);
}
},
// 🎨 ===== 图片操作 =====
// 轮播图切换
onSwiperChange(e) {
const currentIndex = e.detail.current;
this.setData({
currentIndex: currentIndex
});
this.triggerEvent('indexchange', {
currentIndex: currentIndex
});
},
// 图片加载完成
onImageLoad(e) {
const index = e.currentTarget.dataset.index;
const mediaList = this.data.mediaList;
if (mediaList[index]) {
mediaList[index].loading = false;
mediaList[index].error = false;
this.setData({
mediaList: mediaList
});
}
},
// 图片加载失败
onImageError(e) {
console.error('❌ 图片加载失败');
const index = e.currentTarget.dataset.index;
const mediaList = this.data.mediaList;
if (mediaList[index]) {
mediaList[index].loading = false;
mediaList[index].error = true;
this.setData({
mediaList: mediaList
});
}
},
// 图片点击
onImageTap(e) {
// 可以实现双击放大等功能
},
// 重试加载
retryLoad(e) {
const index = e.currentTarget.dataset.index;
const mediaList = this.data.mediaList;
if (mediaList[index]) {
mediaList[index].loading = true;
mediaList[index].error = false;
this.setData({
mediaList: mediaList
});
}
},
// 🎨 ===== 视频操作 =====
// 视频播放
onVideoPlay() {
this.triggerEvent('videoplay');
},
// 视频暂停
onVideoPause() {
this.triggerEvent('videopause');
},
// 视频结束
onVideoEnded() {
this.triggerEvent('videoended');
},
// 视频错误
onVideoError(e) {
console.error('❌ 视频播放错误:', e.detail);
wx.showToast({
title: '视频播放失败',
icon: 'none'
});
},
// 视频时间更新
onVideoTimeUpdate(e) {
// 可以用于显示播放进度
},
// 🎨 ===== 音频操作 =====
// 切换音频播放
toggleAudioPlay() {
if (this.data.audioPlaying) {
this.pauseAudio();
} else {
this.playAudio();
}
},
// 播放音频
playAudio() {
// 这里需要实现音频播放逻辑
this.setData({
audioPlaying: true
});
// 模拟播放进度
this.startAudioProgress();
},
// 暂停音频
pauseAudio() {
this.setData({
audioPlaying: false
});
this.stopAudioProgress();
},
// 停止音频
stopAudio() {
this.setData({
audioPlaying: false,
audioProgress: 0,
audioCurrentTime: 0
});
this.stopAudioProgress();
},
// 开始音频进度更新
startAudioProgress() {
this.audioProgressTimer = setInterval(() => {
const currentTime = this.data.audioCurrentTime + 1;
const duration = this.data.currentMedia.duration || 100;
const progress = (currentTime / duration) * 100;
this.setData({
audioCurrentTime: currentTime,
audioProgress: Math.min(progress, 100)
});
if (progress >= 100) {
this.stopAudio();
}
}, 1000);
},
// 停止音频进度更新
stopAudioProgress() {
if (this.audioProgressTimer) {
clearInterval(this.audioProgressTimer);
this.audioProgressTimer = null;
}
},
// 🎨 ===== 文件操作 =====
// 打开文件
openFile() {
const currentMedia = this.data.currentMedia;
wx.openDocument({
filePath: currentMedia.tempFilePath || currentMedia.url,
fileType: currentMedia.extension,
success: () => {
},
fail: (error) => {
console.error('❌ 文件打开失败:', error);
wx.showToast({
title: '无法打开此文件',
icon: 'none'
});
}
});
},
// 保存文件
async saveFile() {
const currentMedia = this.data.currentMedia;
try {
wx.showLoading({
title: '保存中...'
});
// 如果是网络文件,先下载
let filePath = currentMedia.tempFilePath;
if (!filePath && currentMedia.url) {
const downloadResult = await mediaManager.downloadFile(currentMedia.url);
if (downloadResult.success) {
filePath = downloadResult.tempFilePath;
} else {
throw new Error('下载失败');
}
}
// 保存到本地
const result = await new Promise((resolve, reject) => {
wx.saveFile({
tempFilePath: filePath,
success: resolve,
fail: reject
});
});
wx.hideLoading();
wx.showToast({
title: '保存成功',
icon: 'success'
});
} catch (error) {
wx.hideLoading();
console.error('❌ 文件保存失败:', error);
wx.showToast({
title: '保存失败',
icon: 'none'
});
}
},
// 🎨 ===== 操作按钮 =====
// 下载媒体
async downloadMedia() {
const currentMedia = this.data.currentMedia;
if (!currentMedia.url) {
wx.showToast({
title: '无法下载',
icon: 'none'
});
return;
}
try {
wx.showLoading({
title: '下载中...'
});
const result = await mediaManager.downloadFile(currentMedia.url, {
fileName: currentMedia.name
});
wx.hideLoading();
if (result.success) {
wx.showToast({
title: '下载完成',
icon: 'success'
});
this.triggerEvent('download', {
media: currentMedia,
filePath: result.tempFilePath
});
} else {
throw new Error(result.error);
}
} catch (error) {
wx.hideLoading();
console.error('❌ 下载失败:', error);
wx.showToast({
title: '下载失败',
icon: 'none'
});
}
},
// 分享媒体
shareMedia() {
const currentMedia = this.data.currentMedia;
this.triggerEvent('share', {
media: currentMedia
});
},
// 编辑媒体
editMedia() {
const currentMedia = this.data.currentMedia;
this.triggerEvent('edit', {
media: currentMedia,
index: this.data.currentIndex
});
},
// 删除媒体
deleteMedia() {
const currentMedia = this.data.currentMedia;
wx.showModal({
title: '删除确认',
content: '确定要删除这个文件吗?',
success: (res) => {
if (res.confirm) {
this.triggerEvent('delete', {
media: currentMedia,
index: this.data.currentIndex
});
}
}
});
},
// 收藏媒体
favoriteMedia() {
const currentMedia = this.data.currentMedia;
const favorited = !currentMedia.favorited;
// 更新收藏状态
currentMedia.favorited = favorited;
this.setData({
currentMedia: currentMedia
});
this.triggerEvent('favorite', {
media: currentMedia,
favorited: favorited
});
wx.showToast({
title: favorited ? '已收藏' : '已取消收藏',
icon: 'success'
});
},
// 显示更多操作
showMoreActions() {
const actions = ['转发', '设为壁纸', '添加到相册', '举报'];
wx.showActionSheet({
itemList: actions,
success: (res) => {
this.triggerEvent('moreaction', {
action: actions[res.tapIndex],
media: this.data.currentMedia
});
}
});
},
// 🎨 ===== 工具方法 =====
// 格式化文件大小
formatFileSize(size) {
if (!size) return '未知大小';
const units = ['B', 'KB', 'MB', 'GB'];
let unitIndex = 0;
let fileSize = size;
while (fileSize >= 1024 && unitIndex < units.length - 1) {
fileSize /= 1024;
unitIndex++;
}
return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
},
// 格式化时长
formatDuration(duration) {
if (!duration) return '00:00';
const minutes = Math.floor(duration / 60);
const seconds = Math.floor(duration % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
},
// 格式化时间
formatTime(time) {
if (!time) return '00:00';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
},
// 获取文件图标
getFileIcon(extension) {
const iconMap = {
'pdf': '📄',
'doc': '📝',
'docx': '📝',
'xls': '📊',
'xlsx': '📊',
'ppt': '📽️',
'pptx': '📽️',
'txt': '📃',
'zip': '🗜️',
'rar': '🗜️',
'mp3': '🎵',
'wav': '🎵',
'mp4': '🎬',
'avi': '🎬'
};
return iconMap[extension] || '📄';
}
}
});

View file

@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View file

@ -0,0 +1,192 @@
<!-- 🎨 媒体预览组件 -->
<view class="media-preview-container" wx:if="{{visible}}" bindtap="onMaskTap">
<!-- 背景遮罩 -->
<view class="preview-mask"></view>
<!-- 预览内容 -->
<view class="preview-content" catchtap="stopPropagation">
<!-- 头部工具栏 -->
<view class="preview-header">
<view class="header-info">
<text class="media-title">{{currentMedia.name || '媒体预览'}}</text>
<text class="media-info" wx:if="{{currentMedia.size}}">
{{formatFileSize(currentMedia.size)}}
</text>
</view>
<view class="header-actions">
<!-- 下载按钮 -->
<view class="action-btn" bindtap="downloadMedia" wx:if="{{currentMedia.url}}">
<text class="action-icon">📥</text>
</view>
<!-- 分享按钮 -->
<view class="action-btn" bindtap="shareMedia" wx:if="{{canShare}}">
<text class="action-icon">📤</text>
</view>
<!-- 关闭按钮 -->
<view class="action-btn close-btn" bindtap="closePreview">
<text class="action-icon">✕</text>
</view>
</view>
</view>
<!-- 媒体内容区域 -->
<view class="media-container">
<!-- 图片预览 -->
<view class="image-preview" wx:if="{{currentMedia.type === 'image'}}">
<swiper class="image-swiper"
current="{{currentIndex}}"
bindchange="onSwiperChange"
indicator-dots="{{mediaList.length > 1}}"
indicator-color="rgba(255, 255, 255, 0.3)"
indicator-active-color="rgba(255, 255, 255, 0.8)">
<swiper-item wx:for="{{mediaList}}" wx:key="index" wx:if="{{item.type === 'image'}}">
<view class="image-item">
<image class="preview-image"
src="{{item.url || item.tempFilePath}}"
mode="aspectFit"
bindload="onImageLoad"
binderror="onImageError"
bindtap="onImageTap"
data-index="{{index}}" />
<!-- 加载状态 -->
<view class="loading-overlay" wx:if="{{item.loading}}">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 错误状态 -->
<view class="error-overlay" wx:if="{{item.error}}">
<text class="error-icon">❌</text>
<text class="error-text">加载失败</text>
<view class="retry-btn" bindtap="retryLoad" data-index="{{index}}">
<text class="retry-text">重试</text>
</view>
</view>
</view>
</swiper-item>
</swiper>
<!-- 图片计数 -->
<view class="image-counter" wx:if="{{mediaList.length > 1}}">
<text class="counter-text">{{currentIndex + 1}} / {{mediaList.length}}</text>
</view>
</view>
<!-- 视频预览 -->
<view class="video-preview" wx:if="{{currentMedia.type === 'video'}}">
<video class="preview-video"
src="{{currentMedia.url || currentMedia.tempFilePath}}"
poster="{{currentMedia.thumbnailPath}}"
controls="{{true}}"
autoplay="{{false}}"
loop="{{false}}"
muted="{{false}}"
show-center-play-btn="{{true}}"
show-play-btn="{{true}}"
show-fullscreen-btn="{{true}}"
bindplay="onVideoPlay"
bindpause="onVideoPause"
bindended="onVideoEnded"
binderror="onVideoError"
bindtimeupdate="onVideoTimeUpdate">
</video>
<!-- 视频信息 -->
<view class="video-info">
<text class="video-duration">{{formatDuration(currentMedia.duration)}}</text>
<text class="video-size">{{currentMedia.width}}×{{currentMedia.height}}</text>
</view>
</view>
<!-- 文件预览 -->
<view class="file-preview" wx:if="{{currentMedia.type === 'file'}}">
<view class="file-icon-container">
<text class="file-icon">{{getFileIcon(currentMedia.extension)}}</text>
</view>
<view class="file-details">
<text class="file-name">{{currentMedia.name}}</text>
<text class="file-size">{{formatFileSize(currentMedia.size)}}</text>
<text class="file-type">{{currentMedia.extension.toUpperCase()}} 文件</text>
</view>
<view class="file-actions">
<view class="file-action-btn" bindtap="openFile">
<text class="action-text">打开文件</text>
</view>
<view class="file-action-btn" bindtap="saveFile">
<text class="action-text">保存到本地</text>
</view>
</view>
</view>
<!-- 音频预览 -->
<view class="audio-preview" wx:if="{{currentMedia.type === 'audio'}}">
<view class="audio-player">
<view class="audio-cover">
<text class="audio-icon">🎵</text>
</view>
<view class="audio-controls">
<view class="play-btn {{audioPlaying ? 'playing' : ''}}" bindtap="toggleAudioPlay">
<text class="play-icon">{{audioPlaying ? '⏸️' : '▶️'}}</text>
</view>
<view class="audio-progress">
<view class="progress-bar">
<view class="progress-fill" style="width: {{audioProgress}}"></view>
</view>
<view class="time-info">
<text class="current-time">{{formatTime(audioCurrentTime)}}</text>
<text class="total-time">{{formatTime(currentMedia.duration)}}</text>
</view>
</view>
</view>
</view>
<view class="audio-info">
<text class="audio-name">{{currentMedia.name}}</text>
<text class="audio-size">{{formatFileSize(currentMedia.size)}}</text>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="preview-footer" wx:if="{{showFooter}}">
<view class="footer-actions">
<!-- 编辑按钮 -->
<view class="footer-btn" bindtap="editMedia" wx:if="{{canEdit}}">
<text class="footer-icon">✏️</text>
<text class="footer-text">编辑</text>
</view>
<!-- 删除按钮 -->
<view class="footer-btn" bindtap="deleteMedia" wx:if="{{canDelete}}">
<text class="footer-icon">🗑️</text>
<text class="footer-text">删除</text>
</view>
<!-- 收藏按钮 -->
<view class="footer-btn" bindtap="favoriteMedia">
<text class="footer-icon">{{currentMedia.favorited ? '❤️' : '🤍'}}</text>
<text class="footer-text">{{currentMedia.favorited ? '已收藏' : '收藏'}}</text>
</view>
<!-- 更多按钮 -->
<view class="footer-btn" bindtap="showMoreActions">
<text class="footer-icon">⋯</text>
<text class="footer-text">更多</text>
</view>
</view>
</view>
</view>
<!-- 手势操作提示 -->
<view class="gesture-tips" wx:if="{{showGestureTips}}">
<text class="tips-text">双击放大 · 滑动切换 · 点击关闭</text>
</view>
</view>

View file

@ -0,0 +1,586 @@
/* 🎨 媒体预览组件样式 */
/* CSS变量定义 */
.media-preview-container {
--preview-bg: rgba(0, 0, 0, 0.9);
--header-bg: rgba(0, 0, 0, 0.7);
--text-primary: #FFFFFF;
--text-secondary: rgba(255, 255, 255, 0.7);
--button-bg: rgba(255, 255, 255, 0.1);
--button-active: rgba(255, 255, 255, 0.2);
--border-color: rgba(255, 255, 255, 0.2);
--shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.3);
}
/* 🎨 预览容器 */
.media-preview-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
flex-direction: column;
background: var(--preview-bg);
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 🎨 背景遮罩 */
.preview-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
}
/* 🎨 预览内容 */
.preview-content {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
}
/* 🎨 头部工具栏 */
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
background: var(--header-bg);
backdrop-filter: blur(20rpx);
border-bottom: 1rpx solid var(--border-color);
}
.header-info {
flex: 1;
min-width: 0;
}
.media-title {
display: block;
font-size: 32rpx;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 8rpx;
}
.media-info {
font-size: 26rpx;
color: var(--text-secondary);
}
.header-actions {
display: flex;
align-items: center;
gap: 16rpx;
}
.action-btn {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
background: var(--button-bg);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
backdrop-filter: blur(10rpx);
}
.action-btn:active {
background: var(--button-active);
transform: scale(0.9);
}
.action-btn.close-btn {
background: rgba(255, 59, 48, 0.8);
}
.action-icon {
font-size: 32rpx;
color: var(--text-primary);
}
/* 🎨 媒体容器 */
.media-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
/* 🎨 图片预览 */
.image-preview {
width: 100%;
height: 100%;
position: relative;
}
.image-swiper {
width: 100%;
height: 100%;
}
.image-item {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.preview-image {
max-width: 100%;
max-height: 100%;
border-radius: 16rpx;
box-shadow: var(--shadow);
}
.image-counter {
position: absolute;
bottom: 40rpx;
left: 50%;
transform: translateX(-50%);
padding: 12rpx 24rpx;
background: var(--header-bg);
border-radius: 24rpx;
backdrop-filter: blur(20rpx);
}
.counter-text {
font-size: 28rpx;
color: var(--text-primary);
font-weight: 500;
}
/* 🎨 视频预览 */
.video-preview {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
.preview-video {
width: 100%;
max-height: 80%;
border-radius: 16rpx;
box-shadow: var(--shadow);
}
.video-info {
position: absolute;
bottom: 40rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 24rpx;
padding: 12rpx 24rpx;
background: var(--header-bg);
border-radius: 24rpx;
backdrop-filter: blur(20rpx);
}
.video-duration,
.video-size {
font-size: 26rpx;
color: var(--text-secondary);
}
/* 🎨 文件预览 */
.file-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 40rpx;
text-align: center;
}
.file-icon-container {
width: 200rpx;
height: 200rpx;
border-radius: 32rpx;
background: var(--button-bg);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 40rpx;
backdrop-filter: blur(20rpx);
border: 2rpx solid var(--border-color);
}
.file-icon {
font-size: 120rpx;
}
.file-details {
margin-bottom: 60rpx;
}
.file-name {
display: block;
font-size: 36rpx;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16rpx;
word-break: break-word;
}
.file-size,
.file-type {
display: block;
font-size: 28rpx;
color: var(--text-secondary);
margin-bottom: 8rpx;
}
.file-actions {
display: flex;
gap: 24rpx;
}
.file-action-btn {
padding: 24rpx 48rpx;
background: var(--button-bg);
border-radius: 32rpx;
border: 1rpx solid var(--border-color);
transition: all 0.3s ease;
backdrop-filter: blur(20rpx);
}
.file-action-btn:active {
background: var(--button-active);
transform: scale(0.95);
}
.action-text {
font-size: 30rpx;
color: var(--text-primary);
font-weight: 500;
}
/* 🎨 音频预览 */
.audio-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 40rpx;
}
.audio-player {
display: flex;
flex-direction: column;
align-items: center;
gap: 40rpx;
margin-bottom: 60rpx;
}
.audio-cover {
width: 200rpx;
height: 200rpx;
border-radius: 100rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow);
}
.audio-icon {
font-size: 80rpx;
color: white;
}
.audio-controls {
display: flex;
align-items: center;
gap: 32rpx;
width: 100%;
max-width: 600rpx;
}
.play-btn {
width: 96rpx;
height: 96rpx;
border-radius: 48rpx;
background: var(--button-bg);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
backdrop-filter: blur(20rpx);
border: 2rpx solid var(--border-color);
}
.play-btn:active {
background: var(--button-active);
transform: scale(0.9);
}
.play-btn.playing {
background: rgba(52, 199, 89, 0.8);
}
.play-icon {
font-size: 40rpx;
color: var(--text-primary);
}
.audio-progress {
flex: 1;
}
.progress-bar {
height: 8rpx;
background: var(--button-bg);
border-radius: 4rpx;
margin-bottom: 16rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
border-radius: 4rpx;
transition: width 0.3s ease;
}
.time-info {
display: flex;
justify-content: space-between;
}
.current-time,
.total-time {
font-size: 24rpx;
color: var(--text-secondary);
}
.audio-info {
text-align: center;
}
.audio-name {
display: block;
font-size: 32rpx;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12rpx;
word-break: break-word;
}
.audio-size {
font-size: 26rpx;
color: var(--text-secondary);
}
/* 🎨 加载和错误状态 */
.loading-overlay,
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--preview-bg);
border-radius: 16rpx;
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid var(--border-color);
border-top: 4rpx solid var(--text-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 24rpx;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text,
.error-text {
font-size: 28rpx;
color: var(--text-secondary);
margin-bottom: 24rpx;
}
.error-icon {
font-size: 80rpx;
margin-bottom: 24rpx;
}
.retry-btn {
padding: 16rpx 32rpx;
background: var(--button-bg);
border-radius: 24rpx;
border: 1rpx solid var(--border-color);
transition: all 0.3s ease;
}
.retry-btn:active {
background: var(--button-active);
transform: scale(0.95);
}
.retry-text {
font-size: 26rpx;
color: var(--text-primary);
}
/* 🎨 底部操作栏 */
.preview-footer {
padding: 32rpx;
background: var(--header-bg);
backdrop-filter: blur(20rpx);
border-top: 1rpx solid var(--border-color);
}
.footer-actions {
display: flex;
justify-content: space-around;
align-items: center;
}
.footer-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 16rpx;
border-radius: 16rpx;
transition: all 0.3s ease;
min-width: 120rpx;
}
.footer-btn:active {
background: var(--button-bg);
transform: scale(0.95);
}
.footer-icon {
font-size: 32rpx;
}
.footer-text {
font-size: 24rpx;
color: var(--text-secondary);
}
/* 🎨 手势提示 */
.gesture-tips {
position: absolute;
bottom: 160rpx;
left: 50%;
transform: translateX(-50%);
padding: 16rpx 32rpx;
background: var(--header-bg);
border-radius: 32rpx;
backdrop-filter: blur(20rpx);
animation: tipsFadeIn 0.5s ease-out 1s both;
}
@keyframes tipsFadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(20rpx);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.tips-text {
font-size: 24rpx;
color: var(--text-secondary);
text-align: center;
}
/* 📱 响应式设计 */
@media screen and (max-width: 375px) {
.preview-header {
padding: 24rpx;
}
.media-title {
font-size: 28rpx;
}
.action-btn {
width: 64rpx;
height: 64rpx;
}
.action-icon {
font-size: 28rpx;
}
.file-icon-container {
width: 160rpx;
height: 160rpx;
}
.file-icon {
font-size: 96rpx;
}
}
@media screen and (min-width: 414px) {
.preview-header {
padding: 40rpx;
}
.media-title {
font-size: 36rpx;
}
.action-btn {
width: 80rpx;
height: 80rpx;
}
.action-icon {
font-size: 36rpx;
}
.file-icon-container {
width: 240rpx;
height: 240rpx;
}
.file-icon {
font-size: 140rpx;
}
}

View file

@ -0,0 +1,179 @@
// 💬 @提醒选择组件逻辑
const groupChatManager = require('../../utils/group-chat-manager.js');
Component({
properties: {
// 是否显示
visible: {
type: Boolean,
value: false
},
// 群ID
groupId: {
type: String,
value: ''
},
// 当前用户ID
currentUserId: {
type: String,
value: ''
},
// 是否显示@全体成员
showMentionAll: {
type: Boolean,
value: true
}
},
data: {
// 成员数据
allMembers: [],
filteredMembers: [],
// 搜索关键词
searchKeyword: '',
// 加载状态
loading: false
},
observers: {
'visible': function(visible) {
if (visible && this.data.groupId) {
this.loadGroupMembers();
}
},
'groupId': function(groupId) {
if (groupId && this.data.visible) {
this.loadGroupMembers();
}
}
},
methods: {
// 加载群成员
async loadGroupMembers() {
if (!this.data.groupId) return;
try {
this.setData({
loading: true
});
const result = await groupChatManager.getGroupMembers(this.data.groupId);
if (result.success) {
// 过滤掉当前用户
const members = result.data.filter(member => member.userId !== this.data.currentUserId);
this.setData({
allMembers: members,
loading: false
});
// 应用搜索过滤
this.applyFilter();
} else {
throw new Error(result.error || '获取群成员失败');
}
} catch (error) {
this.setData({
loading: false
});
console.error('❌ 加载群成员失败:', error);
wx.showToast({
title: '加载成员失败',
icon: 'none'
});
}
},
// 搜索输入
onSearchInput(e) {
const keyword = e.detail.value;
this.setData({
searchKeyword: keyword
});
this.applyFilter();
},
// 清除搜索
clearSearch() {
this.setData({
searchKeyword: ''
});
this.applyFilter();
},
// 应用搜索过滤
applyFilter() {
const keyword = this.data.searchKeyword.toLowerCase();
let filtered = this.data.allMembers;
if (keyword) {
filtered = this.data.allMembers.filter(member => {
const name = (member.nickname || member.username || '').toLowerCase();
return name.includes(keyword);
});
}
this.setData({
filteredMembers: filtered
});
},
// @全体成员
onMentionAll() {
this.triggerEvent('mention', {
type: 'all',
text: '所有人',
userIds: this.data.allMembers.map(member => member.userId)
});
this.onClose();
},
// @特定成员
onMentionMember(e) {
const member = e.currentTarget.dataset.member;
this.triggerEvent('mention', {
type: 'user',
text: member.nickname || member.username,
userId: member.userId,
userIds: [member.userId]
});
this.onClose();
},
// 关闭选择器
onClose() {
this.setData({
searchKeyword: '',
filteredMembers: this.data.allMembers
});
this.triggerEvent('close');
},
// 点击遮罩
onMaskTap() {
this.onClose();
},
// 阻止事件冒泡
stopPropagation() {
// 阻止点击事件冒泡
}
}
});

View file

@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View file

@ -0,0 +1,85 @@
<!-- 💬 @提醒选择组件 -->
<view class="mention-selector-container" wx:if="{{visible}}" bindtap="onMaskTap">
<view class="selector-content" catchtap="stopPropagation">
<!-- 选择器头部 -->
<view class="selector-header">
<text class="header-title">选择要@的成员</text>
<view class="close-btn" bindtap="onClose">
<text class="close-icon">✕</text>
</view>
</view>
<!-- 搜索框 -->
<view class="search-container">
<view class="search-input-wrapper">
<text class="search-icon">🔍</text>
<input class="search-input"
placeholder="搜索群成员"
value="{{searchKeyword}}"
bindinput="onSearchInput" />
<view wx:if="{{searchKeyword}}"
class="clear-search"
bindtap="clearSearch">
<text class="clear-icon">✕</text>
</view>
</view>
</view>
<!-- 成员列表 -->
<scroll-view class="members-list" scroll-y="true">
<!-- @全体成员 -->
<view wx:if="{{showMentionAll && !searchKeyword}}"
class="member-item mention-all"
bindtap="onMentionAll">
<view class="member-avatar-container">
<view class="mention-all-avatar">
<text class="mention-all-icon">@</text>
</view>
</view>
<view class="member-info">
<text class="member-name">所有人</text>
<text class="member-desc">@全体成员</text>
</view>
<view class="member-action">
<text class="action-text">@</text>
</view>
</view>
<!-- 群成员 -->
<view class="member-item"
wx:for="{{filteredMembers}}"
wx:key="userId"
bindtap="onMentionMember"
data-member="{{item}}">
<view class="member-avatar-container">
<image class="member-avatar"
src="{{item.avatar || '/images/default-avatar.svg'}}"
mode="aspectFill" />
<view wx:if="{{item.role === 'owner'}}" class="role-badge owner">
<text class="role-text">群主</text>
</view>
<view wx:elif="{{item.role === 'admin'}}" class="role-badge admin">
<text class="role-text">管理员</text>
</view>
</view>
<view class="member-info">
<text class="member-name">{{item.nickname || item.username}}</text>
<text class="member-desc">{{item.status || ''}}</text>
</view>
<view class="member-action">
<text class="action-text">@</text>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" wx:if="{{filteredMembers.length === 0 && searchKeyword}}">
<text class="empty-icon">👥</text>
<text class="empty-text">没有找到相关成员</text>
</view>
</scroll-view>
</view>
</view>

View file

@ -0,0 +1,378 @@
/* 💬 @提醒选择组件样式 */
/* CSS变量定义 */
:host {
--primary-color: #007AFF;
--primary-light: #5AC8FA;
--success-color: #34C759;
--warning-color: #FF9500;
--background-color: #F2F2F7;
--surface-color: #FFFFFF;
--text-primary: #000000;
--text-secondary: #8E8E93;
--text-tertiary: #C7C7CC;
--border-color: #E5E5EA;
--shadow-light: 0 1rpx 3rpx rgba(0, 0, 0, 0.1);
--shadow-medium: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
--radius-small: 8rpx;
--radius-medium: 12rpx;
--radius-large: 20rpx;
}
/* 🌙 深色模式支持 */
@media (prefers-color-scheme: dark) {
:host {
--primary-color: #0A84FF;
--primary-light: #64D2FF;
--success-color: #30D158;
--warning-color: #FF9F0A;
--background-color: #000000;
--surface-color: #1C1C1E;
--text-primary: #FFFFFF;
--text-secondary: #8E8E93;
--text-tertiary: #48484A;
--border-color: #38383A;
--shadow-light: 0 1rpx 3rpx rgba(0, 0, 0, 0.3);
--shadow-medium: 0 4rpx 12rpx rgba(0, 0, 0, 0.4);
}
}
.mention-selector-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.selector-content {
width: 100%;
max-height: 80vh;
background: var(--surface-color);
border-radius: var(--radius-large) var(--radius-large) 0 0;
box-shadow: var(--shadow-medium);
animation: slideUp 0.3s ease-out;
display: flex;
flex-direction: column;
overflow: hidden;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* 🎨 选择器头部 */
.selector-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid var(--border-color);
background: var(--background-color);
}
.header-title {
font-size: 36rpx;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
width: 64rpx;
height: 64rpx;
border-radius: 32rpx;
background: var(--surface-color);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.close-btn:active {
background: var(--border-color);
transform: scale(0.9);
}
.close-icon {
font-size: 28rpx;
color: var(--text-secondary);
}
/* 🎨 搜索框 */
.search-container {
padding: 24rpx 32rpx;
background: var(--surface-color);
border-bottom: 1rpx solid var(--border-color);
}
.search-input-wrapper {
display: flex;
align-items: center;
background: var(--background-color);
border: 1rpx solid var(--border-color);
border-radius: var(--radius-small);
padding: 0 24rpx;
transition: all 0.3s ease;
}
.search-input-wrapper:focus-within {
border-color: var(--primary-color);
box-shadow: 0 0 0 4rpx rgba(0, 122, 255, 0.1);
}
.search-icon {
font-size: 28rpx;
color: var(--text-secondary);
margin-right: 16rpx;
}
.search-input {
flex: 1;
height: 80rpx;
font-size: 28rpx;
color: var(--text-primary);
}
.clear-search {
width: 48rpx;
height: 48rpx;
border-radius: 24rpx;
background: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.clear-search:active {
transform: scale(0.9);
}
.clear-icon {
font-size: 24rpx;
color: white;
}
/* 🎨 成员列表 */
.members-list {
flex: 1;
background: var(--surface-color);
}
.member-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid var(--border-color);
transition: all 0.2s ease;
}
.member-item:last-child {
border-bottom: none;
}
.member-item:active {
background: var(--background-color);
}
.member-item.mention-all {
background: rgba(0, 122, 255, 0.05);
}
.member-item.mention-all:active {
background: rgba(0, 122, 255, 0.1);
}
.member-avatar-container {
position: relative;
margin-right: 24rpx;
}
.member-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
border: 2rpx solid var(--border-color);
}
.mention-all-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid var(--border-color);
}
.mention-all-icon {
font-size: 36rpx;
color: white;
font-weight: bold;
}
.role-badge {
position: absolute;
bottom: -6rpx;
right: -6rpx;
padding: 4rpx 8rpx;
border-radius: 12rpx;
border: 2rpx solid var(--surface-color);
}
.role-badge.owner {
background: var(--warning-color);
}
.role-badge.admin {
background: var(--primary-color);
}
.role-text {
font-size: 20rpx;
color: white;
font-weight: 600;
}
.member-info {
flex: 1;
min-width: 0;
}
.member-name {
font-size: 30rpx;
font-weight: 500;
color: var(--text-primary);
display: block;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-desc {
font-size: 26rpx;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-action {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
border-radius: 32rpx;
background: var(--primary-color);
transition: all 0.2s ease;
}
.member-action:active {
transform: scale(0.9);
}
.action-text {
font-size: 28rpx;
color: white;
font-weight: bold;
}
/* 🎨 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 40rpx;
text-align: center;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 24rpx;
opacity: 0.5;
}
.empty-text {
font-size: 28rpx;
color: var(--text-secondary);
}
/* 📱 响应式设计 */
@media screen and (max-width: 375px) {
.selector-header,
.search-container,
.member-item {
padding-left: 24rpx;
padding-right: 24rpx;
}
.member-avatar,
.mention-all-avatar {
width: 64rpx;
height: 64rpx;
border-radius: 32rpx;
}
.mention-all-icon {
font-size: 28rpx;
}
.member-action {
width: 48rpx;
height: 48rpx;
border-radius: 24rpx;
}
.action-text {
font-size: 24rpx;
}
}
@media screen and (min-width: 414px) {
.selector-header,
.search-container,
.member-item {
padding-left: 40rpx;
padding-right: 40rpx;
}
.member-avatar,
.mention-all-avatar {
width: 96rpx;
height: 96rpx;
border-radius: 48rpx;
}
.mention-all-icon {
font-size: 40rpx;
}
.member-action {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
}
.action-text {
font-size: 32rpx;
}
}

View file

@ -0,0 +1,535 @@
// ✨ 消息操作菜单组件逻辑
const messageInteractionManager = require('../../utils/message-interaction-manager.js');
Component({
properties: {
// 是否显示菜单
visible: {
type: Boolean,
value: false
},
// 消息对象
message: {
type: Object,
value: {}
},
// 是否是自己的消息
isOwnMessage: {
type: Boolean,
value: false
},
// 可用的操作
actions: {
type: Object,
value: {
quote: true, // 引用回复
forward: true, // 转发
favorite: true, // 收藏
multiSelect: true, // 多选
copy: true, // 复制
recall: true, // 撤回
delete: true, // 删除
report: true // 举报
}
},
// 是否显示表情回应
showReactions: {
type: Boolean,
value: true
},
// 是否显示消息信息
showMessageInfo: {
type: Boolean,
value: false
}
},
data: {
// 常用表情
commonEmojis: ['👍', '❤️', '😂', '😮', '😢', '😡'],
// 是否可以撤回
canRecall: false,
// 表情选择器
showEmojiPicker: false,
currentEmojiCategory: 'recent',
currentEmojiList: [],
// 表情分类
emojiCategories: {
recent: ['👍', '❤️', '😂', '😮', '😢', '😡', '🎉', '🔥'],
smileys: ['😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🤩', '🥳'],
gestures: ['👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '🖕', '👇', '☝️', '👋', '🤚', '🖐️', '✋', '🖖', '👏', '🙌', '🤲', '🤝', '🙏'],
hearts: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟']
}
},
observers: {
'message, isOwnMessage': function(message, isOwnMessage) {
if (message && message.messageId) {
this.checkRecallPermission();
}
}
},
lifetimes: {
attached() {
this.initEmojiList();
}
},
methods: {
// ✨ ===== 基础操作 =====
// 阻止事件冒泡
stopPropagation() {
// 阻止点击事件冒泡到遮罩层
},
// 遮罩点击
onMaskTap() {
this.closeMenu();
},
// 关闭菜单
closeMenu() {
this.setData({
visible: false,
showEmojiPicker: false
});
this.triggerEvent('close');
},
// 👍 ===== 表情回应操作 =====
// 表情点击
async onReactionTap(e) {
const emoji = e.currentTarget.dataset.emoji;
try {
const userId = wx.getStorageSync('userId');
if (!userId) {
// 未登录用户不显示提示,直接跳转到登录页
wx.navigateTo({ url: '/pages/login/login' });
return;
}
// 添加表情回应
const result = await messageInteractionManager.addReaction(
this.data.message.messageId,
emoji,
userId
);
if (result.success) {
// 触发表情回应事件
this.triggerEvent('reaction', {
messageId: this.data.message.messageId,
emoji: emoji,
action: 'add'
});
// 关闭菜单
this.closeMenu();
wx.showToast({
title: '表情回应已添加',
icon: 'success'
});
} else {
wx.showToast({
title: result.error || '添加失败',
icon: 'none'
});
}
} catch (error) {
console.error('❌ 添加表情回应失败:', error);
wx.showToast({
title: '操作失败',
icon: 'none'
});
}
},
// 显示更多表情
showMoreEmojis() {
this.setData({
showEmojiPicker: true,
currentEmojiCategory: 'recent'
});
this.updateEmojiList();
},
// 关闭表情选择器
closeEmojiPicker() {
this.setData({
showEmojiPicker: false
});
},
// 切换表情分类
switchEmojiCategory(e) {
const category = e.currentTarget.dataset.category;
this.setData({
currentEmojiCategory: category
});
this.updateEmojiList();
},
// 表情选择
async onEmojiSelect(e) {
const emoji = e.currentTarget.dataset.emoji;
// 添加到最近使用
this.addToRecentEmojis(emoji);
// 执行表情回应
await this.onReactionTap({ currentTarget: { dataset: { emoji } } });
},
// 初始化表情列表
initEmojiList() {
this.setData({
currentEmojiList: this.data.emojiCategories.recent
});
},
// 更新表情列表
updateEmojiList() {
const category = this.data.currentEmojiCategory;
const emojiList = this.data.emojiCategories[category] || [];
this.setData({
currentEmojiList: emojiList
});
},
// 添加到最近使用表情
addToRecentEmojis(emoji) {
let recentEmojis = [...this.data.emojiCategories.recent];
// 移除已存在的
recentEmojis = recentEmojis.filter(e => e !== emoji);
// 添加到开头
recentEmojis.unshift(emoji);
// 限制数量
if (recentEmojis.length > 20) {
recentEmojis = recentEmojis.slice(0, 20);
}
// 更新数据
this.setData({
[`emojiCategories.recent`]: recentEmojis
});
// 如果当前显示的是最近分类,更新列表
if (this.data.currentEmojiCategory === 'recent') {
this.setData({
currentEmojiList: recentEmojis
});
}
},
// 🎯 ===== 操作按钮处理 =====
// 操作点击
async onActionTap(e) {
const action = e.currentTarget.dataset.action;
switch (action) {
case 'quote':
this.handleQuote();
break;
case 'forward':
this.handleForward();
break;
case 'favorite':
this.handleFavorite();
break;
case 'multiSelect':
this.handleMultiSelect();
break;
case 'copy':
this.handleCopy();
break;
case 'recall':
this.handleRecall();
break;
case 'delete':
this.handleDelete();
break;
case 'report':
this.handleReport();
break;
default:
console.warn('⚠️ 未知操作:', action);
}
},
// 处理引用回复
handleQuote() {
this.triggerEvent('action', {
action: 'quote',
message: this.data.message
});
this.closeMenu();
},
// 处理转发
handleForward() {
this.triggerEvent('action', {
action: 'forward',
message: this.data.message
});
this.closeMenu();
},
// 处理收藏
async handleFavorite() {
try {
const userId = wx.getStorageSync('userId');
const messageId = this.data.message.messageId;
const isFavorited = this.data.message.favorited;
let result;
if (isFavorited) {
result = await messageInteractionManager.unfavoriteMessage(messageId, userId);
} else {
result = await messageInteractionManager.favoriteMessage(messageId, userId);
}
if (result.success) {
this.triggerEvent('action', {
action: 'favorite',
message: this.data.message,
favorited: !isFavorited
});
wx.showToast({
title: isFavorited ? '已取消收藏' : '已收藏',
icon: 'success'
});
} else {
wx.showToast({
title: result.error || '操作失败',
icon: 'none'
});
}
} catch (error) {
console.error('❌ 收藏操作失败:', error);
wx.showToast({
title: '操作失败',
icon: 'none'
});
}
this.closeMenu();
},
// 处理多选
handleMultiSelect() {
this.triggerEvent('action', {
action: 'multiSelect',
message: this.data.message
});
this.closeMenu();
},
// 处理复制
handleCopy() {
if (this.data.message.msgType === 'text') {
wx.setClipboardData({
data: this.data.message.content,
success: () => {
wx.showToast({
title: '已复制到剪贴板',
icon: 'success'
});
}
});
}
this.closeMenu();
},
// 处理撤回
async handleRecall() {
try {
const userId = wx.getStorageSync('userId');
const messageId = this.data.message.messageId;
const result = await messageInteractionManager.recallMessage(messageId, userId);
if (result.success) {
this.triggerEvent('action', {
action: 'recall',
message: this.data.message
});
wx.showToast({
title: '消息已撤回',
icon: 'success'
});
} else {
wx.showToast({
title: result.error || '撤回失败',
icon: 'none'
});
}
} catch (error) {
console.error('❌ 撤回消息失败:', error);
wx.showToast({
title: '撤回失败',
icon: 'none'
});
}
this.closeMenu();
},
// 处理删除
handleDelete() {
wx.showModal({
title: '删除消息',
content: '确定要删除这条消息吗?',
success: (res) => {
if (res.confirm) {
this.triggerEvent('action', {
action: 'delete',
message: this.data.message
});
}
}
});
this.closeMenu();
},
// 处理举报
handleReport() {
wx.showActionSheet({
itemList: ['垃圾信息', '违法违规', '色情内容', '暴力内容', '其他'],
success: (res) => {
const reasons = ['spam', 'illegal', 'sexual', 'violence', 'other'];
const reason = reasons[res.tapIndex];
this.triggerEvent('action', {
action: 'report',
message: this.data.message,
reason: reason
});
}
});
this.closeMenu();
},
// 🔧 ===== 工具方法 =====
// 检查撤回权限
async checkRecallPermission() {
try {
const userId = wx.getStorageSync('userId');
const messageId = this.data.message.messageId;
if (!userId || !messageId) {
this.setData({ canRecall: false });
return;
}
const result = await messageInteractionManager.checkRecallPermission(messageId, userId);
this.setData({ canRecall: result.allowed });
} catch (error) {
console.error('❌ 检查撤回权限失败:', error);
this.setData({ canRecall: false });
}
},
// 格式化时间
formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
// 今天
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
} else if (diffDays === 1) {
// 昨天
return '昨天 ' + date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
} else {
// 更早
return date.toLocaleDateString('zh-CN') + ' ' + date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
}
},
// 获取消息类型文本
getMessageTypeText(msgType) {
const typeMap = {
'text': '文本',
'image': '图片',
'video': '视频',
'voice': '语音',
'file': '文件',
'location': '位置',
'card': '名片'
};
return typeMap[msgType] || '未知';
},
// 格式化文件大小
formatFileSize(size) {
if (!size) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let unitIndex = 0;
let fileSize = size;
while (fileSize >= 1024 && unitIndex < units.length - 1) {
fileSize /= 1024;
unitIndex++;
}
return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
}
}
});

View file

@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View file

@ -0,0 +1,191 @@
<!-- ✨ 消息操作菜单组件 -->
<view class="message-action-menu" wx:if="{{visible}}" bindtap="onMaskTap">
<!-- 背景遮罩 -->
<view class="menu-mask"></view>
<!-- 菜单内容 -->
<view class="menu-content" catchtap="stopPropagation">
<!-- 表情回应区域 -->
<view class="reactions-section" wx:if="{{showReactions}}">
<view class="reactions-title">
<text class="title-text">添加表情回应</text>
</view>
<view class="reactions-grid">
<view class="reaction-item"
wx:for="{{commonEmojis}}"
wx:key="index"
bindtap="onReactionTap"
data-emoji="{{item}}">
<text class="reaction-emoji">{{item}}</text>
</view>
<!-- 更多表情按钮 -->
<view class="reaction-item more-emoji" bindtap="showMoreEmojis">
<text class="more-icon"></text>
</view>
</view>
</view>
<!-- 操作按钮区域 -->
<view class="actions-section">
<!-- 引用回复 -->
<view class="action-item"
wx:if="{{actions.quote}}"
bindtap="onActionTap"
data-action="quote">
<view class="action-icon">
<text class="icon-text">💬</text>
</view>
<text class="action-text">引用</text>
</view>
<!-- 转发 -->
<view class="action-item"
wx:if="{{actions.forward}}"
bindtap="onActionTap"
data-action="forward">
<view class="action-icon">
<text class="icon-text">📤</text>
</view>
<text class="action-text">转发</text>
</view>
<!-- 收藏 -->
<view class="action-item"
wx:if="{{actions.favorite}}"
bindtap="onActionTap"
data-action="favorite">
<view class="action-icon">
<text class="icon-text">{{message.favorited ? '⭐' : '☆'}}</text>
</view>
<text class="action-text">{{message.favorited ? '取消收藏' : '收藏'}}</text>
</view>
<!-- 多选 -->
<view class="action-item"
wx:if="{{actions.multiSelect}}"
bindtap="onActionTap"
data-action="multiSelect">
<view class="action-icon">
<text class="icon-text">📋</text>
</view>
<text class="action-text">多选</text>
</view>
<!-- 复制 -->
<view class="action-item"
wx:if="{{actions.copy && message.msgType === 'text'}}"
bindtap="onActionTap"
data-action="copy">
<view class="action-icon">
<text class="icon-text">📄</text>
</view>
<text class="action-text">复制</text>
</view>
<!-- 撤回 -->
<view class="action-item"
wx:if="{{actions.recall && canRecall}}"
bindtap="onActionTap"
data-action="recall">
<view class="action-icon">
<text class="icon-text">🔄</text>
</view>
<text class="action-text">撤回</text>
</view>
<!-- 删除 -->
<view class="action-item danger"
wx:if="{{actions.delete}}"
bindtap="onActionTap"
data-action="delete">
<view class="action-icon">
<text class="icon-text">🗑️</text>
</view>
<text class="action-text">删除</text>
</view>
<!-- 举报 -->
<view class="action-item danger"
wx:if="{{actions.report && !isOwnMessage}}"
bindtap="onActionTap"
data-action="report">
<view class="action-icon">
<text class="icon-text">⚠️</text>
</view>
<text class="action-text">举报</text>
</view>
</view>
<!-- 消息信息区域 -->
<view class="message-info-section" wx:if="{{showMessageInfo}}">
<view class="info-item">
<text class="info-label">发送时间:</text>
<text class="info-value">{{formatTime(message.timestamp)}}</text>
</view>
<view class="info-item" wx:if="{{message.editedAt}}">
<text class="info-label">编辑时间:</text>
<text class="info-value">{{formatTime(message.editedAt)}}</text>
</view>
<view class="info-item" wx:if="{{message.msgType !== 'text'}}">
<text class="info-label">消息类型:</text>
<text class="info-value">{{getMessageTypeText(message.msgType)}}</text>
</view>
<view class="info-item" wx:if="{{message.size}}">
<text class="info-label">文件大小:</text>
<text class="info-value">{{formatFileSize(message.size)}}</text>
</view>
</view>
</view>
</view>
<!-- 表情选择器弹窗 -->
<view class="emoji-picker-modal" wx:if="{{showEmojiPicker}}" bindtap="closeEmojiPicker">
<view class="emoji-picker-content" catchtap="stopPropagation">
<view class="emoji-picker-header">
<text class="picker-title">选择表情</text>
<view class="close-btn" bindtap="closeEmojiPicker">
<text class="close-icon">✕</text>
</view>
</view>
<view class="emoji-categories">
<view class="category-tab {{currentEmojiCategory === 'recent' ? 'active' : ''}}"
bindtap="switchEmojiCategory"
data-category="recent">
<text class="tab-text">最近</text>
</view>
<view class="category-tab {{currentEmojiCategory === 'smileys' ? 'active' : ''}}"
bindtap="switchEmojiCategory"
data-category="smileys">
<text class="tab-text">笑脸</text>
</view>
<view class="category-tab {{currentEmojiCategory === 'gestures' ? 'active' : ''}}"
bindtap="switchEmojiCategory"
data-category="gestures">
<text class="tab-text">手势</text>
</view>
<view class="category-tab {{currentEmojiCategory === 'hearts' ? 'active' : ''}}"
bindtap="switchEmojiCategory"
data-category="hearts">
<text class="tab-text">爱心</text>
</view>
</view>
<scroll-view class="emoji-grid-container" scroll-y="true">
<view class="emoji-grid">
<view class="emoji-grid-item"
wx:for="{{currentEmojiList}}"
wx:key="index"
bindtap="onEmojiSelect"
data-emoji="{{item}}">
<text class="grid-emoji">{{item}}</text>
</view>
</view>
</scroll-view>
</view>
</view>

View file

@ -0,0 +1,446 @@
/* ✨ 消息操作菜单组件样式 */
/* CSS变量定义 */
.message-action-menu {
--menu-bg: rgba(0, 0, 0, 0.8);
--content-bg: #FFFFFF;
--border-color: #E5E5EA;
--text-primary: #000000;
--text-secondary: #8E8E93;
--text-danger: #FF3B30;
--button-bg: #F2F2F7;
--button-active: #E5E5EA;
--shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.2);
--radius: 16rpx;
}
/* 🌙 深色模式支持 */
@media (prefers-color-scheme: dark) {
.message-action-menu {
--content-bg: #1C1C1E;
--border-color: #38383A;
--text-primary: #FFFFFF;
--text-secondary: #8E8E93;
--text-danger: #FF453A;
--button-bg: #2C2C2E;
--button-active: #3A3A3C;
--shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.4);
}
}
/* 🎨 菜单容器 */
.message-action-menu {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9998;
display: flex;
align-items: flex-end;
justify-content: center;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 🎨 背景遮罩 */
.menu-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--menu-bg);
}
/* 🎨 菜单内容 */
.menu-content {
width: 100%;
max-width: 750rpx;
background: var(--content-bg);
border-radius: var(--radius) var(--radius) 0 0;
box-shadow: var(--shadow);
animation: slideUp 0.3s ease-out;
overflow: hidden;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* 🎨 表情回应区域 */
.reactions-section {
padding: 32rpx;
border-bottom: 1rpx solid var(--border-color);
}
.reactions-title {
margin-bottom: 24rpx;
}
.title-text {
font-size: 32rpx;
font-weight: 600;
color: var(--text-primary);
}
.reactions-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.reaction-item {
width: 88rpx;
height: 88rpx;
border-radius: 44rpx;
background: var(--button-bg);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
border: 2rpx solid transparent;
}
.reaction-item:active {
background: var(--button-active);
transform: scale(0.9);
}
.reaction-emoji {
font-size: 48rpx;
}
.more-emoji {
border: 2rpx dashed var(--border-color);
background: transparent;
}
.more-icon {
font-size: 32rpx;
color: var(--text-secondary);
}
/* 🎨 操作按钮区域 */
.actions-section {
padding: 16rpx 0;
}
.action-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
transition: all 0.2s ease;
border-bottom: 1rpx solid var(--border-color);
}
.action-item:last-child {
border-bottom: none;
}
.action-item:active {
background: var(--button-bg);
}
.action-item.danger {
color: var(--text-danger);
}
.action-item.danger .action-text {
color: var(--text-danger);
}
.action-icon {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
background: var(--button-bg);
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
}
.action-item.danger .action-icon {
background: rgba(255, 59, 48, 0.1);
}
.icon-text {
font-size: 32rpx;
}
.action-text {
font-size: 32rpx;
color: var(--text-primary);
font-weight: 500;
}
/* 🎨 消息信息区域 */
.message-info-section {
padding: 32rpx;
background: var(--button-bg);
border-top: 1rpx solid var(--border-color);
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.info-item:last-child {
margin-bottom: 0;
}
.info-label {
font-size: 28rpx;
color: var(--text-secondary);
}
.info-value {
font-size: 28rpx;
color: var(--text-primary);
font-weight: 500;
}
/* 🎨 表情选择器弹窗 */
.emoji-picker-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: var(--menu-bg);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease-out;
}
.emoji-picker-content {
width: 90%;
max-width: 600rpx;
height: 80%;
max-height: 800rpx;
background: var(--content-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
overflow: hidden;
animation: scaleIn 0.3s ease-out;
}
@keyframes scaleIn {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.emoji-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid var(--border-color);
}
.picker-title {
font-size: 36rpx;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
width: 64rpx;
height: 64rpx;
border-radius: 32rpx;
background: var(--button-bg);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.close-btn:active {
background: var(--button-active);
transform: scale(0.9);
}
.close-icon {
font-size: 28rpx;
color: var(--text-secondary);
}
/* 🎨 表情分类标签 */
.emoji-categories {
display: flex;
border-bottom: 1rpx solid var(--border-color);
}
.category-tab {
flex: 1;
padding: 24rpx 16rpx;
text-align: center;
transition: all 0.2s ease;
border-bottom: 4rpx solid transparent;
}
.category-tab.active {
border-bottom-color: #007AFF;
}
.category-tab:active {
background: var(--button-bg);
}
.tab-text {
font-size: 28rpx;
color: var(--text-secondary);
font-weight: 500;
}
.category-tab.active .tab-text {
color: #007AFF;
font-weight: 600;
}
/* 🎨 表情网格 */
.emoji-grid-container {
flex: 1;
padding: 16rpx;
}
.emoji-grid {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
}
.emoji-grid-item {
width: 88rpx;
height: 88rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.emoji-grid-item:active {
background: var(--button-bg);
transform: scale(0.9);
}
.grid-emoji {
font-size: 48rpx;
}
/* 📱 响应式设计 */
@media screen and (max-width: 375px) {
.reactions-section {
padding: 24rpx;
}
.reaction-item {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
}
.reaction-emoji {
font-size: 40rpx;
}
.action-item {
padding: 20rpx 24rpx;
}
.action-icon {
width: 64rpx;
height: 64rpx;
border-radius: 32rpx;
margin-right: 20rpx;
}
.icon-text {
font-size: 28rpx;
}
.action-text {
font-size: 28rpx;
}
.emoji-grid-item {
width: 72rpx;
height: 72rpx;
}
.grid-emoji {
font-size: 40rpx;
}
}
@media screen and (min-width: 414px) {
.reactions-section {
padding: 40rpx;
}
.reaction-item {
width: 96rpx;
height: 96rpx;
border-radius: 48rpx;
}
.reaction-emoji {
font-size: 52rpx;
}
.action-item {
padding: 28rpx 40rpx;
}
.action-icon {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
margin-right: 28rpx;
}
.icon-text {
font-size: 36rpx;
}
.action-text {
font-size: 36rpx;
}
.emoji-grid-item {
width: 96rpx;
height: 96rpx;
}
.grid-emoji {
font-size: 52rpx;
}
}

View file

@ -0,0 +1,161 @@
Component({
properties: {
// 语音消息数据
voiceData: {
type: Object,
value: {},
observer: 'onVoiceDataChange'
},
// 是否为自己发送的消息
isSelf: {
type: Boolean,
value: false
},
// 消息ID
messageId: {
type: String,
value: ''
}
},
data: {
// 播放状态
isPlaying: false,
// 波形数据
waveformData: [],
// 语音信息
voiceUrl: '',
voiceDuration: 0
},
lifetimes: {
attached() {
this.initComponent();
},
detached() {
this.cleanup();
}
},
methods: {
// 初始化组件
initComponent() {
// 生成波形数据
this.generateWaveform();
// 获取全局音频上下文
const app = getApp();
this.audioContext = app.globalData.audioContext || wx.createInnerAudioContext();
// 注册音频事件
this.setupAudioEvents();
},
// 语音数据变化处理
onVoiceDataChange(newData, oldData) {
if (!newData || JSON.stringify(newData) === JSON.stringify(oldData)) {
return;
}
this.setData({
voiceUrl: newData.url || '',
voiceDuration: newData.duration || 0
});
// 重新生成波形
this.generateWaveform();
},
// 设置音频事件监听
setupAudioEvents() {
if (!this.audioContext) return;
this.audioContext.onPlay(() => {
if (this.isCurrentAudio()) {
this.setData({ isPlaying: true });
}
});
this.audioContext.onPause(() => {
if (this.isCurrentAudio()) {
this.setData({ isPlaying: false });
}
});
this.audioContext.onEnded(() => {
if (this.isCurrentAudio()) {
this.setData({ isPlaying: false });
}
});
this.audioContext.onError((err) => {
console.error('语音播放错误:', err);
if (this.isCurrentAudio()) {
this.setData({ isPlaying: false });
wx.showToast({ title: '播放失败', icon: 'none' });
}
});
},
// 检查是否为当前音频
isCurrentAudio() {
return this.audioContext && this.audioContext.src === this.data.voiceUrl;
},
// 切换播放状态
togglePlay() {
if (!this.data.voiceUrl) {
wx.showToast({ title: '语音地址无效', icon: 'none' });
return;
}
try {
if (this.data.isPlaying) {
this.audioContext.pause();
} else {
// 停止其他正在播放的音频
if (this.audioContext.src !== this.data.voiceUrl) {
this.audioContext.src = this.data.voiceUrl;
}
this.audioContext.play();
}
} catch (error) {
console.error('播放语音失败:', error);
wx.showToast({ title: '播放失败', icon: 'none' });
}
},
// 生成波形数据
generateWaveform() {
const duration = this.data.voiceDuration || 1000;
const barCount = Math.min(Math.max(Math.floor(duration / 200), 8), 30); // 8-30个波形条
const waveformData = [];
for (let i = 0; i < barCount; i++) {
// 生成随机高度,模拟真实波形
const height = Math.random() * 60 + 20; // 20-80%的高度
waveformData.push(height);
}
this.setData({ waveformData });
},
// 清理资源
cleanup() {
// 如果当前正在播放,停止播放
if (this.data.isPlaying && this.audioContext) {
try {
this.audioContext.stop();
} catch (e) {
// 忽略停止错误
}
}
}
}
});

View file

@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View file

@ -0,0 +1,53 @@
<!-- 🎤 语音消息组件 -->
<view class="voice-message-container {{isSelf ? 'self' : 'other'}} {{isPlaying ? 'playing' : ''}}">
<!-- 语音消息气泡 -->
<view class="voice-bubble" bindtap="togglePlay">
<!-- 播放按钮 -->
<view class="play-button">
<view class="play-icon {{isPlaying ? 'pause' : 'play'}}">
<text wx:if="{{!isPlaying}}" class="icon">▶</text>
<text wx:else class="icon">⏸</text>
</view>
</view>
<!-- 语音波形 -->
<view class="voice-waveform">
<view class="waveform-container">
<view wx:for="{{waveformData}}"
wx:key="index"
class="wave-bar {{index <= currentWaveIndex ? 'active' : ''}}"
style="height: {{item}}%;">
</view>
</view>
</view>
<!-- 语音时长 -->
<view class="voice-duration">
<text class="duration-text">{{formatDuration(duration)}}</text>
</view>
</view>
<!-- 播放进度条 -->
<view wx:if="{{isPlaying}}" class="progress-container">
<view class="progress-bar">
<view class="progress-fill" style="width: {{playProgress}}%;"></view>
</view>
<view class="progress-time">
<text class="current-time">{{formatTime(currentTime)}}</text>
<text class="total-time">{{formatTime(duration)}}</text>
</view>
</view>
<!-- 加载状态 -->
<view wx:if="{{isLoading}}" class="loading-container">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 错误状态 -->
<view wx:if="{{hasError}}" class="error-container">
<text class="error-icon">⚠️</text>
<text class="error-text">播放失败</text>
<text class="retry-button" bindtap="retryPlay">重试</text>
</view>
</view>

View file

@ -0,0 +1,392 @@
/* 🎤 语音消息组件样式 */
/* CSS变量定义 */
.voice-message-container {
--primary-color: #007AFF;
--primary-light: #5AC8FA;
--success-color: #34C759;
--warning-color: #FF9500;
--danger-color: #FF3B30;
--background-light: #F2F2F7;
--background-dark: #1C1C1E;
--text-primary: #000000;
--text-secondary: #8E8E93;
--border-color: #E5E5EA;
--shadow-light: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
--radius-medium: 12rpx;
--radius-large: 20rpx;
}
/* 🌙 深色模式支持 */
@media (prefers-color-scheme: dark) {
.voice-message-container {
--primary-color: #0A84FF;
--background-light: #2C2C2E;
--background-dark: #1C1C1E;
--text-primary: #FFFFFF;
--text-secondary: #8E8E93;
--border-color: #38383A;
--shadow-light: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
}
.voice-message-container {
max-width: 100%;
width: 100%;
margin: 8rpx 0;
position: relative;
box-sizing: border-box;
}
/* 🎨 语音气泡 */
.voice-bubble {
display: flex;
align-items: center;
padding: 24rpx;
border-radius: var(--radius-large);
box-shadow: var(--shadow-light);
transition: all 0.3s ease;
min-width: 200rpx;
position: relative;
overflow: hidden;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
/* 自己发送的消息 - 使用聊天气泡的绿色 */
.voice-message-container.self .voice-bubble {
background: #4DD1A1;
color: white;
}
/* 他人发送的消息 - 使用聊天气泡的灰色 */
.voice-message-container.other .voice-bubble {
background: #D9D9D9;
color: var(--text-primary);
border: 1rpx solid rgba(255,255,255,0.03);
}
/* 播放状态 */
.voice-message-container.playing .voice-bubble {
transform: scale(1.02);
}
.voice-message-container.self.playing .voice-bubble {
background: #3CB88F; /* 稍微深一点的绿色表示播放中 */
}
.voice-message-container.other.playing .voice-bubble {
background: #C0C0C0; /* 稍微深一点的灰色表示播放中 */
border-color: #A0A0A0;
}
/* 🎵 播放按钮 */
.play-button {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
transition: all 0.3s ease;
position: relative;
box-sizing: border-box;
overflow: hidden;
flex-shrink: 0; /* 不允许在flex布局中被压缩 */
flex: 0 0 auto; /* 宽高由自身决定 */
min-width: 80rpx; /* 保底宽度,维持正圆 */
}
.voice-message-container.self .play-button {
background: rgba(255, 255, 255, 0.3); /* 在绿色背景上更明显的白色按钮 */
}
.voice-message-container.other .play-button {
background: rgba(0, 0, 0, 0.15); /* 在灰色背景上的深色按钮 */
}
.play-button:active {
transform: scale(0.95);
}
.play-icon {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.play-icon .icon {
font-size: 32rpx;
font-weight: bold;
}
.voice-message-container.self .play-icon .icon {
color: white; /* 白色图标在绿色背景上 */
}
.voice-message-container.other .play-icon .icon {
color: #333333; /* 深色图标在灰色背景上 */
}
/* 播放动画 */
.play-icon.play .icon {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* 🌊 语音波形 */
.voice-waveform {
flex: 1;
margin-right: 24rpx;
height: 60rpx;
display: flex;
align-items: center;
min-width: 0; /* 允许在狭窄容器中收缩,避免溢出 */
overflow: hidden;
}
.waveform-container {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
gap: 4rpx;
}
.wave-bar {
width: 6rpx;
border-radius: 3rpx;
transition: all 0.3s ease;
min-height: 8rpx;
}
.voice-message-container.self .wave-bar {
background: rgba(255, 255, 255, 0.5); /* 在绿色背景上更明显的波形 */
}
.voice-message-container.other .wave-bar {
background: rgba(0, 0, 0, 0.2); /* 在灰色背景上的深色波形 */
}
.voice-message-container.self .wave-bar.active {
background: white;
transform: scaleY(1.2);
}
.voice-message-container.other .wave-bar.active {
background: rgba(0, 0, 0, 0.5); /* 深色波形激活状态 */
transform: scaleY(1.2);
}
/* 波形动画 */
.voice-message-container.playing .wave-bar.active {
animation: waveAnimation 1.5s ease-in-out infinite;
}
@keyframes waveAnimation {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(1.5); }
}
/* ⏱️ 语音时长 */
.voice-duration {
min-width: 60rpx;
text-align: right;
}
.duration-text {
font-size: 24rpx;
font-weight: 500;
}
.voice-message-container.self .duration-text {
color: rgba(255, 255, 255, 0.95); /* 白色时长文字在绿色背景上 */
}
.voice-message-container.other .duration-text {
color: rgba(0, 0, 0, 0.6); /* 深色时长文字在灰色背景上 */
}
/* 📊 播放进度条 */
.progress-container {
margin-top: 16rpx;
padding: 0 24rpx;
}
.progress-bar {
height: 4rpx;
background: var(--border-color);
border-radius: 2rpx;
overflow: hidden;
margin-bottom: 8rpx;
}
.progress-fill {
height: 100%;
background: var(--primary-color);
border-radius: 2rpx;
transition: width 0.1s linear;
}
.progress-time {
display: flex;
justify-content: space-between;
align-items: center;
}
.current-time,
.total-time {
font-size: 20rpx;
color: var(--text-secondary);
}
/* 🔄 加载状态 */
.loading-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.1);
border-radius: var(--radius-large);
backdrop-filter: blur(10rpx);
}
.loading-spinner {
width: 40rpx;
height: 40rpx;
border: 3rpx solid rgba(255, 255, 255, 0.3);
border-top: 3rpx solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 16rpx;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
font-size: 24rpx;
color: white;
}
/* ⚠️ 错误状态 */
.error-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 59, 48, 0.1);
border-radius: var(--radius-large);
backdrop-filter: blur(10rpx);
gap: 12rpx;
}
.error-icon {
font-size: 32rpx;
}
.error-text {
font-size: 24rpx;
color: var(--danger-color);
}
.retry-button {
font-size: 24rpx;
color: var(--primary-color);
text-decoration: underline;
transition: all 0.2s ease;
}
.retry-button:active {
opacity: 0.7;
}
/* 🎨 特殊效果 */
.voice-bubble::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.1) 50%, transparent 70%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.voice-bubble:active::before {
opacity: 1;
}
/* 📱 响应式设计 */
/* 去除设备宽度相关的硬编码,统一依赖父容器宽度以避免溢出 */
/* 🎭 动画增强 */
.voice-message-container {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 长按效果 */
.voice-bubble {
user-select: none;
-webkit-user-select: none;
}
.voice-bubble:active {
transform: scale(0.98);
}
/* 可访问性 */
.voice-bubble[aria-pressed="true"] {
outline: 2rpx solid var(--primary-color);
outline-offset: 4rpx;
}
/* 状态指示器 */
.voice-message-container::after {
content: '';
position: absolute;
top: -4rpx;
right: -4rpx;
width: 16rpx;
height: 16rpx;
border-radius: 8rpx;
opacity: 0;
transition: all 0.3s ease;
}
.voice-message-container.playing::after {
opacity: 1;
background: var(--success-color);
animation: pulse 2s infinite;
}