congming_huose-apk/common/components/ControlTab.vue

968 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="control-tab">
<!-- 顶部状态卡片 -->
<view class="status-card">
<view class="" v-if="kjobj.status == 1"> <image style="width: 60rpx;height: 60rpx;" src="https://api.ccttiot.com/smartmeter/img/static/uzZLS6VfQDFA1jTNaZVq" mode="aspectFill"></image> </view>
<view class="" v-if="kjobj.status == 2"> <image style="width: 56rpx;height: 62rpx;" src="https://api.ccttiot.com/smartmeter/img/static/uuGVupItvaHcUcDfB8Dw" mode="aspectFill"></image> </view>
<view class="" v-if="kjobj.status == 3"> <image style="width: 60rpx;height: 60rpx;" src="https://api.ccttiot.com/smartmeter/img/static/u69cK2hDbhRQ9BQAEysP" mode="aspectFill"></image> </view>
<view class="" v-if="kjobj.status == 4"> <image style="width: 60rpx;height: 60rpx;" src="https://api.ccttiot.com/smartmeter/img/static/urRtZWuAS07btljjIrty" mode="aspectFill"></image> </view>
<text class="status-title">{{ titlename }}</text>
<!-- <text class="status-subtitle">{{ $i18n.t('alarmCleared') }}</text> -->
</view>
<!-- 控制栅格卡片 -->
<view class="grid-card">
<view class="grid-item" @click="onOpenAlarm">
<text class="grid-label">{{ $i18n.t('armAlarm') }}</text>
<image style="width: 102rpx;height: 102rpx;" src="https://api.ccttiot.com/smartmeter/img/static/uyM0CBeinHHNAs5lp3NF" mode="aspectFill"></image>
</view>
<view class="divider-vertical"></view>
<view class="grid-item" @click="onCloseAlarm">
<text class="grid-label">{{ $i18n.t('disarmAlarm') }}</text>
<image style="width: 94rpx;height: 102rpx;" src="https://api.ccttiot.com/smartmeter/img/static/uHzvT9I1TMOtJs93dpBP" mode="aspectFill"></image>
</view>
<view class="divider-horizontal"></view>
<view class="grid-item" @click="onNightMode">
<view class="grid-label">{{ $i18n.t('nightModeLabel') }}</view>
<image style="width: 102rpx;height: 102rpx;" src="https://api.ccttiot.com/smartmeter/img/static/uYsM9Nn95AK9c3JkUqvb" mode="aspectFill"></image>
</view>
<view class="divider-vertical"></view>
<view class="grid-item" @click="onEmergency">
<view class="grid-label">{{ $i18n.t('emergencyLabel') }}</view>
<image style="width: 102rpx;height: 102rpx;" src="https://api.ccttiot.com/smartmeter/img/static/uSkABcRy9Qk8KRzcFqj5" mode="aspectFill"></image>
</view>
</view>
<view class="" style="width: 100rpx;height: 100rpx;margin: auto;margin-top: 50rpx;" @click="btnkongjian">
<image style="width: 80rpx;height: 80rpx;" src="https://api.ccttiot.com/smartmeter/img/static/c650IT3ZxgcljMf8ZSGI" mode="aspectFill"></image>
</view>
<!-- 倒计时弹窗 -->
<view v-if="showCountdownModal" class="modal-overlay">
<view class="countdown-modal">
<view class="progress-circle">
<view class="progress-ring countdown-ring" :style="{ transform: `rotate(${(3-countdown)*120}deg)` }"></view>
<view class="countdown-number">{{ countdown }}</view>
</view>
<text class="modal-title">{{$i18n.t('fssos')}}...</text>
<button class="cancel-btn" @click="cancelCountdown">{{$i18n.t('cancel')}}</button>
</view>
</view>
<!-- 定位获取弹窗 -->
<view v-if="showLocationModal" class="modal-overlay">
<view class="location-modal">
<view class="progress-circle">
<view class="progress-ring location-ring" :style="{ transform: `rotate(${locationProgress*360}deg)` }"></view>
<view class="location-icon">
<view class="location-pin"></view>
<view class="location-waves">
<view class="wave wave1"></view>
<view class="wave wave2"></view>
<view class="wave wave3"></view>
</view>
</view>
</view>
<text class="modal-title">{{$i18n.t('fsdw')}}...</text>
<button class="cancel-btn" @click="cancelLocation">{{$i18n.t('cancel')}}</button>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'ControlTab',
props: {
statusTitle: {
type: String,
default: ''
}
},
data() {
return {
kjobj:{},
titlename:'',
// 倒计时弹窗相关
showCountdownModal: false,
countdown: 3,
countdownTimer: null,
// 定位弹窗相关
showLocationModal: false,
locationTimer: null,
locationProgress: 0,
minHoldTimer: null,
sockedata: {},
socketTask: null,
messages: [],
reconnectAttempts: 0,
maxReconnectAttempts: 5,
reconnectInterval: 3000,
isPageActive: true,
deviceMac1: 'E9204D6232DC',
}
},
computed: {
// 计算属性可以在这里添加
},
created() {
// this.deviceMac1 = uni.getStorageSync('mac1')
this.getxq()
this.initWebSocket()
},
mounted() {
// 监听空间切换事件
uni.$on('spaceChanged', this.handleSpaceChanged)
},
beforeDestroy() {
// 移除事件监听
uni.$off('spaceChanged', this.handleSpaceChanged)
// 清理定时器
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
}
if (this.locationTimer) {
clearInterval(this.locationTimer)
}
if (this.minHoldTimer) {
clearTimeout(this.minHoldTimer)
this.minHoldTimer = null
}
},
onLoad(option) {
},
methods: {
// 请求空间详情
getxq(){
let spaceId = uni.getStorageSync('kjid')
this.$http.get(`/bst/space/${spaceId}`).then(res => {
if(res.code == 200){
this.kjobj = res.data
if(res.data.status == 1){
this.titlename = this.$i18n.t('statusArmed')
}else if(res.data.status == 2){
this.titlename = this.$i18n.t('statusDisarmed')
}else if(res.data.status == 3){
this.titlename = this.$i18n.t('statusNight')
}
}
})
},
//WebSocket 自动重连实现
initWebSocket() {
let token = uni.getStorageSync('token')
// 关闭已有连接
if (this.socketTask) {
this.socketTask.close()
this.socketTask = null
}
// 创建新连接
this.socketTask = uni.connectSocket({
url: `wss://gw.ccttiot.com/prod-api/ws/ws/oneNet?mac=${this.deviceMac1}`,
success: () => {
console.log('WebSocket连接建立中...')
},
fail: (err) => {
console.error('WebSocket连接失败:', err)
this.scheduleReconnect()
}
});
// 监听连接打开
this.socketTask.onOpen(() => {
console.log('WebSocket连接已打开')
this.reconnectAttempts = 0; // 重置重连计数器
// 发送订阅消息(如果需要)
this.socketTask.send({
data: JSON.stringify({
action: 'subscribe'
}),
success: () => console.log('订阅消息发送成功'),
fail: (err) => console.error('订阅消息发送失败:', err)
})
})
// 监听消息接收
this.socketTask.onMessage((res) => {
console.log('收到实时消息:', res.data)
this.messages.push(res.data) // 存储消息
let num = ''
try {
const data = JSON.parse(res.data)
console.log('解析后的JSON数据:', data)
// 查找num值
let num = null
// 第一步先判断外层是否有data字段且data是字符串格式的JSON
if (data.data && typeof data.data === 'string') {
try {
// 解析内层的data字符串为JSON对象
const innerData = JSON.parse(data.data)
// 遍历内层对象找num相关键
const innerKeys = Object.keys(innerData)
for (const key of innerKeys) {
if (key.toLowerCase().includes('num')) {
num = innerData[key]
console.log(`从内层${key}获取到num值:`, num)
break
}
}
} catch (e) {
console.error('解析内层data字符串失败:', e)
}
}
// 处理业务逻辑
if (num !== null) {
const numValue = Number(num)
if (numValue === 1) {
this.getstatus(1)
uni.showModal({
title: '提示',
content: '布防信息',
showCancel: false,
success: function(res) {
if (res.confirm) {
} else if (res.cancel) {
}
}
})
uni.showToast({ title: '收到新无操作消息', icon: 'none', duration: 3000})
} else if (numValue === 2) {
this.getstatus(2)
uni.showModal({
title: '提示',
content: '撤防信息',
showCancel: false,
success: function(res) {
if (res.confirm) {
} else if (res.cancel) {
}
}
})
} else if (numValue === 3) {
this.getstatus(3)
uni.showModal({
title: '提示',
content: '夜间信息',
showCancel: false,
success: function(res) {
if (res.confirm) {
} else if (res.cancel) {
}
}
})
} else if(numValue === 4) {
uni.showModal({
title: '提示',
content: '报警信息',
showCancel: false,
success: function(res) {
if (res.confirm) {
} else if (res.cancel) {
}
}
})
this.startCountdown()
} else {
console.log('num值不在1-4范围内:', numValue)
uni.showModal({
title: '提示',
content: data.data,
showCancel: false,
success: function(res) {
if (res.confirm) {
} else if (res.cancel) {
}
}
})
}
} else {
uni.showModal({
title: '提示',
content: data.data,
showCancel: false,
success: function(res) {
if (res.confirm) {
} else if (res.cancel) {
}
}
})
console.log('没有找到num值')
console.log('外层所有可用键:', Object.keys(data))
// 补充打印内层data内容方便调试
if (data.data) {
console.log('内层data内容:', data.data)
}
}
// if(this.titlename == this.$i18n.t('statusDisarmed')){
// this.titlename = this.$i18n.t('statusArmed')
// this.getstatus(1)
// }else{
// this.titlename = this.$i18n.t('statusDisarmed')
// this.getstatus(2)
// }
} catch (e) {
console.log('原始消息内容:', res.data)
}
});
// 监听错误
this.socketTask.onError((err) => {
console.error('WebSocket错误:', err)
this.scheduleReconnect()
});
// 监听连接关闭
this.socketTask.onClose((res) => {
console.log('WebSocket连接已关闭', res)
if (this.isPageActive) {
this.scheduleReconnect()
}
})
},
// 关闭WebSocket连接
closeWebSocket() {
if (this.socketTask) {
this.socketTask.close({
success: () => {
console.log('WebSocket已主动关闭')
this.socketTask = null
},
fail: (err) => {
console.error('WebSocket关闭失败:', err)
this.socketTask = null
}
})
}
},
// 安排重连
scheduleReconnect() {
if (!this.isPageActive || this.reconnectAttempts >= this.maxReconnectAttempts) {
console.log('已达到最大重连次数或页面已关闭,停止重连')
return
}
this.reconnectAttempts++;
console.log(`尝试第 ${this.reconnectAttempts} 次重连,等待 ${this.reconnectInterval/1000} 秒...`)
setTimeout(() => {
this.initWebSocket()
}, this.reconnectInterval)
// 指数退避策略,增加重连间隔
this.reconnectInterval = Math.min(this.reconnectInterval * 2, 30000) // 最大不超过30秒
},
// 点击跳转到空间详情
btnkongjian(){
uni.navigateTo({
url:'/pages/kongjian/kongjianxq'
})
},
// 点击布防
onOpenAlarm() {
uni.showModal({
title: this.$i18n.t('confirmOperation'),
content: this.$i18n.t('confirmArmAlarm'),
confirmText: this.$i18n.t('confirm'),
cancelText: this.$i18n.t('cancel'),
success: (res) => {
if (res.confirm) {
this.getstatus(1)
}
}
})
},
// 点击撤防
onCloseAlarm() {
uni.showModal({
title: this.$i18n.t('confirmOperation'),
content: this.$i18n.t('confirmDisarmAlarm'),
confirmText: this.$i18n.t('confirm'),
cancelText: this.$i18n.t('cancel'),
success: (res) => {
if (res.confirm) {
this.getstatus(2)
}
}
})
},
// 点击开启夜间模式
onNightMode() {
uni.showModal({
title: this.$i18n.t('confirmOperation'),
content: this.$i18n.t('confirmNightMode'),
confirmText: this.$i18n.t('confirm'),
cancelText: this.$i18n.t('cancel'),
success: (res) => {
if (res.confirm) {
this.getstatus(3)
}
}
})
},
// 操作状态
getstatus(status){
let spaceId = uni.getStorageSync('kjid')
let data = {
spaceId:spaceId,
status:status
}
this.$http.put(`/bst/space/changeStatus`,data).then(res => {
if(res.code == 200){
uni.showToast({ title: res.msg, icon: 'success',duration:3000})
this.getxq()
// 发射事件通知父组件状态已改变
this.$emit('status-changed', { status, spaceId })
}else{
uni.showToast({ title: res.msg, icon: 'none',duration:3000})
}
})
},
// 处理空间变化
handleSpaceChanged(payload){
try{
console.log('控制模块收到空间变化事件:', payload)
// 重新获取空间详情
this.getxq()
}catch(e){
console.warn('控制模块处理空间切换失败:', e)
}
},
// 点击报警
onEmergency() {
uni.showModal({
title: this.$i18n.t('confirmOperation'),
content: this.$i18n.t('confirmEmergency'),
confirmText: this.$i18n.t('confirm'),
cancelText: this.$i18n.t('cancel'),
success: (res) => {
if (res.confirm) {
this.startCountdown()
}
}
})
},
// 开始倒计时
startCountdown() {
this.showCountdownModal = true
this.countdown = 3
this.countdownTimer = setInterval(() => {
this.countdown--
if (this.countdown <= 0) {
this.clearCountdown()
this.checkLocationPermission()
}
}, 1000)
},
// 取消倒计时
cancelCountdown() {
this.clearCountdown()
},
// 清除倒计时
clearCountdown() {
this.showCountdownModal = false
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
this.countdownTimer = null
}
},
// 检查定位权限
checkLocationPermission() {
// 直接尝试获取定位,如果失败则发送无定位的请求
this.startLocation()
},
// 开始获取定位
startLocation() {
this.showLocationModal = true
this.locationProgress = 0
// 进度条动画(纯展示,不代表业务完成)
this.locationTimer = setInterval(() => {
this.locationProgress += 0.005
if (this.locationProgress >= 1) {
this.locationProgress = 0
}
}, 16) // 约60fps
// 至少展示3秒动画然后再继续后续流程
let holdDone = false
let locationSucceeded = false
let locationData = null
const proceedIfReady = () => {
if (!holdDone) return
this.clearLocation()
if (locationSucceeded && locationData) {
this.sendSOSRequest(locationData)
} else {
this.sendSOSDirectly()
}
}
this.minHoldTimer = setTimeout(() => {
holdDone = true
proceedIfReady()
}, 3000)
// 同时尝试获取位置结果先存起来等3秒到再决定是否带位置信息
if (typeof uni.getLocation === 'function') {
try {
uni.getLocation({
type: 'wgs84',
timeout: 5000,
success: (res) => {
console.log('获取位置成功:', res)
let spaceId = uni.getStorageSync('kjid')
locationData = {
spaceId: spaceId,
lon: res.longitude.toString(),
lat: res.latitude.toString()
}
locationSucceeded = true
proceedIfReady()
},
fail: (err) => {
console.log('获取位置失败:', err)
locationSucceeded = false
locationData = null
proceedIfReady()
}
})
} catch (error) {
console.log('调用定位接口异常:', error)
locationSucceeded = false
locationData = null
proceedIfReady()
}
} else {
console.log('当前环境不支持定位功能')
locationSucceeded = false
locationData = null
proceedIfReady()
}
},
// 取消定位
cancelLocation() {
this.clearLocation()
},
// 清除定位相关
clearLocation() {
this.showLocationModal = false
if (this.locationTimer) {
clearInterval(this.locationTimer)
this.locationTimer = null
}
this.locationProgress = 0
},
// 直接发送SOS请求无定位
sendSOSDirectly() {
let spaceId = uni.getStorageSync('kjid')
let data = {
spaceId: spaceId,
lon: '',
lat: ''
}
this.sendSOSRequest(data)
},
getsos(){
let spaceId = uni.getStorageSync('kjid')
// 获取当前位置
uni.getLocation({
type: 'wgs84', // 返回可以用于uni.openLocation的经纬度
timeout: 10000, // 10秒超时
success: (res) => {
uni.hideLoading()
console.log('获取位置成功:', res)
let data = {
spaceId: spaceId,
lon: res.longitude.toString(),
lat: res.latitude.toString()
}
this.sendSOSRequest(data)
},
fail: (err) => {
uni.hideLoading()
console.log('获取位置失败:', err)
// 如果获取位置失败,仍然发送请求但使用空坐标
let data = {
spaceId: spaceId,
lon: '',
lat: ''
}
this.sendSOSRequest(data)
}
})
},
// 发送SOS请求的方法
sendSOSRequest(data) {
this.$http.put(`/bst/space/panic`, data).then(res => {
if(res.code == 200){
uni.showToast({ title: res.msg, icon: 'success', duration: 3000})
}else{
uni.showToast({ title: res.msg, icon: 'none', duration: 3000})
}
}).catch(err => {
console.log('发送SOS请求失败:', err)
uni.showToast({ title: '发送报警失败,请重试', icon: 'none', duration: 3000})
})
},
},
}
</script>
<style lang="scss" scoped>
.control-tab{
padding: 24rpx 24rpx 40rpx;
box-sizing: border-box;
background: #f5f6f7;
min-height: 100%;
padding-top: 50rpx;
}
.status-card{
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 28rpx;
}
.status-icon{
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 6rpx solid #3aa273;
border-right-color: transparent;
transform: rotate(30deg);
margin-bottom: 12rpx;
}
.status-title{
font-size: 32rpx;
color: #222;
font-weight: 600;
margin-bottom: 6rpx;
margin-top: 20rpx;
}
.status-subtitle{
font-size: 26rpx;
color: #35a06f;
}
.grid-card{
position: relative;
background: #fff;
margin-top: 100rpx;
border-radius: 24rpx;
padding: 28rpx 0;
display: grid;
grid-template-columns: 1fr 2rpx 1fr;
grid-template-rows: auto 2rpx auto;
width: 648rpx;
margin: auto;
}
.grid-item{
width: 324rpx;
height: 360rpx;
display: flex;
padding: 40rpx 60rpx;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
}
.grid-label{
font-size: 26rpx;
color: #7a7f85;
margin-bottom: 24rpx;
}
.grid-icon{
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
background: #000;
}
.icon-ring{
background: none;
border: 12rpx solid #000;
border-radius: 60rpx;
}
.icon-ring-small{
background: none;
border: 12rpx solid #000;
border-radius: 60rpx;
clip-path: inset(0 18rpx 0 0);
}
.icon-rotate{
background: none;
position: relative;
border: 12rpx solid #000;
border-radius: 60rpx;
}
.icon-rotate:after{
content: '';
position: absolute;
right: -8rpx;
bottom: -8rpx;
width: 28rpx;
height: 28rpx;
background: #000;
border-radius: 50%;
}
.icon-bang{
display: flex;
align-items: center;
justify-content: center;
background: #000;
color: #fff;
font-weight: 700;
}
.icon-bang:after{
content: '!';
color: #fff;
font-size: 64rpx;
line-height: 1;
}
.divider-vertical{
width: 2rpx;
background: #eee;
}
.divider-horizontal{
height: 2rpx;
background: #eee;
grid-column: 1 / 4;
}
/* 弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
justify-content: flex-end;
z-index: 9999;
}
.countdown-modal, .location-modal {
background: #f5f6f7;
border-radius: 32rpx 32rpx 0 0;
padding: 80rpx 60rpx 80rpx;
width: 100%;
bottom: 120rpx;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
min-height: 60vh;
box-sizing: border-box;
animation: modal-slide-up 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes modal-slide-up {
0% {
transform: translateY(100%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
.progress-circle {
position: relative;
width: 240rpx;
height: 240rpx;
margin-bottom: 60rpx;
}
.progress-ring {
position: absolute;
top: 0;
left: 0;
width: 240rpx;
height: 240rpx;
border-radius: 50%;
border: 12rpx solid #e8e9ea;
transition: transform 0.3s ease;
}
.countdown-ring {
border-top: 12rpx solid #ec5a5a;
border-right: 12rpx solid #ec5a5a;
border-bottom: 12rpx solid transparent;
border-left: 12rpx solid transparent;
transform-origin: center;
animation: location-spin 1.5s linear infinite;
transition: transform 0.1s ease-out;
}
.location-ring {
border-top: 12rpx solid #ec5a5a;
animation: location-spin 1.5s linear infinite;
transition: transform 0.1s ease-out;
}
@keyframes location-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.countdown-number {
position: absolute;
top: 50%;
left: 54%;
transform: translate(-50%, -50%);
font-size: 120rpx;
font-weight: bold;
color: #ec5a5a;
line-height: 1;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: number-pulse 1s ease-in-out infinite;
}
@keyframes number-pulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-50%, -50%) scale(1.05);
}
}
.location-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80rpx;
height: 80rpx;
}
.location-pin {
width: 20rpx;
height: 20rpx;
background: #ec5a5a;
border-radius: 50%;
position: absolute;
top: 50%;
left: 60%;
transform: translate(-50%, -50%);
z-index: 3;
box-shadow: 0 0 0 6rpx rgba(255, 107, 53, 0.3);
}
.location-pin::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 32rpx;
height: 32rpx;
background: #ec5a5a;
border-radius: 50% 50% 50% 0;
transform: translate(-50%, -50%) rotate(-45deg);
}
.location-waves {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80rpx;
height: 80rpx;
}
.wave {
position: absolute;
top: 50%;
left: 60%;
transform: translate(-50%, -50%);
border: 2rpx solid #ec5a5a;
border-radius: 50%;
opacity: 0.6;
animation: wave-animation 2s ease-out infinite;
}
.wave1 {
width: 40rpx;
height: 40rpx;
animation-delay: 0s;
}
.wave2 {
width: 60rpx;
height: 60rpx;
animation-delay: 0.67s;
}
.wave3 {
width: 80rpx;
height: 80rpx;
animation-delay: 1.33s;
}
@keyframes wave-animation {
0% {
transform: translate(-50%, -50%) scale(0.3);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.6;
}
100% {
transform: translate(-50%, -50%) scale(1.8);
opacity: 0;
}
}
.modal-title {
font-size: 36rpx;
color: #333;
margin-bottom: 80rpx;
text-align: center;
font-weight: 500;
}
.cancel-btn {
width: 100%;
max-width: 500rpx;
height: 100rpx;
background: #ec5a5a;
color: #fff;
border: none;
border-radius: 50rpx;
font-size: 36rpx;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-top: auto;
position: relative;
overflow: hidden;
}
.cancel-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.cancel-btn:hover::before {
left: 100%;
}
.cancel-btn:active {
background: #e55a2b;
transform: translateY(2rpx) scale(0.98);
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.3);
}
</style>