Initial Commit
This commit is contained in:
commit
1d71a02738
237 changed files with 64293 additions and 0 deletions
567
components/interactive-feedback/interactive-feedback.js
Normal file
567
components/interactive-feedback/interactive-feedback.js
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
// 🎯 交互反馈组件逻辑
|
||||
const animationManager = require('../../utils/animation-manager.js');
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
// 反馈类型
|
||||
feedbackType: {
|
||||
type: String,
|
||||
value: 'button' // button, card, list-item, icon, floating
|
||||
},
|
||||
|
||||
// 是否启用波纹效果
|
||||
ripple: {
|
||||
type: Boolean,
|
||||
value: true
|
||||
},
|
||||
|
||||
// 波纹颜色
|
||||
rippleColor: {
|
||||
type: String,
|
||||
value: 'primary' // primary, white, dark
|
||||
},
|
||||
|
||||
// 触摸反馈类型
|
||||
touchFeedback: {
|
||||
type: String,
|
||||
value: 'overlay' // overlay, highlight, glow, none
|
||||
},
|
||||
|
||||
// 是否启用缩放效果
|
||||
scaleEffect: {
|
||||
type: Boolean,
|
||||
value: true
|
||||
},
|
||||
|
||||
// 缩放类型
|
||||
scaleType: {
|
||||
type: String,
|
||||
value: 'normal' // normal, large, none
|
||||
},
|
||||
|
||||
// 是否禁用
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
value: false
|
||||
},
|
||||
|
||||
// 加载状态
|
||||
loading: {
|
||||
type: Boolean,
|
||||
value: false,
|
||||
observer: 'onLoadingChange'
|
||||
},
|
||||
|
||||
// 主题
|
||||
theme: {
|
||||
type: String,
|
||||
value: 'default' // default, primary, success, error, ghost, minimal
|
||||
},
|
||||
|
||||
// 特殊效果
|
||||
effect: {
|
||||
type: String,
|
||||
value: 'none' // none, glass, neon, gradient, hover-lift, hover-glow
|
||||
}
|
||||
},
|
||||
|
||||
data: {
|
||||
// 反馈状态
|
||||
pressed: false,
|
||||
active: false,
|
||||
|
||||
// 波纹数据
|
||||
ripples: [],
|
||||
showRipple: false,
|
||||
|
||||
// 触摸反馈
|
||||
showTouchFeedback: false,
|
||||
touchFeedbackClass: '',
|
||||
touchFeedbackStyle: '',
|
||||
|
||||
// 动画数据
|
||||
animationData: null,
|
||||
loadingAnimation: null,
|
||||
successAnimation: null,
|
||||
errorAnimation: null,
|
||||
|
||||
// 样式
|
||||
feedbackStyle: '',
|
||||
feedbackClass: '',
|
||||
|
||||
// 状态反馈
|
||||
showSuccess: false,
|
||||
showError: false,
|
||||
|
||||
// 定时器
|
||||
rippleTimer: null,
|
||||
feedbackTimer: null
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
console.log('🎯 交互反馈组件加载');
|
||||
this.initComponent();
|
||||
},
|
||||
|
||||
detached() {
|
||||
console.log('🎯 交互反馈组件卸载');
|
||||
this.cleanup();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 初始化组件
|
||||
initComponent() {
|
||||
this.updateFeedbackClass();
|
||||
this.updateTouchFeedbackClass();
|
||||
this.createLoadingAnimation();
|
||||
},
|
||||
|
||||
// 加载状态变化处理
|
||||
onLoadingChange(loading) {
|
||||
if (loading) {
|
||||
this.startLoadingAnimation();
|
||||
} else {
|
||||
this.stopLoadingAnimation();
|
||||
}
|
||||
},
|
||||
|
||||
// 🎯 ===== 触摸事件处理 =====
|
||||
|
||||
// 触摸开始
|
||||
onTouchStart(e) {
|
||||
if (this.properties.disabled || this.properties.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🎯 触摸开始');
|
||||
|
||||
this.setData({
|
||||
pressed: true
|
||||
});
|
||||
|
||||
// 显示触摸反馈
|
||||
this.showTouchFeedbackEffect();
|
||||
|
||||
// 创建波纹效果
|
||||
if (this.properties.ripple) {
|
||||
this.createRipple(e);
|
||||
}
|
||||
|
||||
// 缩放效果
|
||||
if (this.properties.scaleEffect) {
|
||||
this.applyScaleEffect(true);
|
||||
}
|
||||
|
||||
this.triggerEvent('touchstart', e);
|
||||
},
|
||||
|
||||
// 触摸结束
|
||||
onTouchEnd(e) {
|
||||
if (this.properties.disabled || this.properties.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🎯 触摸结束');
|
||||
|
||||
this.setData({
|
||||
pressed: false
|
||||
});
|
||||
|
||||
// 隐藏触摸反馈
|
||||
this.hideTouchFeedbackEffect();
|
||||
|
||||
// 恢复缩放
|
||||
if (this.properties.scaleEffect) {
|
||||
this.applyScaleEffect(false);
|
||||
}
|
||||
|
||||
this.triggerEvent('touchend', e);
|
||||
},
|
||||
|
||||
// 触摸取消
|
||||
onTouchCancel(e) {
|
||||
if (this.properties.disabled || this.properties.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🎯 触摸取消');
|
||||
|
||||
this.setData({
|
||||
pressed: false
|
||||
});
|
||||
|
||||
// 隐藏触摸反馈
|
||||
this.hideTouchFeedbackEffect();
|
||||
|
||||
// 恢复缩放
|
||||
if (this.properties.scaleEffect) {
|
||||
this.applyScaleEffect(false);
|
||||
}
|
||||
|
||||
this.triggerEvent('touchcancel', e);
|
||||
},
|
||||
|
||||
// 点击事件
|
||||
onTap(e) {
|
||||
if (this.properties.disabled || this.properties.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🎯 点击事件');
|
||||
|
||||
// 按钮点击动画
|
||||
this.playButtonPressAnimation();
|
||||
|
||||
this.triggerEvent('tap', e);
|
||||
},
|
||||
|
||||
// 长按事件
|
||||
onLongPress(e) {
|
||||
if (this.properties.disabled || this.properties.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🎯 长按事件');
|
||||
|
||||
// 长按反馈
|
||||
this.playLongPressFeedback();
|
||||
|
||||
this.triggerEvent('longpress', e);
|
||||
},
|
||||
|
||||
// 🌊 ===== 波纹效果 =====
|
||||
|
||||
// 创建波纹
|
||||
createRipple(e) {
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
// 获取组件位置信息
|
||||
this.createSelectorQuery()
|
||||
.select('.interactive-feedback-container')
|
||||
.boundingClientRect((rect) => {
|
||||
if (!rect) return;
|
||||
|
||||
// 计算波纹位置
|
||||
const x = touch.clientX - rect.left;
|
||||
const y = touch.clientY - rect.top;
|
||||
|
||||
// 计算波纹大小
|
||||
const size = Math.max(rect.width, rect.height) * 2;
|
||||
|
||||
// 创建波纹数据
|
||||
const ripple = {
|
||||
id: this.generateRippleId(),
|
||||
class: `${this.properties.rippleColor} animate`,
|
||||
style: `
|
||||
left: ${x - size / 2}px;
|
||||
top: ${y - size / 2}px;
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
`,
|
||||
animation: null
|
||||
};
|
||||
|
||||
// 添加波纹
|
||||
const ripples = [...this.data.ripples, ripple];
|
||||
this.setData({
|
||||
ripples: ripples,
|
||||
showRipple: true
|
||||
});
|
||||
|
||||
// 清理波纹
|
||||
this.rippleTimer = setTimeout(() => {
|
||||
this.removeRipple(ripple.id);
|
||||
}, 600);
|
||||
|
||||
})
|
||||
.exec();
|
||||
},
|
||||
|
||||
// 移除波纹
|
||||
removeRipple(rippleId) {
|
||||
const ripples = this.data.ripples.filter(ripple => ripple.id !== rippleId);
|
||||
this.setData({
|
||||
ripples: ripples,
|
||||
showRipple: ripples.length > 0
|
||||
});
|
||||
},
|
||||
|
||||
// 清理所有波纹
|
||||
clearRipples() {
|
||||
this.setData({
|
||||
ripples: [],
|
||||
showRipple: false
|
||||
});
|
||||
},
|
||||
|
||||
// 📱 ===== 触摸反馈 =====
|
||||
|
||||
// 显示触摸反馈
|
||||
showTouchFeedbackEffect() {
|
||||
if (this.properties.touchFeedback === 'none') return;
|
||||
|
||||
this.setData({
|
||||
showTouchFeedback: true,
|
||||
touchFeedbackClass: `${this.properties.touchFeedback} active`
|
||||
});
|
||||
},
|
||||
|
||||
// 隐藏触摸反馈
|
||||
hideTouchFeedbackEffect() {
|
||||
this.setData({
|
||||
showTouchFeedback: false,
|
||||
touchFeedbackClass: this.properties.touchFeedback
|
||||
});
|
||||
},
|
||||
|
||||
// 🎭 ===== 动画效果 =====
|
||||
|
||||
// 应用缩放效果
|
||||
applyScaleEffect(pressed) {
|
||||
if (!this.properties.scaleEffect) return;
|
||||
|
||||
let scale = 1;
|
||||
if (pressed) {
|
||||
switch (this.properties.scaleType) {
|
||||
case 'large':
|
||||
scale = 1.02;
|
||||
break;
|
||||
case 'none':
|
||||
scale = 1;
|
||||
break;
|
||||
case 'normal':
|
||||
default:
|
||||
scale = 0.98;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const animation = animationManager.scale(scale, {
|
||||
duration: 150,
|
||||
timingFunction: 'ease-out'
|
||||
});
|
||||
|
||||
this.setData({
|
||||
animationData: animation.export()
|
||||
});
|
||||
},
|
||||
|
||||
// 播放按钮点击动画
|
||||
playButtonPressAnimation() {
|
||||
const animation = animationManager.buttonPress({
|
||||
duration: 200
|
||||
});
|
||||
|
||||
this.setData({
|
||||
animationData: animation.export()
|
||||
});
|
||||
},
|
||||
|
||||
// 播放长按反馈
|
||||
playLongPressFeedback() {
|
||||
const animation = animationManager.pulse({
|
||||
duration: 300
|
||||
});
|
||||
|
||||
this.setData({
|
||||
animationData: animation.export()
|
||||
});
|
||||
},
|
||||
|
||||
// 🔄 ===== 加载动画 =====
|
||||
|
||||
// 创建加载动画
|
||||
createLoadingAnimation() {
|
||||
const loadingAnimation = animationManager.loadingSpinner({
|
||||
duration: 1000
|
||||
});
|
||||
|
||||
this.setData({
|
||||
loadingAnimation: loadingAnimation.export()
|
||||
});
|
||||
},
|
||||
|
||||
// 开始加载动画
|
||||
startLoadingAnimation() {
|
||||
this.loadingTimer = setInterval(() => {
|
||||
const loadingAnimation = animationManager.loadingSpinner({
|
||||
duration: 1000
|
||||
});
|
||||
|
||||
this.setData({
|
||||
loadingAnimation: loadingAnimation.export()
|
||||
});
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
// 停止加载动画
|
||||
stopLoadingAnimation() {
|
||||
if (this.loadingTimer) {
|
||||
clearInterval(this.loadingTimer);
|
||||
this.loadingTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
// ✅ ===== 状态反馈 =====
|
||||
|
||||
// 显示成功反馈
|
||||
showSuccessFeedback(duration = 1500) {
|
||||
console.log('✅ 显示成功反馈');
|
||||
|
||||
this.setData({
|
||||
showSuccess: true
|
||||
});
|
||||
|
||||
const successAnimation = animationManager.bounceIn({
|
||||
duration: 500
|
||||
});
|
||||
|
||||
this.setData({
|
||||
successAnimation: successAnimation.export()
|
||||
});
|
||||
|
||||
// 自动隐藏
|
||||
setTimeout(() => {
|
||||
this.hideSuccessFeedback();
|
||||
}, duration);
|
||||
},
|
||||
|
||||
// 隐藏成功反馈
|
||||
hideSuccessFeedback() {
|
||||
this.setData({
|
||||
showSuccess: false
|
||||
});
|
||||
},
|
||||
|
||||
// 显示错误反馈
|
||||
showErrorFeedback(duration = 1500) {
|
||||
console.log('❌ 显示错误反馈');
|
||||
|
||||
this.setData({
|
||||
showError: true
|
||||
});
|
||||
|
||||
const errorAnimation = animationManager.shake({
|
||||
duration: 500
|
||||
});
|
||||
|
||||
this.setData({
|
||||
errorAnimation: errorAnimation.export()
|
||||
});
|
||||
|
||||
// 自动隐藏
|
||||
setTimeout(() => {
|
||||
this.hideErrorFeedback();
|
||||
}, duration);
|
||||
},
|
||||
|
||||
// 隐藏错误反馈
|
||||
hideErrorFeedback() {
|
||||
this.setData({
|
||||
showError: false
|
||||
});
|
||||
},
|
||||
|
||||
// 🎨 ===== 样式管理 =====
|
||||
|
||||
// 更新反馈类
|
||||
updateFeedbackClass() {
|
||||
let feedbackClass = this.properties.feedbackType;
|
||||
|
||||
if (this.properties.theme !== 'default') {
|
||||
feedbackClass += ` theme-${this.properties.theme}`;
|
||||
}
|
||||
|
||||
if (this.properties.effect !== 'none') {
|
||||
feedbackClass += ` ${this.properties.effect}`;
|
||||
}
|
||||
|
||||
if (this.properties.disabled) {
|
||||
feedbackClass += ' disabled';
|
||||
}
|
||||
|
||||
if (this.properties.loading) {
|
||||
feedbackClass += ' loading';
|
||||
}
|
||||
|
||||
if (this.data.pressed) {
|
||||
feedbackClass += ' pressed';
|
||||
}
|
||||
|
||||
if (this.data.active) {
|
||||
feedbackClass += ' active';
|
||||
}
|
||||
|
||||
this.setData({
|
||||
feedbackClass: feedbackClass
|
||||
});
|
||||
},
|
||||
|
||||
// 更新触摸反馈类
|
||||
updateTouchFeedbackClass() {
|
||||
this.setData({
|
||||
touchFeedbackClass: this.properties.touchFeedback
|
||||
});
|
||||
},
|
||||
|
||||
// 🔧 ===== 工具方法 =====
|
||||
|
||||
// 生成波纹ID
|
||||
generateRippleId() {
|
||||
return `ripple_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
},
|
||||
|
||||
// 设置激活状态
|
||||
setActive(active) {
|
||||
this.setData({
|
||||
active: active
|
||||
});
|
||||
this.updateFeedbackClass();
|
||||
},
|
||||
|
||||
// 触发成功状态
|
||||
triggerSuccess() {
|
||||
this.showSuccessFeedback();
|
||||
this.triggerEvent('success');
|
||||
},
|
||||
|
||||
// 触发错误状态
|
||||
triggerError() {
|
||||
this.showErrorFeedback();
|
||||
this.triggerEvent('error');
|
||||
},
|
||||
|
||||
// 重置状态
|
||||
reset() {
|
||||
this.setData({
|
||||
pressed: false,
|
||||
active: false,
|
||||
showSuccess: false,
|
||||
showError: false
|
||||
});
|
||||
|
||||
this.clearRipples();
|
||||
this.hideTouchFeedbackEffect();
|
||||
this.updateFeedbackClass();
|
||||
},
|
||||
|
||||
// 清理资源
|
||||
cleanup() {
|
||||
if (this.rippleTimer) {
|
||||
clearTimeout(this.rippleTimer);
|
||||
this.rippleTimer = null;
|
||||
}
|
||||
|
||||
if (this.feedbackTimer) {
|
||||
clearTimeout(this.feedbackTimer);
|
||||
this.feedbackTimer = null;
|
||||
}
|
||||
|
||||
this.stopLoadingAnimation();
|
||||
this.clearRipples();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
44
components/interactive-feedback/interactive-feedback.wxml
Normal file
44
components/interactive-feedback/interactive-feedback.wxml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<!-- 🎯 交互反馈组件 -->
|
||||
<view class="interactive-feedback-container {{feedbackClass}}"
|
||||
style="{{feedbackStyle}}"
|
||||
animation="{{animationData}}"
|
||||
bindtouchstart="onTouchStart"
|
||||
bindtouchend="onTouchEnd"
|
||||
bindtouchcancel="onTouchCancel"
|
||||
bindtap="onTap"
|
||||
bindlongpress="onLongPress">
|
||||
|
||||
<!-- 内容插槽 -->
|
||||
<slot></slot>
|
||||
|
||||
<!-- 波纹效果 -->
|
||||
<view wx:if="{{showRipple}}" class="ripple-container">
|
||||
<view wx:for="{{ripples}}"
|
||||
wx:key="id"
|
||||
class="ripple {{item.class}}"
|
||||
style="{{item.style}}"
|
||||
animation="{{item.animation}}">
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 触摸反馈 -->
|
||||
<view wx:if="{{showTouchFeedback}}"
|
||||
class="touch-feedback {{touchFeedbackClass}}"
|
||||
style="{{touchFeedbackStyle}}">
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view wx:if="{{loading}}" class="loading-overlay">
|
||||
<view class="loading-spinner" animation="{{loadingAnimation}}"></view>
|
||||
</view>
|
||||
|
||||
<!-- 成功反馈 -->
|
||||
<view wx:if="{{showSuccess}}" class="success-feedback" animation="{{successAnimation}}">
|
||||
<text class="success-icon">✓</text>
|
||||
</view>
|
||||
|
||||
<!-- 错误反馈 -->
|
||||
<view wx:if="{{showError}}" class="error-feedback" animation="{{errorAnimation}}">
|
||||
<text class="error-icon">✕</text>
|
||||
</view>
|
||||
</view>
|
||||
446
components/interactive-feedback/interactive-feedback.wxss
Normal file
446
components/interactive-feedback/interactive-feedback.wxss
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
/* 🎯 交互反馈组件样式 */
|
||||
|
||||
/* CSS变量定义 */
|
||||
.interactive-feedback-container {
|
||||
--primary-color: #007AFF;
|
||||
--primary-light: #5AC8FA;
|
||||
--success-color: #34C759;
|
||||
--error-color: #FF3B30;
|
||||
--background-color: #F2F2F7;
|
||||
--surface-color: #FFFFFF;
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #8E8E93;
|
||||
--border-color: #E5E5EA;
|
||||
--shadow-light: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
--radius-medium: 12rpx;
|
||||
}
|
||||
|
||||
/* 🌙 深色模式支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.interactive-feedback-container {
|
||||
--primary-color: #0A84FF;
|
||||
--background-color: #000000;
|
||||
--surface-color: #1C1C1E;
|
||||
--text-primary: #FFFFFF;
|
||||
--text-secondary: #8E8E93;
|
||||
--border-color: #38383A;
|
||||
--shadow-light: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.interactive-feedback-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* 🎨 反馈类型样式 */
|
||||
.interactive-feedback-container.button {
|
||||
border-radius: var(--radius-medium);
|
||||
background: var(--surface-color);
|
||||
border: 1rpx solid var(--border-color);
|
||||
box-shadow: var(--shadow-light);
|
||||
}
|
||||
|
||||
.interactive-feedback-container.card {
|
||||
border-radius: var(--radius-medium);
|
||||
background: var(--surface-color);
|
||||
box-shadow: var(--shadow-light);
|
||||
}
|
||||
|
||||
.interactive-feedback-container.list-item {
|
||||
background: var(--surface-color);
|
||||
border-bottom: 1rpx solid var(--border-color);
|
||||
}
|
||||
|
||||
.interactive-feedback-container.icon {
|
||||
border-radius: 50%;
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
.interactive-feedback-container.floating {
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 122, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 🎭 状态样式 */
|
||||
.interactive-feedback-container.pressed {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.interactive-feedback-container.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.interactive-feedback-container.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.interactive-feedback-container.loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.interactive-feedback-container.success {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.interactive-feedback-container.error {
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 🌊 波纹效果 */
|
||||
.ripple-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.ripple {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
transform: scale(0);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.ripple.primary {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.ripple.white {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.ripple.dark {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 📱 触摸反馈 */
|
||||
.touch-feedback {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.touch-feedback.overlay {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.touch-feedback.highlight {
|
||||
background: rgba(0, 122, 255, 0.1);
|
||||
}
|
||||
|
||||
.touch-feedback.glow {
|
||||
box-shadow: 0 0 20rpx rgba(0, 122, 255, 0.5);
|
||||
}
|
||||
|
||||
.touch-feedback.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 🔄 加载状态 */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border: 3rpx solid rgba(0, 122, 255, 0.2);
|
||||
border-top: 3rpx solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ✅ 成功反馈 */
|
||||
.success-feedback {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--success-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 40rpx;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ❌ 错误反馈 */
|
||||
.error-feedback {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--error-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 40rpx;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 🎪 动画效果 */
|
||||
@keyframes rippleExpand {
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 0.3;
|
||||
}
|
||||
to {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes successPop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes errorShake {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translate(-50%, -50%) translateX(-10rpx);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-50%, -50%) translateX(10rpx);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 20%, 53%, 80%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
40%, 43% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
90% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
/* 🎭 动画类应用 */
|
||||
.ripple.animate {
|
||||
animation: rippleExpand 0.6s ease-out;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.success-feedback.animate {
|
||||
animation: successPop 0.5s ease-out;
|
||||
}
|
||||
|
||||
.error-feedback.animate {
|
||||
animation: errorShake 0.5s ease-out;
|
||||
}
|
||||
|
||||
.interactive-feedback-container.pulse {
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.interactive-feedback-container.bounce {
|
||||
animation: bounce 1s ease-in-out;
|
||||
}
|
||||
|
||||
/* 🎨 特殊效果 */
|
||||
.interactive-feedback-container.glass {
|
||||
backdrop-filter: blur(20rpx);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.interactive-feedback-container.neon {
|
||||
box-shadow:
|
||||
0 0 10rpx var(--primary-color),
|
||||
0 0 20rpx var(--primary-color),
|
||||
0 0 40rpx var(--primary-color);
|
||||
}
|
||||
|
||||
.interactive-feedback-container.gradient {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
|
||||
}
|
||||
|
||||
/* 📱 响应式设计 */
|
||||
@media screen and (max-width: 375px) {
|
||||
.success-feedback,
|
||||
.error-feedback {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
}
|
||||
|
||||
.success-icon,
|
||||
.error-icon {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
border-width: 2rpx;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 414px) {
|
||||
.success-feedback,
|
||||
.error-feedback {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
}
|
||||
|
||||
.success-icon,
|
||||
.error-icon {
|
||||
font-size: 50rpx;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
border-width: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* 🎯 交互状态 */
|
||||
.interactive-feedback-container:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.interactive-feedback-container.no-scale:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.interactive-feedback-container.scale-large:active {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* 🔧 性能优化 */
|
||||
.interactive-feedback-container {
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.ripple {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* 🎪 组合效果 */
|
||||
.interactive-feedback-container.hover-lift {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.interactive-feedback-container.hover-lift:hover {
|
||||
transform: translateY(-4rpx);
|
||||
box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.interactive-feedback-container.hover-glow:hover {
|
||||
box-shadow: 0 0 30rpx rgba(0, 122, 255, 0.4);
|
||||
}
|
||||
|
||||
/* 🎭 主题变体 */
|
||||
.interactive-feedback-container.theme-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.interactive-feedback-container.theme-success {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.interactive-feedback-container.theme-error {
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.interactive-feedback-container.theme-ghost {
|
||||
background: transparent;
|
||||
border: 2rpx solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.interactive-feedback-container.theme-minimal {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
555
components/media-preview/media-preview.js
Normal file
555
components/media-preview/media-preview.js
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
// 🎨 媒体预览组件逻辑
|
||||
const mediaManager = require('../../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() {
|
||||
console.log('🎨 媒体预览组件已加载');
|
||||
},
|
||||
|
||||
detached() {
|
||||
console.log('🎨 媒体预览组件已卸载');
|
||||
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) {
|
||||
console.log('🖼️ 图片加载完成');
|
||||
|
||||
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) {
|
||||
// 可以实现双击放大等功能
|
||||
console.log('🖼️ 图片点击');
|
||||
},
|
||||
|
||||
// 重试加载
|
||||
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() {
|
||||
console.log('🎬 视频开始播放');
|
||||
this.triggerEvent('videoplay');
|
||||
},
|
||||
|
||||
// 视频暂停
|
||||
onVideoPause() {
|
||||
console.log('🎬 视频暂停');
|
||||
this.triggerEvent('videopause');
|
||||
},
|
||||
|
||||
// 视频结束
|
||||
onVideoEnded() {
|
||||
console.log('🎬 视频播放结束');
|
||||
this.triggerEvent('videoended');
|
||||
},
|
||||
|
||||
// 视频错误
|
||||
onVideoError(e) {
|
||||
console.error('❌ 视频播放错误:', e.detail);
|
||||
wx.showToast({
|
||||
title: '视频播放失败',
|
||||
icon: 'none'
|
||||
});
|
||||
},
|
||||
|
||||
// 视频时间更新
|
||||
onVideoTimeUpdate(e) {
|
||||
// 可以用于显示播放进度
|
||||
console.log('🎬 视频时间更新:', e.detail);
|
||||
},
|
||||
|
||||
// 🎨 ===== 音频操作 =====
|
||||
|
||||
// 切换音频播放
|
||||
toggleAudioPlay() {
|
||||
if (this.data.audioPlaying) {
|
||||
this.pauseAudio();
|
||||
} else {
|
||||
this.playAudio();
|
||||
}
|
||||
},
|
||||
|
||||
// 播放音频
|
||||
playAudio() {
|
||||
// 这里需要实现音频播放逻辑
|
||||
console.log('🎵 播放音频');
|
||||
|
||||
this.setData({
|
||||
audioPlaying: true
|
||||
});
|
||||
|
||||
// 模拟播放进度
|
||||
this.startAudioProgress();
|
||||
},
|
||||
|
||||
// 暂停音频
|
||||
pauseAudio() {
|
||||
console.log('🎵 暂停音频');
|
||||
|
||||
this.setData({
|
||||
audioPlaying: false
|
||||
});
|
||||
|
||||
this.stopAudioProgress();
|
||||
},
|
||||
|
||||
// 停止音频
|
||||
stopAudio() {
|
||||
console.log('🎵 停止音频');
|
||||
|
||||
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: () => {
|
||||
console.log('📄 文件打开成功');
|
||||
},
|
||||
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'
|
||||
});
|
||||
|
||||
console.log('📄 文件保存成功:', result.savedFilePath);
|
||||
|
||||
} 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] || '📄';
|
||||
}
|
||||
}
|
||||
});
|
||||
4
components/media-preview/media-preview.json
Normal file
4
components/media-preview/media-preview.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
192
components/media-preview/media-preview.wxml
Normal file
192
components/media-preview/media-preview.wxml
Normal 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>
|
||||
586
components/media-preview/media-preview.wxss
Normal file
586
components/media-preview/media-preview.wxss
Normal 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;
|
||||
}
|
||||
}
|
||||
181
components/mention-selector/mention-selector.js
Normal file
181
components/mention-selector/mention-selector.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// 💬 @提醒选择组件逻辑
|
||||
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();
|
||||
|
||||
console.log('✅ 群成员加载完成:', members.length);
|
||||
} 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() {
|
||||
console.log('💬 @全体成员');
|
||||
|
||||
this.triggerEvent('mention', {
|
||||
type: 'all',
|
||||
text: '所有人',
|
||||
userIds: this.data.allMembers.map(member => member.userId)
|
||||
});
|
||||
|
||||
this.onClose();
|
||||
},
|
||||
|
||||
// @特定成员
|
||||
onMentionMember(e) {
|
||||
const member = e.currentTarget.dataset.member;
|
||||
console.log('💬 @特定成员:', 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() {
|
||||
// 阻止点击事件冒泡
|
||||
}
|
||||
}
|
||||
});
|
||||
4
components/mention-selector/mention-selector.json
Normal file
4
components/mention-selector/mention-selector.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
85
components/mention-selector/mention-selector.wxml
Normal file
85
components/mention-selector/mention-selector.wxml
Normal 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.png'}}"
|
||||
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>
|
||||
378
components/mention-selector/mention-selector.wxss
Normal file
378
components/mention-selector/mention-selector.wxss
Normal 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;
|
||||
}
|
||||
}
|
||||
546
components/message-action-menu/message-action-menu.js
Normal file
546
components/message-action-menu/message-action-menu.js
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
// ✨ 消息操作菜单组件逻辑
|
||||
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() {
|
||||
console.log('✨ 消息操作菜单组件已加载');
|
||||
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;
|
||||
console.log('👍 表情点击:', emoji);
|
||||
|
||||
try {
|
||||
const userId = wx.getStorageSync('userId');
|
||||
if (!userId) {
|
||||
wx.showToast({
|
||||
title: '请先登录',
|
||||
icon: 'none'
|
||||
});
|
||||
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;
|
||||
console.log('🎯 操作点击:', 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() {
|
||||
console.log('💬 处理引用回复');
|
||||
|
||||
this.triggerEvent('action', {
|
||||
action: 'quote',
|
||||
message: this.data.message
|
||||
});
|
||||
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
// 处理转发
|
||||
handleForward() {
|
||||
console.log('📤 处理转发');
|
||||
|
||||
this.triggerEvent('action', {
|
||||
action: 'forward',
|
||||
message: this.data.message
|
||||
});
|
||||
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
// 处理收藏
|
||||
async handleFavorite() {
|
||||
console.log('⭐ 处理收藏');
|
||||
|
||||
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() {
|
||||
console.log('📋 处理多选');
|
||||
|
||||
this.triggerEvent('action', {
|
||||
action: 'multiSelect',
|
||||
message: this.data.message
|
||||
});
|
||||
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
// 处理复制
|
||||
handleCopy() {
|
||||
console.log('📄 处理复制');
|
||||
|
||||
if (this.data.message.msgType === 'text') {
|
||||
wx.setClipboardData({
|
||||
data: this.data.message.content,
|
||||
success: () => {
|
||||
wx.showToast({
|
||||
title: '已复制到剪贴板',
|
||||
icon: 'success'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
// 处理撤回
|
||||
async handleRecall() {
|
||||
console.log('🔄 处理撤回');
|
||||
|
||||
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() {
|
||||
console.log('🗑️ 处理删除');
|
||||
|
||||
wx.showModal({
|
||||
title: '删除消息',
|
||||
content: '确定要删除这条消息吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.triggerEvent('action', {
|
||||
action: 'delete',
|
||||
message: this.data.message
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
// 处理举报
|
||||
handleReport() {
|
||||
console.log('⚠️ 处理举报');
|
||||
|
||||
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]}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
4
components/message-action-menu/message-action-menu.json
Normal file
4
components/message-action-menu/message-action-menu.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
191
components/message-action-menu/message-action-menu.wxml
Normal file
191
components/message-action-menu/message-action-menu.wxml
Normal 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>
|
||||
446
components/message-action-menu/message-action-menu.wxss
Normal file
446
components/message-action-menu/message-action-menu.wxss
Normal 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;
|
||||
}
|
||||
}
|
||||
118
components/navigation-bar/navigation-bar.js
Normal file
118
components/navigation-bar/navigation-bar.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
Component({
|
||||
options: {
|
||||
multipleSlots: true // 在组件定义时的选项中启用多slot支持
|
||||
},
|
||||
/**
|
||||
* 组件的属性列表
|
||||
*/
|
||||
properties: {
|
||||
extClass: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
background: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
back: {
|
||||
type: Boolean,
|
||||
value: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
value: false
|
||||
},
|
||||
homeButton: {
|
||||
type: Boolean,
|
||||
value: false,
|
||||
},
|
||||
animated: {
|
||||
// 显示隐藏的时候opacity动画效果
|
||||
type: Boolean,
|
||||
value: true
|
||||
},
|
||||
show: {
|
||||
// 显示隐藏导航,隐藏的时候navigation-bar的高度占位还在
|
||||
type: Boolean,
|
||||
value: true,
|
||||
observer: '_showChange'
|
||||
},
|
||||
// back为true的时候,返回的页面深度
|
||||
delta: {
|
||||
type: Number,
|
||||
value: 1
|
||||
},
|
||||
},
|
||||
/**
|
||||
* 组件的初始数据
|
||||
*/
|
||||
data: {
|
||||
displayStyle: ''
|
||||
},
|
||||
lifetimes: {
|
||||
attached() {
|
||||
const rect = wx.getMenuButtonBoundingClientRect()
|
||||
|
||||
// 使用新的API,提供兜底方案
|
||||
let platform, windowWidth, safeArea;
|
||||
try {
|
||||
const deviceInfo = wx.getDeviceInfo();
|
||||
platform = deviceInfo.platform;
|
||||
const windowInfo = wx.getWindowInfo();
|
||||
windowWidth = windowInfo.windowWidth;
|
||||
safeArea = windowInfo.safeArea || {};
|
||||
} catch (error) {
|
||||
console.warn('使用新API失败,回退到旧API:', error);
|
||||
const systemInfo = wx.getSystemInfoSync();
|
||||
platform = systemInfo.platform;
|
||||
windowWidth = systemInfo.windowWidth;
|
||||
safeArea = systemInfo.safeArea || {};
|
||||
}
|
||||
|
||||
const isAndroid = platform === 'android'
|
||||
const isDevtools = platform === 'devtools'
|
||||
const { top = 0, bottom = 0 } = safeArea
|
||||
this.setData({
|
||||
ios: !isAndroid,
|
||||
innerPaddingRight: `padding-right: ${windowWidth - rect.left}px`,
|
||||
leftWidth: `width: ${windowWidth - rect.left}px`,
|
||||
safeAreaTop: isDevtools || isAndroid ? `height: calc(var(--height) + ${top}px); padding-top: ${top}px` : ``
|
||||
})
|
||||
},
|
||||
},
|
||||
/**
|
||||
* 组件的方法列表
|
||||
*/
|
||||
methods: {
|
||||
_showChange(show) {
|
||||
const animated = this.data.animated
|
||||
let displayStyle = ''
|
||||
if (animated) {
|
||||
displayStyle = `opacity: ${show ? '1' : '0'
|
||||
};transition:opacity 0.5s;`
|
||||
} else {
|
||||
displayStyle = `display: ${show ? '' : 'none'}`
|
||||
}
|
||||
this.setData({
|
||||
displayStyle
|
||||
})
|
||||
},
|
||||
back() {
|
||||
const data = this.data
|
||||
if (data.delta) {
|
||||
wx.navigateBack({
|
||||
delta: data.delta
|
||||
})
|
||||
}
|
||||
this.triggerEvent('back', { delta: data.delta }, {})
|
||||
}
|
||||
},
|
||||
})
|
||||
5
components/navigation-bar/navigation-bar.json
Normal file
5
components/navigation-bar/navigation-bar.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"component": true,
|
||||
"styleIsolation": "apply-shared",
|
||||
"usingComponents": {}
|
||||
}
|
||||
64
components/navigation-bar/navigation-bar.wxml
Normal file
64
components/navigation-bar/navigation-bar.wxml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<view class="weui-navigation-bar {{extClass}}">
|
||||
<view class="weui-navigation-bar__inner {{ios ? 'ios' : 'android'}}" style="color: {{color}}; background: {{background}}; {{displayStyle}}; {{innerPaddingRight}}; {{safeAreaTop}};">
|
||||
|
||||
<!-- 左侧按钮 -->
|
||||
<view class='weui-navigation-bar__left' style="{{leftWidth}};">
|
||||
<block wx:if="{{back || homeButton}}">
|
||||
<!-- 返回上一页 -->
|
||||
<block wx:if="{{back}}">
|
||||
<view class="weui-navigation-bar__buttons weui-navigation-bar__buttons_goback">
|
||||
<view
|
||||
bindtap="back"
|
||||
class="weui-navigation-bar__btn_goback_wrapper"
|
||||
hover-class="weui-active"
|
||||
hover-stay-time="100"
|
||||
aria-role="button"
|
||||
aria-label="返回"
|
||||
>
|
||||
<view class="weui-navigation-bar__button weui-navigation-bar__btn_goback"></view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<!-- 返回首页 -->
|
||||
<block wx:if="{{homeButton}}">
|
||||
<view class="weui-navigation-bar__buttons weui-navigation-bar__buttons_home">
|
||||
<view
|
||||
bindtap="home"
|
||||
class="weui-navigation-bar__btn_home_wrapper"
|
||||
hover-class="weui-active"
|
||||
aria-role="button"
|
||||
aria-label="首页"
|
||||
>
|
||||
<view class="weui-navigation-bar__button weui-navigation-bar__btn_home"></view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<slot name="left"></slot>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 标题 -->
|
||||
<view class='weui-navigation-bar__center'>
|
||||
<view wx:if="{{loading}}" class="weui-navigation-bar__loading" aria-role="alert">
|
||||
<view
|
||||
class="weui-loading"
|
||||
aria-role="img"
|
||||
aria-label="加载中"
|
||||
></view>
|
||||
</view>
|
||||
<block wx:if="{{title}}">
|
||||
<text>{{title}}</text>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<slot name="center"></slot>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 右侧留空 -->
|
||||
<view class='weui-navigation-bar__right'>
|
||||
<slot name="right"></slot>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
96
components/navigation-bar/navigation-bar.wxss
Normal file
96
components/navigation-bar/navigation-bar.wxss
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
.weui-navigation-bar {
|
||||
--weui-FG-0:rgba(0,0,0,.9);
|
||||
--height: 44px;
|
||||
--left: 16px;
|
||||
}
|
||||
.weui-navigation-bar .android {
|
||||
--height: 48px;
|
||||
}
|
||||
|
||||
.weui-navigation-bar {
|
||||
overflow: hidden;
|
||||
color: var(--weui-FG-0);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__inner {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: calc(var(--height) + env(safe-area-inset-top));
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__left {
|
||||
position: relative;
|
||||
padding-left: var(--left);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__btn_goback_wrapper {
|
||||
padding: 11px 18px 11px 16px;
|
||||
margin: -11px -18px -11px -16px;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__btn_goback_wrapper.weui-active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__btn_goback {
|
||||
font-size: 12px;
|
||||
width: 12px;
|
||||
height: 24px;
|
||||
-webkit-mask: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='24' viewBox='0 0 12 24'%3E %3Cpath fill-opacity='.9' fill-rule='evenodd' d='M10 19.438L8.955 20.5l-7.666-7.79a1.02 1.02 0 0 1 0-1.42L8.955 3.5 10 4.563 2.682 12 10 19.438z'/%3E%3C/svg%3E") no-repeat 50% 50%;
|
||||
mask: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='24' viewBox='0 0 12 24'%3E %3Cpath fill-opacity='.9' fill-rule='evenodd' d='M10 19.438L8.955 20.5l-7.666-7.79a1.02 1.02 0 0 1 0-1.42L8.955 3.5 10 4.563 2.682 12 10 19.438z'/%3E%3C/svg%3E") no-repeat 50% 50%;
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
background-color: var(--weui-FG-0);
|
||||
}
|
||||
|
||||
.weui-navigation-bar__center {
|
||||
font-size: 17px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__loading {
|
||||
margin-right: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.weui-loading {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
background: transparent url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='80px' height='80px' viewBox='0 0 80 80' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3Eloading%3C/title%3E%3Cdefs%3E%3ClinearGradient x1='94.0869141%25' y1='0%25' x2='94.0869141%25' y2='90.559082%25' id='linearGradient-1'%3E%3Cstop stop-color='%23606060' stop-opacity='0' offset='0%25'%3E%3C/stop%3E%3Cstop stop-color='%23606060' stop-opacity='0.3' offset='100%25'%3E%3C/stop%3E%3C/linearGradient%3E%3ClinearGradient x1='100%25' y1='8.67370605%25' x2='100%25' y2='90.6286621%25' id='linearGradient-2'%3E%3Cstop stop-color='%23606060' offset='0%25'%3E%3C/stop%3E%3Cstop stop-color='%23606060' stop-opacity='0.3' offset='100%25'%3E%3C/stop%3E%3C/linearGradient%3E%3C/defs%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' opacity='0.9'%3E%3Cg%3E%3Cpath d='M40,0 C62.09139,0 80,17.90861 80,40 C80,62.09139 62.09139,80 40,80 L40,73 C58.2253967,73 73,58.2253967 73,40 C73,21.7746033 58.2253967,7 40,7 L40,0 Z' fill='url(%23linearGradient-1)'%3E%3C/path%3E%3Cpath d='M40,0 L40,7 C21.7746033,7 7,21.7746033 7,40 C7,58.2253967 21.7746033,73 40,73 L40,80 C17.90861,80 0,62.09139 0,40 C0,17.90861 17.90861,0 40,0 Z' fill='url(%23linearGradient-2)'%3E%3C/path%3E%3Ccircle id='Oval' fill='%23606060' cx='40.5' cy='3.5' r='3.5'%3E%3C/circle%3E%3C/g%3E%3C/g%3E%3C/svg%3E%0A") no-repeat;
|
||||
background-size: 100%;
|
||||
margin-left: 0;
|
||||
animation: loading linear infinite 1s;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
from {
|
||||
transform: rotate(0);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
456
components/page-transition/page-transition.js
Normal file
456
components/page-transition/page-transition.js
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
// 🎬 页面过渡组件逻辑
|
||||
const animationManager = require('../../utils/animation-manager.js');
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
// 过渡类型
|
||||
transitionType: {
|
||||
type: String,
|
||||
value: 'fade',
|
||||
observer: 'onTransitionTypeChange'
|
||||
},
|
||||
|
||||
// 是否显示
|
||||
visible: {
|
||||
type: Boolean,
|
||||
value: true,
|
||||
observer: 'onVisibleChange'
|
||||
},
|
||||
|
||||
// 动画时长
|
||||
duration: {
|
||||
type: Number,
|
||||
value: 300
|
||||
},
|
||||
|
||||
// 缓动函数
|
||||
easing: {
|
||||
type: String,
|
||||
value: 'ease-out'
|
||||
},
|
||||
|
||||
// 是否显示遮罩
|
||||
showOverlay: {
|
||||
type: Boolean,
|
||||
value: false
|
||||
},
|
||||
|
||||
// 遮罩类型
|
||||
overlayType: {
|
||||
type: String,
|
||||
value: 'fade-black'
|
||||
},
|
||||
|
||||
// 是否显示加载
|
||||
showLoading: {
|
||||
type: Boolean,
|
||||
value: false
|
||||
},
|
||||
|
||||
// 加载文本
|
||||
loadingText: {
|
||||
type: String,
|
||||
value: '加载中...'
|
||||
},
|
||||
|
||||
// 是否启用硬件加速
|
||||
hardwareAccelerated: {
|
||||
type: Boolean,
|
||||
value: true
|
||||
},
|
||||
|
||||
// 调试模式
|
||||
debug: {
|
||||
type: Boolean,
|
||||
value: false
|
||||
}
|
||||
},
|
||||
|
||||
data: {
|
||||
// 过渡状态
|
||||
transitionState: 'idle', // idle, entering, entered, leaving, left
|
||||
|
||||
// 动画数据
|
||||
animationData: null,
|
||||
loadingAnimation: null,
|
||||
|
||||
// 样式
|
||||
animationStyle: '',
|
||||
overlayStyle: '',
|
||||
|
||||
// CSS类
|
||||
transitionClass: '',
|
||||
overlayClass: ''
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
console.log('🎬 页面过渡组件加载');
|
||||
this.initComponent();
|
||||
},
|
||||
|
||||
ready() {
|
||||
console.log('🎬 页面过渡组件就绪');
|
||||
this.setupInitialState();
|
||||
},
|
||||
|
||||
detached() {
|
||||
console.log('🎬 页面过渡组件卸载');
|
||||
this.cleanup();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 初始化组件
|
||||
initComponent() {
|
||||
// 设置初始过渡类
|
||||
this.updateTransitionClass();
|
||||
|
||||
// 设置遮罩类
|
||||
this.updateOverlayClass();
|
||||
|
||||
// 创建加载动画
|
||||
this.createLoadingAnimation();
|
||||
},
|
||||
|
||||
// 设置初始状态
|
||||
setupInitialState() {
|
||||
if (this.properties.visible) {
|
||||
this.enter();
|
||||
} else {
|
||||
this.setData({
|
||||
transitionState: 'left'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 过渡类型变化处理
|
||||
onTransitionTypeChange(newType, oldType) {
|
||||
if (newType !== oldType) {
|
||||
this.updateTransitionClass();
|
||||
}
|
||||
},
|
||||
|
||||
// 可见性变化处理
|
||||
onVisibleChange(visible, wasVisible) {
|
||||
if (visible === wasVisible) return;
|
||||
|
||||
if (visible) {
|
||||
this.enter();
|
||||
} else {
|
||||
this.leave();
|
||||
}
|
||||
},
|
||||
|
||||
// 🎭 ===== 过渡控制 =====
|
||||
|
||||
// 进入动画
|
||||
async enter() {
|
||||
if (this.data.transitionState === 'entering' || this.data.transitionState === 'entered') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🎬 开始进入动画:', this.properties.transitionType);
|
||||
|
||||
this.setData({
|
||||
transitionState: 'entering'
|
||||
});
|
||||
|
||||
this.triggerEvent('transitionstart', {
|
||||
type: 'enter',
|
||||
transitionType: this.properties.transitionType
|
||||
});
|
||||
|
||||
try {
|
||||
// 创建进入动画
|
||||
const animation = this.createEnterAnimation();
|
||||
|
||||
this.setData({
|
||||
animationData: animation.export()
|
||||
});
|
||||
|
||||
// 等待动画完成
|
||||
await this.waitForAnimation();
|
||||
|
||||
this.setData({
|
||||
transitionState: 'entered'
|
||||
});
|
||||
|
||||
this.triggerEvent('transitionend', {
|
||||
type: 'enter',
|
||||
transitionType: this.properties.transitionType
|
||||
});
|
||||
|
||||
console.log('✅ 进入动画完成');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 进入动画失败:', error);
|
||||
this.setData({
|
||||
transitionState: 'entered'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 退出动画
|
||||
async leave() {
|
||||
if (this.data.transitionState === 'leaving' || this.data.transitionState === 'left') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🎬 开始退出动画:', this.properties.transitionType);
|
||||
|
||||
this.setData({
|
||||
transitionState: 'leaving'
|
||||
});
|
||||
|
||||
this.triggerEvent('transitionstart', {
|
||||
type: 'leave',
|
||||
transitionType: this.properties.transitionType
|
||||
});
|
||||
|
||||
try {
|
||||
// 创建退出动画
|
||||
const animation = this.createLeaveAnimation();
|
||||
|
||||
this.setData({
|
||||
animationData: animation.export()
|
||||
});
|
||||
|
||||
// 等待动画完成
|
||||
await this.waitForAnimation();
|
||||
|
||||
this.setData({
|
||||
transitionState: 'left'
|
||||
});
|
||||
|
||||
this.triggerEvent('transitionend', {
|
||||
type: 'leave',
|
||||
transitionType: this.properties.transitionType
|
||||
});
|
||||
|
||||
console.log('✅ 退出动画完成');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 退出动画失败:', error);
|
||||
this.setData({
|
||||
transitionState: 'left'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 🎨 ===== 动画创建 =====
|
||||
|
||||
// 创建进入动画
|
||||
createEnterAnimation() {
|
||||
const transitionType = this.properties.transitionType;
|
||||
|
||||
switch (transitionType) {
|
||||
case 'slideLeft':
|
||||
return animationManager.slideIn('left', {
|
||||
duration: this.properties.duration,
|
||||
timingFunction: this.properties.easing
|
||||
});
|
||||
|
||||
case 'slideRight':
|
||||
return animationManager.slideIn('right', {
|
||||
duration: this.properties.duration,
|
||||
timingFunction: this.properties.easing
|
||||
});
|
||||
|
||||
case 'slideUp':
|
||||
return animationManager.slideIn('up', {
|
||||
duration: this.properties.duration,
|
||||
timingFunction: this.properties.easing
|
||||
});
|
||||
|
||||
case 'slideDown':
|
||||
return animationManager.slideIn('down', {
|
||||
duration: this.properties.duration,
|
||||
timingFunction: this.properties.easing
|
||||
});
|
||||
|
||||
case 'scale':
|
||||
return animationManager.scale(1, {
|
||||
duration: this.properties.duration,
|
||||
timingFunction: this.properties.easing
|
||||
});
|
||||
|
||||
case 'bounce':
|
||||
return animationManager.bounceIn({
|
||||
duration: this.properties.duration
|
||||
});
|
||||
|
||||
case 'fade':
|
||||
default:
|
||||
return animationManager.fadeIn({
|
||||
duration: this.properties.duration,
|
||||
timingFunction: this.properties.easing
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 创建退出动画
|
||||
createLeaveAnimation() {
|
||||
const transitionType = this.properties.transitionType;
|
||||
|
||||
switch (transitionType) {
|
||||
case 'slideLeft':
|
||||
return animationManager.slideOut('left', '100%', {
|
||||
duration: this.properties.duration,
|
||||
timingFunction: this.properties.easing
|
||||
});
|
||||
|
||||
case 'slideRight':
|
||||
return animationManager.slideOut('right', '100%', {
|
||||
duration: this.properties.duration,
|
||||
timingFunction: this.properties.easing
|
||||
});
|
||||
|
||||
case 'slideUp':
|
||||
return animationManager.slideOut('up', '100%', {
|
||||
duration: this.properties.duration,
|
||||
timingFunction: this.properties.easing
|
||||
});
|
||||
|
||||
case 'slideDown':
|
||||
return animationManager.slideOut('down', '100%', {
|
||||
duration: this.properties.duration,
|
||||
timingFunction: this.properties.easing
|
||||
});
|
||||
|
||||
case 'scale':
|
||||
return animationManager.scale(0, {
|
||||
duration: this.properties.duration,
|
||||
timingFunction: this.properties.easing
|
||||
});
|
||||
|
||||
case 'bounce':
|
||||
return animationManager.bounceOut({
|
||||
duration: this.properties.duration
|
||||
});
|
||||
|
||||
case 'fade':
|
||||
default:
|
||||
return animationManager.fadeOut({
|
||||
duration: this.properties.duration,
|
||||
timingFunction: this.properties.easing
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 创建加载动画
|
||||
createLoadingAnimation() {
|
||||
const loadingAnimation = animationManager.loadingSpinner({
|
||||
duration: 1000
|
||||
});
|
||||
|
||||
this.setData({
|
||||
loadingAnimation: loadingAnimation.export()
|
||||
});
|
||||
|
||||
// 循环播放加载动画
|
||||
if (this.properties.showLoading) {
|
||||
this.startLoadingLoop();
|
||||
}
|
||||
},
|
||||
|
||||
// 开始加载循环
|
||||
startLoadingLoop() {
|
||||
this.loadingTimer = setInterval(() => {
|
||||
if (this.properties.showLoading) {
|
||||
const loadingAnimation = animationManager.loadingSpinner({
|
||||
duration: 1000
|
||||
});
|
||||
|
||||
this.setData({
|
||||
loadingAnimation: loadingAnimation.export()
|
||||
});
|
||||
} else {
|
||||
this.stopLoadingLoop();
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
// 停止加载循环
|
||||
stopLoadingLoop() {
|
||||
if (this.loadingTimer) {
|
||||
clearInterval(this.loadingTimer);
|
||||
this.loadingTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 🎯 ===== 样式管理 =====
|
||||
|
||||
// 更新过渡类
|
||||
updateTransitionClass() {
|
||||
let transitionClass = this.properties.transitionType;
|
||||
|
||||
if (this.properties.hardwareAccelerated) {
|
||||
transitionClass += ' hardware-accelerated';
|
||||
}
|
||||
|
||||
if (this.properties.debug) {
|
||||
transitionClass += ' debug';
|
||||
}
|
||||
|
||||
this.setData({
|
||||
transitionClass: transitionClass
|
||||
});
|
||||
},
|
||||
|
||||
// 更新遮罩类
|
||||
updateOverlayClass() {
|
||||
this.setData({
|
||||
overlayClass: this.properties.overlayType
|
||||
});
|
||||
},
|
||||
|
||||
// 🔧 ===== 工具方法 =====
|
||||
|
||||
// 等待动画完成
|
||||
waitForAnimation() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, this.properties.duration + 50); // 添加50ms缓冲
|
||||
});
|
||||
},
|
||||
|
||||
// 手动触发进入
|
||||
triggerEnter() {
|
||||
this.enter();
|
||||
},
|
||||
|
||||
// 手动触发退出
|
||||
triggerLeave() {
|
||||
this.leave();
|
||||
},
|
||||
|
||||
// 重置状态
|
||||
reset() {
|
||||
this.setData({
|
||||
transitionState: 'idle',
|
||||
animationData: null
|
||||
});
|
||||
},
|
||||
|
||||
// 获取当前状态
|
||||
getState() {
|
||||
return {
|
||||
transitionState: this.data.transitionState,
|
||||
transitionType: this.properties.transitionType,
|
||||
visible: this.properties.visible
|
||||
};
|
||||
},
|
||||
|
||||
// 清理资源
|
||||
cleanup() {
|
||||
this.stopLoadingLoop();
|
||||
|
||||
if (this.animationTimer) {
|
||||
clearTimeout(this.animationTimer);
|
||||
this.animationTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
4
components/page-transition/page-transition.json
Normal file
4
components/page-transition/page-transition.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
20
components/page-transition/page-transition.wxml
Normal file
20
components/page-transition/page-transition.wxml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!-- 🎬 页面过渡组件 -->
|
||||
<view class="page-transition-container {{transitionClass}}"
|
||||
style="{{animationStyle}}"
|
||||
animation="{{animationData}}">
|
||||
|
||||
<!-- 页面内容插槽 -->
|
||||
<slot></slot>
|
||||
|
||||
<!-- 过渡遮罩 -->
|
||||
<view wx:if="{{showOverlay}}"
|
||||
class="transition-overlay {{overlayClass}}"
|
||||
style="{{overlayStyle}}">
|
||||
</view>
|
||||
|
||||
<!-- 加载指示器 -->
|
||||
<view wx:if="{{showLoading}}" class="transition-loading">
|
||||
<view class="loading-spinner" animation="{{loadingAnimation}}"></view>
|
||||
<text class="loading-text">{{loadingText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
386
components/page-transition/page-transition.wxss
Normal file
386
components/page-transition/page-transition.wxss
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
/* 🎬 页面过渡组件样式 */
|
||||
|
||||
/* CSS变量定义 */
|
||||
.page-transition-container {
|
||||
--primary-color: #007AFF;
|
||||
--background-color: #F2F2F7;
|
||||
--surface-color: #FFFFFF;
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #8E8E93;
|
||||
--shadow-medium: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
|
||||
--radius-large: 20rpx;
|
||||
}
|
||||
|
||||
/* 🌙 深色模式支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.page-transition-container {
|
||||
--primary-color: #0A84FF;
|
||||
--background-color: #000000;
|
||||
--surface-color: #1C1C1E;
|
||||
--text-primary: #FFFFFF;
|
||||
--text-secondary: #8E8E93;
|
||||
--shadow-medium: 0 8rpx 24rpx rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.page-transition-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 🎭 过渡类型样式 */
|
||||
.page-transition-container.slide-left {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.page-transition-container.slide-right {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.page-transition-container.slide-up {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.page-transition-container.slide-down {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.page-transition-container.fade {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.page-transition-container.scale {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.page-transition-container.flip {
|
||||
transform: rotateY(90deg);
|
||||
}
|
||||
|
||||
.page-transition-container.zoom {
|
||||
transform: scale(1.2);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 激活状态 */
|
||||
.page-transition-container.active {
|
||||
transform: translateX(0) translateY(0) scale(1) rotateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 🎨 过渡遮罩 */
|
||||
.transition-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.transition-overlay.fade-black {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.transition-overlay.fade-white {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.transition-overlay.blur {
|
||||
backdrop-filter: blur(10rpx);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.transition-overlay.gradient {
|
||||
background: linear-gradient(45deg,
|
||||
rgba(0, 122, 255, 0.1) 0%,
|
||||
rgba(90, 200, 250, 0.1) 100%);
|
||||
}
|
||||
|
||||
/* 🔄 加载指示器 */
|
||||
.transition-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border: 4rpx solid rgba(0, 122, 255, 0.2);
|
||||
border-top: 4rpx solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 🎪 预定义动画类 */
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flipIn {
|
||||
from {
|
||||
transform: rotateY(90deg);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: rotateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes zoomIn {
|
||||
from {
|
||||
transform: scale(1.2);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounceIn {
|
||||
0% {
|
||||
transform: scale(0.3);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 🎭 动画类应用 */
|
||||
.page-transition-container.animate-slide-left {
|
||||
animation: slideInLeft 0.4s ease-out;
|
||||
}
|
||||
|
||||
.page-transition-container.animate-slide-right {
|
||||
animation: slideInRight 0.4s ease-out;
|
||||
}
|
||||
|
||||
.page-transition-container.animate-slide-up {
|
||||
animation: slideInUp 0.4s ease-out;
|
||||
}
|
||||
|
||||
.page-transition-container.animate-slide-down {
|
||||
animation: slideInDown 0.4s ease-out;
|
||||
}
|
||||
|
||||
.page-transition-container.animate-fade {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.page-transition-container.animate-scale {
|
||||
animation: scaleIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.page-transition-container.animate-flip {
|
||||
animation: flipIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.page-transition-container.animate-zoom {
|
||||
animation: zoomIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.page-transition-container.animate-bounce {
|
||||
animation: bounceIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 📱 响应式设计 */
|
||||
@media screen and (max-width: 375px) {
|
||||
.loading-spinner {
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
border-width: 3rpx;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 414px) {
|
||||
.loading-spinner {
|
||||
width: 70rpx;
|
||||
height: 70rpx;
|
||||
border-width: 5rpx;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* 🎨 性能优化 */
|
||||
.page-transition-container {
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* 减少重绘 */
|
||||
.page-transition-container .transition-content {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
/* 硬件加速 */
|
||||
.page-transition-container.hardware-accelerated {
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* 🎭 特殊效果 */
|
||||
.page-transition-container.glass-effect {
|
||||
backdrop-filter: blur(20rpx);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.page-transition-container.shadow-effect {
|
||||
box-shadow: var(--shadow-medium);
|
||||
}
|
||||
|
||||
.page-transition-container.glow-effect {
|
||||
box-shadow: 0 0 40rpx rgba(0, 122, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 🎪 组合动画 */
|
||||
.page-transition-container.slide-fade {
|
||||
animation: slideInLeft 0.4s ease-out, fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.page-transition-container.scale-fade {
|
||||
animation: scaleIn 0.3s ease-out, fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.page-transition-container.flip-fade {
|
||||
animation: flipIn 0.5s ease-out, fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* 🎯 状态指示器 */
|
||||
.page-transition-container.loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.page-transition-container.transitioning {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-transition-container.completed {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 🔧 调试模式 */
|
||||
.page-transition-container.debug {
|
||||
border: 2rpx dashed #ff0000;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-transition-container.debug::before {
|
||||
content: 'TRANSITION DEBUG';
|
||||
position: absolute;
|
||||
top: 10rpx;
|
||||
left: 10rpx;
|
||||
background: #ff0000;
|
||||
color: white;
|
||||
padding: 4rpx 8rpx;
|
||||
font-size: 20rpx;
|
||||
z-index: 9999;
|
||||
}
|
||||
321
components/voice-message/voice-message.js
Normal file
321
components/voice-message/voice-message.js
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
// 🎤 语音消息组件逻辑
|
||||
const voiceMessageManager = require('../../utils/voice-message-manager.js');
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
// 语音消息数据
|
||||
voiceData: {
|
||||
type: Object,
|
||||
value: {},
|
||||
observer: 'onVoiceDataChange'
|
||||
},
|
||||
|
||||
// 是否为自己发送的消息
|
||||
isSelf: {
|
||||
type: Boolean,
|
||||
value: false
|
||||
},
|
||||
|
||||
// 消息ID
|
||||
messageId: {
|
||||
type: String,
|
||||
value: ''
|
||||
}
|
||||
},
|
||||
|
||||
data: {
|
||||
// 播放状态
|
||||
isPlaying: false,
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
|
||||
// 播放进度
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
playProgress: 0,
|
||||
|
||||
// 波形数据
|
||||
waveformData: [],
|
||||
currentWaveIndex: 0,
|
||||
|
||||
// 语音信息
|
||||
voiceUrl: '',
|
||||
voiceDuration: 0
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
console.log('🎤 语音消息组件加载');
|
||||
this.initComponent();
|
||||
},
|
||||
|
||||
detached() {
|
||||
console.log('🎤 语音消息组件卸载');
|
||||
this.cleanup();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 初始化组件
|
||||
initComponent() {
|
||||
// 注册语音管理器事件
|
||||
this.registerVoiceEvents();
|
||||
|
||||
// 生成波形数据
|
||||
this.generateWaveform();
|
||||
|
||||
// 检查当前播放状态
|
||||
this.checkPlayingState();
|
||||
},
|
||||
|
||||
// 语音数据变化处理
|
||||
onVoiceDataChange(newData, oldData) {
|
||||
if (!newData || JSON.stringify(newData) === JSON.stringify(oldData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🎤 语音数据更新:', newData);
|
||||
|
||||
this.setData({
|
||||
voiceUrl: newData.url || '',
|
||||
voiceDuration: newData.duration || 0,
|
||||
duration: newData.duration || 0
|
||||
});
|
||||
|
||||
// 重新生成波形
|
||||
this.generateWaveform();
|
||||
|
||||
// 检查播放状态
|
||||
this.checkPlayingState();
|
||||
},
|
||||
|
||||
// 注册语音管理器事件
|
||||
registerVoiceEvents() {
|
||||
// 播放开始事件
|
||||
voiceMessageManager.on('playStart', () => {
|
||||
this.checkPlayingState();
|
||||
});
|
||||
|
||||
// 播放结束事件
|
||||
voiceMessageManager.on('playEnd', () => {
|
||||
this.setData({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
playProgress: 0,
|
||||
currentWaveIndex: 0
|
||||
});
|
||||
});
|
||||
|
||||
// 播放进度更新事件
|
||||
voiceMessageManager.on('playTimeUpdate', (data) => {
|
||||
if (this.isCurrentMessage()) {
|
||||
this.updatePlayProgress(data.currentTime, data.duration);
|
||||
}
|
||||
});
|
||||
|
||||
// 播放错误事件
|
||||
voiceMessageManager.on('playError', (error) => {
|
||||
if (this.isCurrentMessage()) {
|
||||
console.error('🎤 语音播放错误:', error);
|
||||
this.setData({
|
||||
isPlaying: false,
|
||||
isLoading: false,
|
||||
hasError: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 播放可以开始事件
|
||||
voiceMessageManager.on('playCanplay', () => {
|
||||
if (this.isCurrentMessage()) {
|
||||
this.setData({
|
||||
isLoading: false,
|
||||
hasError: false
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 检查是否为当前播放的消息
|
||||
isCurrentMessage() {
|
||||
const currentMessageId = voiceMessageManager.getCurrentPlayingMessageId();
|
||||
return currentMessageId === this.properties.messageId;
|
||||
},
|
||||
|
||||
// 检查播放状态
|
||||
checkPlayingState() {
|
||||
const isCurrentlyPlaying = this.isCurrentMessage() && voiceMessageManager.isPlaying();
|
||||
|
||||
this.setData({
|
||||
isPlaying: isCurrentlyPlaying
|
||||
});
|
||||
},
|
||||
|
||||
// 切换播放状态
|
||||
async togglePlay() {
|
||||
if (this.data.hasError) {
|
||||
return this.retryPlay();
|
||||
}
|
||||
|
||||
if (this.data.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.data.isPlaying) {
|
||||
// 暂停播放
|
||||
voiceMessageManager.pausePlaying();
|
||||
this.setData({ isPlaying: false });
|
||||
} else {
|
||||
// 开始播放
|
||||
await this.startPlay();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('🎤 切换播放状态失败:', error);
|
||||
this.setData({
|
||||
hasError: true,
|
||||
isLoading: false,
|
||||
isPlaying: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 开始播放
|
||||
async startPlay() {
|
||||
if (!this.data.voiceUrl) {
|
||||
console.error('🎤 语音URL为空');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.setData({
|
||||
isLoading: true,
|
||||
hasError: false
|
||||
});
|
||||
|
||||
// 播放语音消息
|
||||
await voiceMessageManager.playVoiceMessage(
|
||||
this.data.voiceUrl,
|
||||
this.properties.messageId
|
||||
);
|
||||
|
||||
this.setData({
|
||||
isPlaying: true,
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
console.log('🎤 开始播放语音消息');
|
||||
|
||||
} catch (error) {
|
||||
console.error('🎤 播放语音消息失败:', error);
|
||||
this.setData({
|
||||
hasError: true,
|
||||
isLoading: false,
|
||||
isPlaying: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 重试播放
|
||||
async retryPlay() {
|
||||
console.log('🎤 重试播放语音消息');
|
||||
|
||||
this.setData({
|
||||
hasError: false,
|
||||
isLoading: false,
|
||||
isPlaying: false
|
||||
});
|
||||
|
||||
await this.startPlay();
|
||||
},
|
||||
|
||||
// 更新播放进度
|
||||
updatePlayProgress(currentTime, duration) {
|
||||
if (!duration || duration <= 0) return;
|
||||
|
||||
const progress = (currentTime / duration) * 100;
|
||||
const waveIndex = Math.floor((currentTime / duration) * this.data.waveformData.length);
|
||||
|
||||
this.setData({
|
||||
currentTime: currentTime,
|
||||
duration: duration,
|
||||
playProgress: progress,
|
||||
currentWaveIndex: Math.max(0, waveIndex)
|
||||
});
|
||||
},
|
||||
|
||||
// 生成波形数据
|
||||
generateWaveform() {
|
||||
const duration = this.data.voiceDuration || this.data.duration || 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: waveformData,
|
||||
currentWaveIndex: 0
|
||||
});
|
||||
|
||||
console.log('🌊 生成波形数据:', waveformData.length, '个波形条');
|
||||
},
|
||||
|
||||
// 格式化时长显示
|
||||
formatDuration(duration) {
|
||||
if (!duration || duration <= 0) return '0"';
|
||||
|
||||
const seconds = Math.floor(duration / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${remainingSeconds}"`;
|
||||
}
|
||||
},
|
||||
|
||||
// 格式化时间显示
|
||||
formatTime(time) {
|
||||
if (!time || time <= 0) return '0:00';
|
||||
|
||||
const totalSeconds = Math.floor(time);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
// 获取语音文件大小描述
|
||||
getFileSizeDescription(fileSize) {
|
||||
if (!fileSize || fileSize <= 0) return '';
|
||||
|
||||
if (fileSize < 1024) {
|
||||
return `${fileSize}B`;
|
||||
} else if (fileSize < 1024 * 1024) {
|
||||
return `${(fileSize / 1024).toFixed(1)}KB`;
|
||||
} else {
|
||||
return `${(fileSize / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
},
|
||||
|
||||
// 清理资源
|
||||
cleanup() {
|
||||
// 如果当前正在播放这个消息,停止播放
|
||||
if (this.isCurrentMessage() && voiceMessageManager.isPlaying()) {
|
||||
voiceMessageManager.stopPlaying();
|
||||
}
|
||||
|
||||
// 移除事件监听器
|
||||
voiceMessageManager.off('playStart');
|
||||
voiceMessageManager.off('playEnd');
|
||||
voiceMessageManager.off('playTimeUpdate');
|
||||
voiceMessageManager.off('playError');
|
||||
voiceMessageManager.off('playCanplay');
|
||||
}
|
||||
}
|
||||
});
|
||||
4
components/voice-message/voice-message.json
Normal file
4
components/voice-message/voice-message.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
53
components/voice-message/voice-message.wxml
Normal file
53
components/voice-message/voice-message.wxml
Normal 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>
|
||||
443
components/voice-message/voice-message.wxss
Normal file
443
components/voice-message/voice-message.wxss
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
/* 🎤 语音消息组件样式 */
|
||||
|
||||
/* 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: 480rpx;
|
||||
margin: 8rpx 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 🎨 语音气泡 */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 自己发送的消息 */
|
||||
.voice-message-container.self .voice-bubble {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 他人发送的消息 */
|
||||
.voice-message-container.other .voice-bubble {
|
||||
background: var(--background-light);
|
||||
color: var(--text-primary);
|
||||
border: 1rpx solid var(--border-color);
|
||||
}
|
||||
|
||||
/* 播放状态 */
|
||||
.voice-message-container.playing .voice-bubble {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.voice-message-container.self.playing .voice-bubble {
|
||||
background: linear-gradient(135deg, var(--success-color) 0%, var(--primary-light) 100%);
|
||||
}
|
||||
|
||||
.voice-message-container.other.playing .voice-bubble {
|
||||
background: rgba(0, 122, 255, 0.1);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* 🎵 播放按钮 */
|
||||
.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.2);
|
||||
}
|
||||
|
||||
.voice-message-container.other .play-button {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.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: white;
|
||||
}
|
||||
|
||||
/* 播放动画 */
|
||||
.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;
|
||||
}
|
||||
|
||||
.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.4);
|
||||
}
|
||||
|
||||
.voice-message-container.other .wave-bar {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.voice-message-container.self .wave-bar.active {
|
||||
background: white;
|
||||
transform: scaleY(1.2);
|
||||
}
|
||||
|
||||
.voice-message-container.other .wave-bar.active {
|
||||
background: var(--primary-color);
|
||||
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.9);
|
||||
}
|
||||
|
||||
.voice-message-container.other .duration-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 📊 播放进度条 */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 📱 响应式设计 */
|
||||
@media screen and (max-width: 375px) {
|
||||
.voice-message-container {
|
||||
max-width: 400rpx;
|
||||
}
|
||||
|
||||
.voice-bubble {
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 70rpx;
|
||||
height: 70rpx;
|
||||
margin-right: 20rpx;
|
||||
min-width: 70rpx;
|
||||
}
|
||||
|
||||
.play-icon .icon {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.voice-waveform {
|
||||
height: 50rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 5rpx;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 414px) {
|
||||
.voice-message-container {
|
||||
max-width: 520rpx;
|
||||
}
|
||||
|
||||
.voice-bubble {
|
||||
padding: 28rpx;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 90rpx;
|
||||
height: 90rpx;
|
||||
margin-right: 28rpx;
|
||||
min-width: 90rpx;
|
||||
}
|
||||
|
||||
.play-icon .icon {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
.voice-waveform {
|
||||
height: 70rpx;
|
||||
margin-right: 28rpx;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 7rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* 🎭 动画增强 */
|
||||
.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;
|
||||
}
|
||||
562
components/voice-recorder/voice-recorder.js
Normal file
562
components/voice-recorder/voice-recorder.js
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
// 🎤 语音录制组件逻辑
|
||||
const voiceMessageManager = require('../../utils/voice-message-manager.js');
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
// 是否显示录音界面
|
||||
visible: {
|
||||
type: Boolean,
|
||||
value: false,
|
||||
observer: 'onVisibleChange'
|
||||
},
|
||||
|
||||
// 最大录音时长(毫秒)
|
||||
maxDuration: {
|
||||
type: Number,
|
||||
value: 60000
|
||||
},
|
||||
|
||||
// 最小录音时长(毫秒)
|
||||
minDuration: {
|
||||
type: Number,
|
||||
value: 1000
|
||||
}
|
||||
},
|
||||
|
||||
data: {
|
||||
// 录音状态:idle, recording, paused, completed, error
|
||||
recordingState: 'idle',
|
||||
|
||||
// 状态文本
|
||||
statusText: '准备录音',
|
||||
|
||||
// 录音时长
|
||||
recordingDuration: 0,
|
||||
|
||||
// 实时波形数据
|
||||
realtimeWaveform: [],
|
||||
|
||||
// 最终波形数据
|
||||
finalWaveform: [],
|
||||
|
||||
// 录音文件信息
|
||||
tempFilePath: '',
|
||||
fileSize: 0,
|
||||
|
||||
// 播放状态
|
||||
isPlaying: false,
|
||||
|
||||
// 错误信息
|
||||
errorMessage: '',
|
||||
|
||||
// 权限引导
|
||||
showPermissionGuide: false,
|
||||
|
||||
// 定时器
|
||||
durationTimer: null,
|
||||
waveformTimer: null
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
console.log('🎤 语音录制组件加载');
|
||||
this.initComponent();
|
||||
},
|
||||
|
||||
detached() {
|
||||
console.log('🎤 语音录制组件卸载');
|
||||
this.cleanup();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 事件阻断占位
|
||||
noop() {},
|
||||
// 初始化组件
|
||||
initComponent() {
|
||||
// 注册语音管理器事件
|
||||
this.registerVoiceEvents();
|
||||
|
||||
// 初始化波形数据
|
||||
this.initWaveform();
|
||||
},
|
||||
|
||||
// 可见性变化处理
|
||||
onVisibleChange(visible) {
|
||||
if (visible) {
|
||||
this.resetRecorder();
|
||||
} else {
|
||||
this.cleanup();
|
||||
}
|
||||
},
|
||||
|
||||
// 注册语音管理器事件
|
||||
registerVoiceEvents() {
|
||||
// 录音开始事件
|
||||
voiceMessageManager.on('recordStart', () => {
|
||||
this.setData({
|
||||
recordingState: 'recording',
|
||||
statusText: '正在录音...',
|
||||
recordingDuration: 0
|
||||
});
|
||||
|
||||
this.startDurationTimer();
|
||||
this.startWaveformAnimation();
|
||||
});
|
||||
|
||||
// 录音停止事件
|
||||
voiceMessageManager.on('recordStop', (data) => {
|
||||
this.setData({
|
||||
recordingState: 'completed',
|
||||
statusText: '录音完成',
|
||||
recordingDuration: data.duration,
|
||||
tempFilePath: data.tempFilePath,
|
||||
fileSize: data.fileSize
|
||||
});
|
||||
|
||||
this.stopTimers();
|
||||
this.generateFinalWaveform();
|
||||
});
|
||||
|
||||
// 录音暂停事件
|
||||
voiceMessageManager.on('recordPause', () => {
|
||||
this.setData({
|
||||
recordingState: 'paused',
|
||||
statusText: '录音已暂停'
|
||||
});
|
||||
|
||||
this.stopTimers();
|
||||
});
|
||||
|
||||
// 录音恢复事件
|
||||
voiceMessageManager.on('recordResume', () => {
|
||||
this.setData({
|
||||
recordingState: 'recording',
|
||||
statusText: '正在录音...'
|
||||
});
|
||||
|
||||
this.startDurationTimer();
|
||||
this.startWaveformAnimation();
|
||||
});
|
||||
|
||||
// 录音错误事件
|
||||
voiceMessageManager.on('recordError', (error) => {
|
||||
console.error('🎤 录音错误:', error);
|
||||
|
||||
this.setData({
|
||||
recordingState: 'error',
|
||||
statusText: '录音失败',
|
||||
errorMessage: this.getErrorMessage(error)
|
||||
});
|
||||
|
||||
this.stopTimers();
|
||||
});
|
||||
|
||||
// 录音帧数据事件
|
||||
voiceMessageManager.on('recordFrame', (data) => {
|
||||
this.updateRealtimeWaveform(data.frameBuffer);
|
||||
});
|
||||
},
|
||||
|
||||
// 🎤 ===== 录音控制 =====
|
||||
|
||||
// 开始录音
|
||||
async startRecording() {
|
||||
console.log('🎤 开始录音');
|
||||
|
||||
try {
|
||||
await voiceMessageManager.startRecording({
|
||||
duration: this.properties.maxDuration,
|
||||
format: 'mp3'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('🎤 开始录音失败:', error);
|
||||
|
||||
if (error.message.includes('权限')) {
|
||||
this.setData({
|
||||
showPermissionGuide: true
|
||||
});
|
||||
} else {
|
||||
this.setData({
|
||||
recordingState: 'error',
|
||||
statusText: '录音失败',
|
||||
errorMessage: this.getErrorMessage(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 停止录音
|
||||
stopRecording() {
|
||||
console.log('🎤 停止录音');
|
||||
|
||||
try {
|
||||
// 检查最小录音时长
|
||||
if (this.data.recordingDuration < this.properties.minDuration) {
|
||||
wx.showToast({
|
||||
title: `录音时长不能少于${this.properties.minDuration / 1000}秒`,
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
voiceMessageManager.stopRecording();
|
||||
|
||||
} catch (error) {
|
||||
console.error('🎤 停止录音失败:', error);
|
||||
this.setData({
|
||||
recordingState: 'error',
|
||||
statusText: '停止录音失败',
|
||||
errorMessage: this.getErrorMessage(error)
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 暂停录音
|
||||
pauseRecording() {
|
||||
console.log('🎤 暂停录音');
|
||||
|
||||
try {
|
||||
voiceMessageManager.pauseRecording();
|
||||
} catch (error) {
|
||||
console.error('🎤 暂停录音失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 恢复录音
|
||||
resumeRecording() {
|
||||
console.log('🎤 恢复录音');
|
||||
|
||||
try {
|
||||
voiceMessageManager.resumeRecording();
|
||||
} catch (error) {
|
||||
console.error('🎤 恢复录音失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 取消录音
|
||||
cancelRecording() {
|
||||
console.log('🎤 取消录音');
|
||||
|
||||
try {
|
||||
voiceMessageManager.cancelRecording();
|
||||
this.resetRecorder();
|
||||
} catch (error) {
|
||||
console.error('🎤 取消录音失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 丢弃录音
|
||||
discardRecording() {
|
||||
console.log('🎤 丢弃录音');
|
||||
this.resetRecorder();
|
||||
},
|
||||
|
||||
// 🔊 ===== 播放控制 =====
|
||||
|
||||
// 播放预览
|
||||
async playPreview() {
|
||||
if (!this.data.tempFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.data.isPlaying) {
|
||||
voiceMessageManager.stopPlaying();
|
||||
this.setData({ isPlaying: false });
|
||||
} else {
|
||||
await voiceMessageManager.playVoiceMessage(this.data.tempFilePath);
|
||||
this.setData({ isPlaying: true });
|
||||
|
||||
// 监听播放结束
|
||||
const onPlayEnd = () => {
|
||||
this.setData({ isPlaying: false });
|
||||
voiceMessageManager.off('playEnd', onPlayEnd);
|
||||
};
|
||||
voiceMessageManager.on('playEnd', onPlayEnd);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('🎤 播放预览失败:', error);
|
||||
wx.showToast({
|
||||
title: '播放失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 📤 ===== 发送录音 =====
|
||||
|
||||
// 发送录音
|
||||
async sendRecording() {
|
||||
if (!this.data.tempFilePath || !this.data.recordingDuration) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📤 发送录音');
|
||||
|
||||
try {
|
||||
wx.showLoading({
|
||||
title: '上传中...',
|
||||
mask: true
|
||||
});
|
||||
|
||||
// 上传语音文件
|
||||
const uploadResult = await voiceMessageManager.uploadVoiceFile(
|
||||
this.data.tempFilePath,
|
||||
this.data.recordingDuration
|
||||
);
|
||||
|
||||
wx.hideLoading();
|
||||
|
||||
if (uploadResult.success) {
|
||||
// 触发发送事件
|
||||
this.triggerEvent('send', {
|
||||
type: 'voice',
|
||||
url: uploadResult.url,
|
||||
duration: uploadResult.duration,
|
||||
size: uploadResult.size,
|
||||
tempFilePath: this.data.tempFilePath
|
||||
});
|
||||
|
||||
// 关闭录音界面
|
||||
this.closeRecorder();
|
||||
|
||||
wx.showToast({
|
||||
title: '发送成功',
|
||||
icon: 'success'
|
||||
});
|
||||
|
||||
} else {
|
||||
throw new Error('上传失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
wx.hideLoading();
|
||||
console.error('📤 发送录音失败:', error);
|
||||
|
||||
wx.showToast({
|
||||
title: '发送失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 🎨 ===== 界面控制 =====
|
||||
|
||||
// 关闭录音界面
|
||||
closeRecorder() {
|
||||
console.log('❌ 关闭录音界面');
|
||||
|
||||
// 如果正在录音,先停止
|
||||
if (this.data.recordingState === 'recording') {
|
||||
this.cancelRecording();
|
||||
}
|
||||
|
||||
// 如果正在播放,先停止
|
||||
if (this.data.isPlaying) {
|
||||
voiceMessageManager.stopPlaying();
|
||||
}
|
||||
|
||||
// 先自隐,再通知父级,提升关闭成功率
|
||||
this.setData({ visible: false });
|
||||
this.triggerEvent('close');
|
||||
},
|
||||
|
||||
// 遮罩点击
|
||||
onOverlayTap() {
|
||||
// 点击遮罩关闭
|
||||
this.closeRecorder();
|
||||
},
|
||||
|
||||
// 🔐 ===== 权限处理 =====
|
||||
|
||||
// 取消权限申请
|
||||
cancelPermission() {
|
||||
this.setData({
|
||||
showPermissionGuide: false
|
||||
});
|
||||
},
|
||||
|
||||
// 打开设置页面
|
||||
openSettings() {
|
||||
wx.openSetting({
|
||||
success: (res) => {
|
||||
if (res.authSetting['scope.record']) {
|
||||
this.setData({
|
||||
showPermissionGuide: false
|
||||
});
|
||||
|
||||
wx.showToast({
|
||||
title: '权限已开启',
|
||||
icon: 'success'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 🔧 ===== 工具方法 =====
|
||||
|
||||
// 重置录音器
|
||||
resetRecorder() {
|
||||
this.setData({
|
||||
recordingState: 'idle',
|
||||
statusText: '准备录音',
|
||||
recordingDuration: 0,
|
||||
tempFilePath: '',
|
||||
fileSize: 0,
|
||||
isPlaying: false,
|
||||
errorMessage: '',
|
||||
showPermissionGuide: false
|
||||
});
|
||||
|
||||
this.stopTimers();
|
||||
this.initWaveform();
|
||||
},
|
||||
|
||||
// 初始化波形
|
||||
initWaveform() {
|
||||
const waveform = Array(20).fill(0).map(() => Math.random() * 30 + 10);
|
||||
this.setData({
|
||||
realtimeWaveform: waveform,
|
||||
finalWaveform: []
|
||||
});
|
||||
},
|
||||
|
||||
// 开始时长计时器
|
||||
startDurationTimer() {
|
||||
this.stopTimers();
|
||||
|
||||
this.data.durationTimer = setInterval(() => {
|
||||
const duration = this.data.recordingDuration + 100;
|
||||
this.setData({
|
||||
recordingDuration: duration
|
||||
});
|
||||
|
||||
// 检查最大时长
|
||||
if (duration >= this.properties.maxDuration) {
|
||||
this.stopRecording();
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
|
||||
// 开始波形动画
|
||||
startWaveformAnimation() {
|
||||
this.data.waveformTimer = setInterval(() => {
|
||||
const waveform = Array(20).fill(0).map(() => Math.random() * 80 + 20);
|
||||
this.setData({
|
||||
realtimeWaveform: waveform
|
||||
});
|
||||
}, 150);
|
||||
},
|
||||
|
||||
// 停止定时器
|
||||
stopTimers() {
|
||||
if (this.data.durationTimer) {
|
||||
clearInterval(this.data.durationTimer);
|
||||
this.data.durationTimer = null;
|
||||
}
|
||||
|
||||
if (this.data.waveformTimer) {
|
||||
clearInterval(this.data.waveformTimer);
|
||||
this.data.waveformTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新实时波形
|
||||
updateRealtimeWaveform(frameBuffer) {
|
||||
if (!frameBuffer) return;
|
||||
|
||||
// 简化的波形数据处理
|
||||
const waveform = Array(20).fill(0).map(() => Math.random() * 80 + 20);
|
||||
this.setData({
|
||||
realtimeWaveform: waveform
|
||||
});
|
||||
},
|
||||
|
||||
// 生成最终波形
|
||||
generateFinalWaveform() {
|
||||
const duration = this.data.recordingDuration;
|
||||
const barCount = Math.min(Math.max(Math.floor(duration / 200), 15), 40);
|
||||
|
||||
const waveform = Array(barCount).fill(0).map(() => Math.random() * 70 + 15);
|
||||
this.setData({
|
||||
finalWaveform: waveform
|
||||
});
|
||||
},
|
||||
|
||||
// 格式化时长
|
||||
formatDuration(duration) {
|
||||
if (!duration || duration <= 0) return '00:00';
|
||||
|
||||
const totalSeconds = Math.floor(duration / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
// 获取文件大小文本
|
||||
getFileSizeText(fileSize) {
|
||||
if (!fileSize || fileSize <= 0) return '';
|
||||
|
||||
if (fileSize < 1024) {
|
||||
return `${fileSize}B`;
|
||||
} else if (fileSize < 1024 * 1024) {
|
||||
return `${(fileSize / 1024).toFixed(1)}KB`;
|
||||
} else {
|
||||
return `${(fileSize / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
},
|
||||
|
||||
// 获取质量文本
|
||||
getQualityText(duration) {
|
||||
if (!duration || duration <= 0) return '';
|
||||
|
||||
const seconds = Math.floor(duration / 1000);
|
||||
if (seconds < 3) return '音质:一般';
|
||||
if (seconds < 10) return '音质:良好';
|
||||
return '音质:优秀';
|
||||
},
|
||||
|
||||
// 获取错误消息
|
||||
getErrorMessage(error) {
|
||||
if (error.message) {
|
||||
if (error.message.includes('权限')) {
|
||||
return '需要录音权限';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
return '录音超时';
|
||||
} else if (error.message.includes('fail')) {
|
||||
return '录音失败';
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
return '未知错误';
|
||||
},
|
||||
|
||||
// 清理资源
|
||||
cleanup() {
|
||||
this.stopTimers();
|
||||
|
||||
// 如果正在录音,取消录音
|
||||
if (this.data.recordingState === 'recording') {
|
||||
voiceMessageManager.cancelRecording();
|
||||
}
|
||||
|
||||
// 如果正在播放,停止播放
|
||||
if (this.data.isPlaying) {
|
||||
voiceMessageManager.stopPlaying();
|
||||
}
|
||||
|
||||
// 移除事件监听器
|
||||
voiceMessageManager.off('recordStart');
|
||||
voiceMessageManager.off('recordStop');
|
||||
voiceMessageManager.off('recordPause');
|
||||
voiceMessageManager.off('recordResume');
|
||||
voiceMessageManager.off('recordError');
|
||||
voiceMessageManager.off('recordFrame');
|
||||
}
|
||||
}
|
||||
});
|
||||
4
components/voice-recorder/voice-recorder.json
Normal file
4
components/voice-recorder/voice-recorder.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
150
components/voice-recorder/voice-recorder.wxml
Normal file
150
components/voice-recorder/voice-recorder.wxml
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<!-- 🎤 语音录制组件 -->
|
||||
<view class="voice-recorder-container {{visible ? 'visible' : 'hidden'}}">
|
||||
<!-- 录音遮罩 -->
|
||||
<view class="recorder-overlay" bindtap="onOverlayTap" catchtouchmove="noop"></view>
|
||||
|
||||
<!-- 录音界面 -->
|
||||
<view class="recorder-content" catchtap="noop" catchtouchmove="noop">
|
||||
<!-- 录音状态显示 -->
|
||||
<view class="recorder-status">
|
||||
<view class="status-icon {{recordingState}}">
|
||||
<view wx:if="{{recordingState === 'idle'}}" class="icon-microphone">🎤</view>
|
||||
<view wx:elif="{{recordingState === 'recording'}}" class="icon-recording">
|
||||
<view class="recording-dot"></view>
|
||||
</view>
|
||||
<view wx:elif="{{recordingState === 'paused'}}" class="icon-paused">⏸️</view>
|
||||
<view wx:elif="{{recordingState === 'completed'}}" class="icon-completed">✅</view>
|
||||
<view wx:elif="{{recordingState === 'error'}}" class="icon-error">❌</view>
|
||||
</view>
|
||||
|
||||
<text class="status-text">{{statusText}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 录音时长 -->
|
||||
<view class="recording-duration">
|
||||
<text class="duration-text">{{formatDuration(recordingDuration)}}</text>
|
||||
<text class="max-duration-text">/ {{formatDuration(maxDuration)}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 实时波形显示 -->
|
||||
<view wx:if="{{recordingState === 'recording'}}" class="realtime-waveform">
|
||||
<view class="waveform-container">
|
||||
<view wx:for="{{realtimeWaveform}}"
|
||||
wx:key="index"
|
||||
class="wave-bar realtime"
|
||||
style="height: {{item}}%;">
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 录音完成预览 -->
|
||||
<view wx:if="{{recordingState === 'completed'}}" class="recording-preview">
|
||||
<view class="preview-waveform">
|
||||
<view class="waveform-container">
|
||||
<view wx:for="{{finalWaveform}}"
|
||||
wx:key="index"
|
||||
class="wave-bar preview"
|
||||
style="height: {{item}}%;">
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="preview-info">
|
||||
<text class="file-size">{{getFileSizeText(fileSize)}}</text>
|
||||
<text class="quality-text">{{getQualityText(recordingDuration)}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 录音控制按钮 -->
|
||||
<view class="recorder-controls">
|
||||
<!-- 开始/停止录音按钮 -->
|
||||
<view wx:if="{{recordingState === 'idle' || recordingState === 'error'}}"
|
||||
class="control-button primary"
|
||||
bindtouchstart="startRecording"
|
||||
bindtouchend="stopRecording"
|
||||
bindtouchcancel="cancelRecording">
|
||||
<text class="button-text">按住录音</text>
|
||||
</view>
|
||||
|
||||
<!-- 录音中的控制 -->
|
||||
<view wx:elif="{{recordingState === 'recording'}}" class="recording-controls">
|
||||
<view class="control-button secondary" bindtap="pauseRecording">
|
||||
<text class="button-text">暂停</text>
|
||||
</view>
|
||||
<view class="control-button danger" bindtap="cancelRecording">
|
||||
<text class="button-text">取消</text>
|
||||
</view>
|
||||
<view class="control-button primary" bindtap="stopRecording">
|
||||
<text class="button-text">完成</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 暂停状态的控制 -->
|
||||
<view wx:elif="{{recordingState === 'paused'}}" class="paused-controls">
|
||||
<view class="control-button secondary" bindtap="resumeRecording">
|
||||
<text class="button-text">继续</text>
|
||||
</view>
|
||||
<view class="control-button danger" bindtap="cancelRecording">
|
||||
<text class="button-text">取消</text>
|
||||
</view>
|
||||
<view class="control-button primary" bindtap="stopRecording">
|
||||
<text class="button-text">完成</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 录音完成的控制 -->
|
||||
<view wx:elif="{{recordingState === 'completed'}}" class="completed-controls">
|
||||
<view class="control-button secondary" bindtap="playPreview">
|
||||
<text class="button-text">{{isPlaying ? '暂停' : '试听'}}</text>
|
||||
</view>
|
||||
<view class="control-button danger" bindtap="discardRecording">
|
||||
<text class="button-text">重录</text>
|
||||
</view>
|
||||
<view class="control-button primary" bindtap="sendRecording">
|
||||
<text class="button-text">发送</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 录音提示 -->
|
||||
<view class="recorder-tips">
|
||||
<text wx:if="{{recordingState === 'idle'}}" class="tip-text">
|
||||
按住录音按钮开始录制,最长{{Math.floor(maxDuration/1000)}}秒
|
||||
</text>
|
||||
<text wx:elif="{{recordingState === 'recording'}}" class="tip-text">
|
||||
松开结束录音,向上滑动取消
|
||||
</text>
|
||||
<text wx:elif="{{recordingState === 'paused'}}" class="tip-text">
|
||||
录音已暂停,可以继续录制或完成录音
|
||||
</text>
|
||||
<text wx:elif="{{recordingState === 'completed'}}" class="tip-text">
|
||||
录音完成,可以试听或发送
|
||||
</text>
|
||||
<text wx:elif="{{recordingState === 'error'}}" class="tip-text error">
|
||||
{{errorMessage}}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<view class="close-button" bindtap="closeRecorder">
|
||||
<text class="close-icon">×</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 权限引导 -->
|
||||
<view wx:if="{{showPermissionGuide}}" class="permission-guide">
|
||||
<view class="guide-content">
|
||||
<view class="guide-icon">🎤</view>
|
||||
<text class="guide-title">需要录音权限</text>
|
||||
<text class="guide-desc">使用语音消息功能需要录音权限,请在设置中开启</text>
|
||||
<view class="guide-buttons">
|
||||
<view class="guide-button secondary" bindtap="cancelPermission">
|
||||
<text class="button-text">取消</text>
|
||||
</view>
|
||||
<view class="guide-button primary" bindtap="openSettings">
|
||||
<text class="button-text">去设置</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
534
components/voice-recorder/voice-recorder.wxss
Normal file
534
components/voice-recorder/voice-recorder.wxss
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
/* 🎤 语音录制组件样式 */
|
||||
|
||||
/* CSS变量定义 */
|
||||
.voice-recorder-container {
|
||||
--primary-color: #007AFF;
|
||||
--primary-light: #5AC8FA;
|
||||
--primary-dark: #0051D5;
|
||||
--success-color: #34C759;
|
||||
--warning-color: #FF9500;
|
||||
--danger-color: #FF3B30;
|
||||
--background-color: #F2F2F7;
|
||||
--surface-color: #FFFFFF;
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #8E8E93;
|
||||
--text-tertiary: #C7C7CC;
|
||||
--border-color: #E5E5EA;
|
||||
--shadow-light: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
--shadow-medium: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
|
||||
--radius-small: 8rpx;
|
||||
--radius-medium: 12rpx;
|
||||
--radius-large: 20rpx;
|
||||
--radius-xl: 32rpx;
|
||||
}
|
||||
|
||||
/* 🌙 深色模式支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.voice-recorder-container {
|
||||
--primary-color: #0A84FF;
|
||||
--background-color: #000000;
|
||||
--surface-color: #1C1C1E;
|
||||
--text-primary: #FFFFFF;
|
||||
--text-secondary: #8E8E93;
|
||||
--text-tertiary: #48484A;
|
||||
--border-color: #38383A;
|
||||
--shadow-light: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
|
||||
--shadow-medium: 0 8rpx 24rpx rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.voice-recorder-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.voice-recorder-container.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.voice-recorder-container.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 🎨 录音遮罩 */
|
||||
.recorder-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(10rpx);
|
||||
}
|
||||
|
||||
/* 🎨 录音内容 */
|
||||
.recorder-content {
|
||||
width: 640rpx;
|
||||
max-width: 90vw;
|
||||
background: var(--surface-color);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-medium);
|
||||
padding: 60rpx 40rpx 40rpx;
|
||||
position: relative;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100rpx) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 🎤 录音状态 */
|
||||
.recorder-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24rpx;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-icon.idle {
|
||||
background: var(--background-color);
|
||||
border: 2rpx solid var(--border-color);
|
||||
}
|
||||
|
||||
.status-icon.recording {
|
||||
background: linear-gradient(135deg, var(--danger-color) 0%, #FF6B6B 100%);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-icon.paused {
|
||||
background: var(--warning-color);
|
||||
}
|
||||
|
||||
.status-icon.completed {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.status-icon.error {
|
||||
background: var(--danger-color);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.icon-microphone {
|
||||
font-size: 48rpx;
|
||||
}
|
||||
|
||||
.icon-recording {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.recording-dot {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
border-radius: 12rpx;
|
||||
background: white;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.icon-paused,
|
||||
.icon-completed,
|
||||
.icon-error {
|
||||
font-size: 48rpx;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ⏱️ 录音时长 */
|
||||
.recording-duration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 40rpx;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.duration-text {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.max-duration-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* 🌊 实时波形 */
|
||||
.realtime-waveform {
|
||||
height: 120rpx;
|
||||
margin-bottom: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.waveform-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 8rpx;
|
||||
border-radius: 4rpx;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 16rpx;
|
||||
}
|
||||
|
||||
.wave-bar.realtime {
|
||||
background: var(--primary-color);
|
||||
animation: waveAnimation 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wave-bar.preview {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@keyframes waveAnimation {
|
||||
0%, 100% { transform: scaleY(0.5); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
/* 📊 录音预览 */
|
||||
.recording-preview {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.preview-waveform {
|
||||
height: 80rpx;
|
||||
margin-bottom: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
|
||||
.file-size,
|
||||
.quality-text {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 🎛️ 录音控制 */
|
||||
.recorder-controls {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
height: 88rpx;
|
||||
border-radius: var(--radius-medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16rpx;
|
||||
transition: all 0.3s ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.control-button:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.control-button.primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-light);
|
||||
}
|
||||
|
||||
.control-button.secondary {
|
||||
background: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
border: 1rpx solid var(--border-color);
|
||||
}
|
||||
|
||||
.control-button.danger {
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
color: var(--danger-color);
|
||||
border: 1rpx solid rgba(255, 59, 48, 0.3);
|
||||
}
|
||||
|
||||
.control-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.control-button.primary:active {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-color) 100%);
|
||||
}
|
||||
|
||||
.button-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 录音中的控制按钮 */
|
||||
.recording-controls,
|
||||
.paused-controls,
|
||||
.completed-controls {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.recording-controls .control-button,
|
||||
.paused-controls .control-button,
|
||||
.completed-controls .control-button {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 💡 录音提示 */
|
||||
.recorder-tips {
|
||||
text-align: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tip-text.error {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* ❌ 关闭按钮 */
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 30rpx;
|
||||
background: var(--background-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.close-button:active {
|
||||
background: var(--border-color);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
font-size: 36rpx;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* 🔐 权限引导 */
|
||||
.permission-guide {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.guide-content {
|
||||
background: var(--surface-color);
|
||||
border-radius: var(--radius-large);
|
||||
padding: 60rpx 40rpx 40rpx;
|
||||
margin: 40rpx;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-medium);
|
||||
}
|
||||
|
||||
.guide-icon {
|
||||
font-size: 80rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.guide-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.guide-desc {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 40rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.guide-buttons {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.guide-button {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: var(--radius-medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.guide-button.primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.guide-button.secondary {
|
||||
background: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
border: 1rpx solid var(--border-color);
|
||||
}
|
||||
|
||||
.guide-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 📱 响应式设计 */
|
||||
@media screen and (max-width: 375px) {
|
||||
.recorder-content {
|
||||
width: 560rpx;
|
||||
padding: 50rpx 30rpx 30rpx;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
}
|
||||
|
||||
.duration-text {
|
||||
font-size: 40rpx;
|
||||
}
|
||||
|
||||
.realtime-waveform {
|
||||
height: 100rpx;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
height: 76rpx;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 414px) {
|
||||
.recorder-content {
|
||||
width: 720rpx;
|
||||
padding: 70rpx 50rpx 50rpx;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
}
|
||||
|
||||
.duration-text {
|
||||
font-size: 56rpx;
|
||||
}
|
||||
|
||||
.realtime-waveform {
|
||||
height: 140rpx;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
height: 96rpx;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* 🎭 动画增强 */
|
||||
.voice-recorder-container.visible .recorder-content {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.voice-recorder-container.hidden .recorder-content {
|
||||
animation: slideDown 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(100rpx) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
/* 触摸反馈 */
|
||||
.control-button {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* 可访问性 */
|
||||
.control-button[aria-pressed="true"] {
|
||||
outline: 2rpx solid var(--primary-color);
|
||||
outline-offset: 4rpx;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue