文件
fenleixinxi/uniapp/pages/message/index.vue
MiAn 02fc28613a feat: 深度强化微信小程序端虚拟支付提审防审模式
1. App.vue 启动时立即预加载 uiConfig,杜绝首屏网络延时导致瞬间闪现付费会员入口
2. 会员中心 (member/index.vue):在提审防审模式下直接展示纯净【便民服务指南】卡片,杜绝任何 VIP 皇冠、会员标价、折扣与充值入口
3. 个人中心 (mine/index.vue):提审防审模式下彻底隐藏会员卡片与我的余额,紧凑上移卡片布局
4. 订单明细、充值页与消息中心增加防审合规兼容,避免误触发任何虚拟支付诱导
2026-11-02 17:42:49 +08:00

741 行
18 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<view class="msg fc-page" @touchstart="onGlobalTouch">
<!-- 规范化顶部导航:与首页、分类、我的统一沉浸式渐变顶栏 -->
<fc-navbar title="通知中心" theme="gradient" />
<!-- 分类 Tab -->
<view class="msg__tabs">
<view
v-for="t in tabs"
:key="t.value"
class="msg__tab"
:class="{ 'msg__tab--active': type === t.value }"
@click="changeType(t.value)"
>
<text class="msg__tab-label">{{ t.label }}</text>
<view v-if="type === t.value" class="msg__tab-line"></view>
</view>
</view>
<!-- 操作工具条:未读统计 + 一键已读 + 一键清理 -->
<view class="msg__toolbar">
<view class="msg__stats">
<text class="msg__stats-text">共 {{ list.length }} 条</text>
<text v-if="unreadCount > 0" class="msg__stats-sep">·</text>
<text v-if="unreadCount > 0" class="msg__stats-unread">{{ unreadCount }} 条未读</text>
</view>
<view class="msg__actions">
<view
class="msg__btn"
:class="{ 'msg__btn--disabled': unreadCount === 0 }"
@click="handleReadAll"
>
<text class="msg__btn-text">一键已读</text>
</view>
<view
class="msg__btn msg__btn--clear"
:class="{ 'msg__btn--disabled': !list.length }"
@click="handleClearAll"
>
<text class="msg__btn-text">一键清理</text>
</view>
</view>
</view>
<!-- 空数据提示 -->
<!-- 未登录空状态引导 -->
<view v-if="!isLogin" class="msg__unlogin-box">
<fc-empty icon="inbox" text="登录后查看最新互动与系统通知" />
<button class="msg__unlogin-btn" @click="goLogin">
<text class="msg__unlogin-btn-txt">立即登录</text>
</button>
</view>
<!-- 已登录空数据提示 -->
<fc-empty v-else-if="!loading && !list.length" icon="inbox" text="暂无通知" />
<!-- 通知卡片列表(支持左滑显示删除按钮) -->
<view v-else class="msg__list">
<view
v-for="(m, index) in list"
:key="m.id"
class="msg__swipe-box"
>
<!-- 底层动作区:删除按钮 -->
<view class="msg__swipe-actions">
<view class="msg__delete-btn" @click.stop="handleDelete(m, index)">
<text class="msg__delete-text">删除</text>
</view>
</view>
<!-- 表层滑动内容卡片 -->
<view
class="msg__swipe-content"
:class="{
'msg__swipe-content--unread': !m.read,
'msg__swipe-content--no-transition': m.isSwiping
}"
:style="{ transform: 'translateX(' + (m.offset || 0) + 'px)' }"
@touchstart="onTouchStart($event, m)"
@touchmove="onTouchMove($event, m)"
@touchend="onTouchEnd($event, m)"
@touchcancel="onTouchEnd($event, m)"
@click="handleCardClick(m)"
>
<view class="msg__icon">
<image class="fc-icon--badge-sm" :src="iconUrl(meta(m.type).iconKey)" mode="aspectFit" />
</view>
<view class="msg__body">
<view class="msg__head">
<text class="msg__title fc-ellipsis">{{ m.title }}</text>
<text class="msg__time">{{ timeText(m.createdAt) }}</text>
</view>
<text class="msg__content fc-ellipsis-2">{{ m.content }}</text>
</view>
<view v-if="!m.read" class="msg__dot"></view>
</view>
</view>
</view>
<!-- 底部 Tabbar 占位 -->
<view class="msg__tabbar-space"></view>
<fc-tabbar :current="3" />
</view>
</template>
<script>
import FcNavbar from '@/components/fc-navbar/fc-navbar.vue'
import FcEmpty from '@/components/fc-empty/fc-empty.vue'
import FcTabbar from '@/components/fc-tabbar/fc-tabbar.vue'
import messageApi from '@/common/api/message.js'
import { MESSAGE_TYPE } from '@/common/constants/ui.js'
import { fromNow } from '@/common/utils/format.js'
import { iconUrl } from '@/common/store/ui.js'
export default {
components: { FcNavbar, FcEmpty, FcTabbar },
data() {
return {
type: 'system',
list: [],
loading: false,
tabs: [
{ label: '系统通知', value: 'system' },
{ label: '互动提醒', value: 'interact' },
],
// 手势滑动追踪状态
touchState: {
startX: 0,
startY: 0,
startTime: 0,
direction: '',
initOffset: 0,
activeId: null,
},
}
},
computed: {
/** 是否已登录 */
isLogin() {
const token = uni.getStorageSync('token') || uni.getStorageSync('fc_token') || ''
return !!token
},
/**
* 计算当前列表中的未读通知总数
* @returns {number} 未读数量
*/
unreadCount() {
return this.list.filter(m => !m.read).length
},
/**
* 当前选中的分类名称
* @returns {string} 分类标签名
*/
currentTabLabel() {
const found = this.tabs.find(t => t.value === this.type)
return found ? found.label : '通知'
},
},
onLoad() {
this.fetch()
},
onShow() {
this.fetch()
},
onPullDownRefresh() {
this.fetch().finally(() => {
uni.stopPullDownRefresh()
})
},
methods: {
/** 前往登录页 */
goLogin() {
uni.navigateTo({ url: '/pages/auth/login' })
},
/** 暴露给模板:获取图标完整地址 */
iconUrl,
/**
* 获取指定通知类型的元信息
* @param {string} t 通知类型 system|interact
* @returns {Object} 包含 iconKey 与标题配置的对象
*/
meta(t) {
return MESSAGE_TYPE[t] || MESSAGE_TYPE.system
},
/**
* 格式化相对时间文本
* @param {string|number} t 时间戳或日期字符串
* @returns {string} 相对时间文本,如“刚刚”、“5分钟前”
*/
timeText(t) {
return fromNow(t)
},
/**
* 获取左滑操作区最大展开宽度(px)
* @returns {number} 展开距离,设计稿 140rpx 转真实 px
*/
getMaxSwipePx() {
try {
return uni.upx2px(140)
} catch (e) {
return 70
}
},
/**
* 全局触摸事件:当点击其它位置时关闭所有已滑开的卡片
*/
onGlobalTouch() {
// 若有滑开的卡片且当前非手势拖拽中,不做强制重置,由具体区域判定收起
},
/**
* 拉取当前类型的通知列表
*/
async fetch() {
const token = uni.getStorageSync('token') || uni.getStorageSync('fc_token') || ''
if (!token) {
this.loading = false
this.list = []
return
}
this.loading = true
try {
const res = await messageApi.list({ type: this.type, page: 1, size: 50 })
const rows = (res && res.list) || []
this.list = rows.map(item => ({
...item,
offset: 0,
isSwiping: false,
}))
} catch (e) {
// 静默置空,杜绝未登录或接口偶发波动时弹出生硬的 Toast 提示
this.list = []
} finally {
this.loading = false
}
},
/**
* 切换通知频道
* @param {string} v 频道类型值 system|interact
*/
changeType(v) {
if (this.type === v) return
this.closeAllSwipe()
this.type = v
this.fetch()
},
/**
* 收起所有已滑开的卡片
* @param {number|null} excludeId 排除收起的消息ID(可选)
*/
closeAllSwipe(excludeId = null) {
this.list.forEach(item => {
if (item.id !== excludeId && item.offset !== 0) {
this.$set(item, 'offset', 0)
this.$set(item, 'isSwiping', false)
}
})
},
/**
* 一键标记全部已读
*/
async handleReadAll() {
if (this.unreadCount === 0) {
uni.showToast({ title: '暂无未读通知', icon: 'none' })
return
}
this.closeAllSwipe()
uni.showLoading({ title: '处理中...', mask: true })
try {
await messageApi.readAll({ type: this.type })
this.list.forEach(m => {
m.read = true
})
uni.$emit('refreshUnreadCount')
uni.showToast({ title: '已全部标为已读', icon: 'success' })
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
uni.hideLoading()
}
},
/**
* 一键清理当前分类通知
*/
handleClearAll() {
if (!this.list.length) {
uni.showToast({ title: '暂无可清理通知', icon: 'none' })
return
}
this.closeAllSwipe()
uni.showModal({
title: '一键清理',
content: `确定清空当前${this.currentTabLabel}的全部通知记录吗?`,
confirmText: '清空',
confirmColor: '#FF4D4F',
cancelText: '取消',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '清理中...', mask: true })
try {
await messageApi.clear({ type: this.type })
this.list = []
uni.$emit('refreshUnreadCount')
uni.showToast({ title: '已清空通知', icon: 'success' })
} catch (e) {
uni.showToast({ title: '清理失败', icon: 'none' })
} finally {
uni.hideLoading()
}
},
})
},
/**
* 卡片触摸开始手势
* @param {TouchEvent} e 触摸事件对象
* @param {Object} m 当前触摸的通知对象
*/
onTouchStart(e, m) {
if (!e.touches || !e.touches[0]) return
const touch = e.touches[0]
this.touchState = {
startX: touch.clientX,
startY: touch.clientY,
startTime: Date.now(),
direction: '',
initOffset: m.offset || 0,
activeId: m.id,
}
},
/**
* 卡片触摸滑动中手势
* @param {TouchEvent} e 触摸事件对象
* @param {Object} m 当前触摸的通知对象
*/
onTouchMove(e, m) {
if (!this.touchState.activeId || this.touchState.activeId !== m.id) return
if (!e.touches || !e.touches[0]) return
const touch = e.touches[0]
const deltaX = touch.clientX - this.touchState.startX
const deltaY = touch.clientY - this.touchState.startY
// 滑动方向判定锁:避免干扰页面垂直滚动
if (!this.touchState.direction) {
if (Math.abs(deltaX) > 6 || Math.abs(deltaY) > 6) {
if (Math.abs(deltaX) >= Math.abs(deltaY)) {
this.touchState.direction = 'horizontal'
// 确定为横向滑动时,收起其他已滑开的卡片
this.closeAllSwipe(m.id)
} else {
this.touchState.direction = 'vertical'
}
}
}
// 纵向滚动不阻拦、不处理位移
if (this.touchState.direction !== 'horizontal') return
// 标记当前处于手势拖拽中,关闭 CSS 动效以达到 60fps 灵敏跟手
if (!m.isSwiping) {
this.$set(m, 'isSwiping', true)
}
const maxSwipePx = this.getMaxSwipePx()
let targetOffset = this.touchState.initOffset + deltaX
// 边界限制:不能向右拉出卡片(最大为 0)
if (targetOffset > 0) {
targetOffset = 0
}
// 向左滑动超出最大展开宽度时加阻尼效果
if (targetOffset < -maxSwipePx) {
const overstep = -maxSwipePx - targetOffset
targetOffset = -maxSwipePx - overstep * 0.2
}
this.$set(m, 'offset', targetOffset)
},
/**
* 卡片触摸结束手势
* @param {TouchEvent} e 触摸事件对象
* @param {Object} m 当前触摸的通知对象
*/
onTouchEnd(e, m) {
if (!this.touchState.activeId || this.touchState.activeId !== m.id) return
this.$set(m, 'isSwiping', false)
const currentOffset = m.offset || 0
const maxSwipePx = this.getMaxSwipePx()
const moveTime = Date.now() - this.touchState.startTime
const movedX = currentOffset - this.touchState.initOffset
// 判定是否展开:
// 1. 快速轻扫手势(时间短且位移达到一定幅度)
// 2. 释放位置超过一半阈值
let shouldOpen = false
if (moveTime < 280 && movedX < -25) {
shouldOpen = true
} else if (moveTime < 280 && movedX > 25) {
shouldOpen = false
} else if (currentOffset <= -maxSwipePx / 2) {
shouldOpen = true
}
if (shouldOpen) {
this.$set(m, 'offset', -maxSwipePx)
} else {
this.$set(m, 'offset', 0)
}
this.touchState.activeId = null
this.touchState.direction = ''
},
/**
* 卡片点击事件:包含已滑开卡片的安全收起判断与详情查看
* @param {Object} m 当前点击的通知对象
*/
handleCardClick(m) {
// 若当前卡片处于滑开状态,则先收起卡片,不触发打开详情
if (m.offset && m.offset < 0) {
this.$set(m, 'offset', 0)
return
}
// 若存在其他卡片处于滑开状态,收起其他卡片并拦截点击
let hasOtherOpen = false
this.list.forEach(item => {
if (item.offset && item.offset < 0) {
this.$set(item, 'offset', 0)
hasOtherOpen = true
}
})
if (hasOtherOpen) return
// 正常打开通知详情
this.openMsg(m)
},
/**
* 删除单条通知
* @param {Object} m 待删除的通知对象
* @param {number} index 当前项在列表中的索引
*/
handleDelete(m, index) {
uni.showModal({
title: '删除通知',
content: '确定要删除此条通知吗?',
confirmText: '删除',
confirmColor: '#FF4D4F',
cancelText: '取消',
success: async (res) => {
if (!res.confirm) {
// 取消则收起滑开的卡片
this.$set(m, 'offset', 0)
return
}
uni.showLoading({ title: '删除中...', mask: true })
try {
await messageApi.delete(m.id)
this.list.splice(index, 1)
uni.$emit('refreshUnreadCount')
uni.showToast({ title: '已删除', icon: 'success' })
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
} finally {
uni.hideLoading()
}
},
})
},
/**
* 点击查看通知详情并标记已读
* @param {Object} m 通知项对象
*/
async openMsg(m) {
if (!m.read) {
m.read = true
try {
await messageApi.read(m.id)
uni.$emit('refreshUnreadCount')
} catch (e) {}
}
uni.showModal({
title: m.title || '通知详情',
content: m.content || '',
showCancel: false,
confirmText: '我知道了',
confirmColor: '#00A854',
})
},
},
}
</script>
<style lang="scss" scoped>
.msg {
min-height: 100vh;
background: #F2FEF7;
}
/* 分类 Tab */
.msg__tabs {
display: flex;
background: #ffffff;
padding: 0 32rpx;
border-bottom: 1rpx solid rgba(0, 210, 106, 0.08);
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.02);
position: sticky;
top: 0;
z-index: 10;
}
.msg__tab {
flex: 1;
height: 88rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 30rpx;
color: #64748B;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
&--active {
color: $fc-primary-text;
font-weight: 700;
}
}
.msg__tab-line {
position: absolute;
bottom: 0;
width: 56rpx;
height: 6rpx;
border-radius: 4rpx;
background: $fc-gradient-primary;
}
/* 顶部操作工具条 */
.msg__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 28rpx 12rpx;
}
.msg__stats {
display: flex;
align-items: center;
font-size: 24rpx;
color: #64748B;
}
.msg__stats-sep {
margin: 0 8rpx;
opacity: 0.6;
}
.msg__stats-unread {
color: $fc-primary-text;
font-weight: 600;
}
.msg__actions {
display: flex;
align-items: center;
}
.msg__btn {
padding: 8rpx 22rpx;
margin-left: 14rpx;
border-radius: 26rpx;
background: #ffffff;
border: 1rpx solid rgba(0, 210, 106, 0.3);
box-shadow: 0 2rpx 8rpx rgba(0, 210, 106, 0.06);
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:active {
transform: scale(0.96);
background: #E6F9F0;
}
&--disabled {
opacity: 0.45;
pointer-events: none;
}
&--clear {
border-color: rgba(100, 116, 139, 0.2);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.02);
.msg__btn-text {
color: #64748B;
}
&:active {
background: #F1F5F9;
}
}
}
.msg__btn-text {
font-size: 24rpx;
font-weight: 500;
color: $fc-primary-text;
}
/* 消息列表 */
.msg__list {
padding: 10rpx 0 20rpx;
}
/* 左滑卡片外层容器 */
.msg__swipe-box {
position: relative;
margin: 0 24rpx 20rpx;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 80, 40, 0.04);
}
/* 底层操作区:删除按钮 */
.msg__swipe-actions {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 140rpx;
background: linear-gradient(135deg, #FF6262, #FF3D3D);
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
}
.msg__delete-btn {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:active {
opacity: 0.88;
}
}
.msg__delete-text {
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
letter-spacing: 2rpx;
}
/* 表层卡片内容 */
.msg__swipe-content {
position: relative;
z-index: 2;
width: 100%;
padding: 24rpx;
box-sizing: border-box;
background: #ffffff;
border-radius: 20rpx;
border: 1rpx solid rgba(4, 48, 30, 0.05);
display: flex;
align-items: flex-start;
cursor: pointer;
transition: transform 0.22s cubic-bezier(0.25, 1, 0.5, 1);
&--no-transition {
transition: none !important;
}
&--unread {
background: #F8FFF9;
border-left: 8rpx solid $fc-primary;
}
&:active {
background-color: #FAFCFA;
}
}
.msg__icon {
width: 80rpx;
height: 80rpx;
flex: 0 0 80rpx;
display: flex;
align-items: center;
justify-content: center;
}
.msg__body {
flex: 1;
margin-left: 20rpx;
min-width: 0;
}
.msg__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.msg__title {
font-size: 30rpx;
font-weight: 600;
color: #1E293B;
margin-right: 16rpx;
}
.msg__time {
font-size: 22rpx;
color: #94A3B8;
white-space: nowrap;
}
.msg__content {
display: block;
margin-top: 10rpx;
font-size: 26rpx;
color: #334155;
line-height: 1.5;
}
.msg__dot {
position: absolute;
top: 24rpx;
right: 24rpx;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: $fc-primary;
box-shadow: 0 0 8rpx rgba(0, 224, 112, 0.6);
}
.msg__tabbar-space {
height: 140rpx;
}
/* 未登录引导样式 */
.msg__unlogin-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 32rpx;
}
.msg__unlogin-btn {
margin-top: -20rpx;
height: 72rpx;
padding: 0 48rpx;
border-radius: 36rpx;
background: #00D26A;
display: flex;
align-items: center;
justify-content: center;
border: none;
box-shadow: 0 4rpx 16rpx rgba(0, 210, 106, 0.25);
}
.msg__unlogin-btn-txt {
font-size: 26rpx;
font-weight: 700;
color: #ffffff;
}
</style>