Files
msh-system/msh_single_uniapp/pages/tool/checkin.vue

710 lines
18 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="checkin-page">
<!-- 积分规则按钮 -->
<view class="rules-btn-top" @tap="showRules">
<text>积分规则</text>
</view>
<!-- 打卡状态卡片 -->
<view class="checkin-card">
<!-- 积分显示 -->
<view class="points-header">
<text class="star-icon"></text>
<text class="points-text">{{ currentPoints }} 积分</text>
</view>
<!-- 连续打卡天数 -->
<view class="streak-container">
<view
class="streak-item"
v-for="(item, index) in streakDays"
:key="index"
>
<view
class="streak-circle"
:class="{
completed: item.completed,
pending: !item.completed && !item.special,
special: item.special
}"
>
<text v-if="item.completed" class="check-icon"></text>
<text v-else-if="item.special" class="crown-icon">👑</text>
<text v-else class="circle-icon"></text>
</view>
<text class="day-number">{{ item.day }}</text>
</view>
</view>
<!-- 立即打卡按钮今日已打卡则显示灰色不可点 -->
<view
class="checkin-btn"
:class="{ 'checkin-btn-disabled': todaySigned }"
@click="handleCheckin"
>
<text>{{ todaySigned ? '今日已打卡' : '立即打卡' }}</text>
</view>
</view>
<!-- 打卡任务 -->
<view class="section">
<view class="section-title">打卡任务</view>
<view class="task-list">
<view
class="task-item"
v-for="(task, index) in taskList"
:key="index"
>
<view class="task-icon" :style="{ background: task.iconBg }">
<text>{{ task.icon }}</text>
</view>
<view class="task-content">
<view class="task-header">
<text class="task-name">{{ task.name }}</text>
<view class="points-badge">
<text>+{{ task.points }}</text>
</view>
</view>
<text class="task-desc">{{ task.desc }}</text>
</view>
<view class="task-action-btn" @click="handleTask(task)">
<text>去完成</text>
</view>
</view>
</view>
</view>
<!-- 积分兑换 -->
<view class="section">
<view class="section-title">积分兑换</view>
<view class="exchange-list">
<view
class="exchange-item"
v-for="(item, index) in exchangeList"
:key="index"
>
<view class="exchange-icon" :style="{ background: item.iconBg }">
<text>{{ item.icon }}</text>
</view>
<view class="exchange-content">
<text class="exchange-name">{{ item.name }}</text>
<text class="exchange-desc">{{ item.desc }}</text>
</view>
<view class="exchange-btn" @click="handleExchange(item)">
<text>立即兑换</text>
</view>
</view>
</view>
</view>
<!-- 提示信息 -->
<view class="tip-banner">
<text class="tip-icon">💡</text>
<text class="tip-text">连续打卡7天可获得额外奖励完成每日任务还能获得更多积分哦</text>
</view>
<!-- 底部安全距离 -->
<view class="safe-bottom"></view>
</view>
</template>
<script>
export default {
data() {
return {
todaySigned: false,
/** 防止 loadCheckinData 与打卡后刷新并发时,旧 user/info 响应覆盖新积分 */
_userInfoFetchGen: 0,
/** 打卡流程进行中:禁止 onShow 触发的 loadCheckinData 用「打卡前」发出的 user/info 写 currentPoints */
_suppressStalePointsLoad: false,
/** 防止连点导致多次打卡请求与积分展示乱序 */
_checkinSubmitting: false,
currentPoints: 0,
streakDays: [
{ day: 1, completed: false, special: false },
{ day: 2, completed: false, special: false },
{ day: 3, completed: false, special: false },
{ day: 4, completed: false, special: false },
{ day: 5, completed: false, special: false },
{ day: 6, completed: false, special: false },
{ day: 7, completed: false, special: true }
],
taskList: [
{
id: 1,
name: '饮食记录上传',
desc: '上传饮食照片赚取积分',
points: 20,
icon: '📸',
iconBg: 'linear-gradient(135deg, #fff4f0 0%, #ffe8e0 100%)'
},
{
id: 2,
name: '打卡视频制作',
desc: '制作打卡视频分享动态',
points: 50,
icon: '🎬',
iconBg: 'linear-gradient(135deg, #fff4f0 0%, #ffe8e0 100%)'
},
{
id: 3,
name: '内容分享',
desc: '分享内容至社交平台',
points: 30,
icon: '📤',
iconBg: 'linear-gradient(135deg, #fff4f0 0%, #ffe8e0 100%)'
}
],
exchangeList: [
{
id: 1,
name: '微信红包兑换',
desc: '100积分 = 5元红包',
icon: '💰',
iconBg: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)'
},
{
id: 2,
name: '米面油',
desc: '300积分 = 10斤大米/5升油',
icon: '🌾',
iconBg: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)'
},
{
id: 3,
name: '零食礼盒',
desc: '200积分 = 1箱零食',
icon: '🎁',
iconBg: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)'
}
]
}
},
onLoad() {
const { checkLogin, toLogin } = require('@/libs/login.js');
if (!checkLogin()) {
toLogin();
return;
}
this.loadCheckinData();
},
onShow() {
// 页面显示时刷新数据(打卡请求进行中时跳过,避免与 handleCheckin 内刷新竞态)
if (this._checkinSubmitting) {
return;
}
this.loadCheckinData();
},
methods: {
async loadCheckinData(options = {}) {
const includePointsRefresh = !!options.includePointsRefresh;
const skipPoints = includePointsRefresh
? false
: (!!options.skipPoints || this._suppressStalePointsLoad);
const pointsGen = skipPoints ? null : ++this._userInfoFetchGen;
try {
const { getCheckinStreak, getCheckinTasks } = await import('@/api/tool.js');
const { getSignGet, getFrontUserInfo } = await import('@/api/user.js');
const [userInfoRes, streakRes, tasksRes, signRes] = await Promise.all([
skipPoints ? Promise.resolve(null) : getFrontUserInfo().catch(() => null),
getCheckinStreak().catch(() => ({ data: { streakDays: 7, currentStreak: 0 } })),
getCheckinTasks().catch(() => ({ data: { tasks: [] } })),
getSignGet().catch(() => ({ data: { today: false } }))
]);
if (signRes && signRes.data && signRes.data.today) {
this.todaySigned = true;
}
// BUG-001-A打卡请求进行中时勿用并行的 user/info 回写积分(避免先显示旧分再跳变);显式 includePointsRefresh 时仍刷新
const allowPointsFromThisLoad =
!this._checkinSubmitting || includePointsRefresh;
if (!skipPoints && userInfoRes && pointsGen === this._userInfoFetchGen && allowPointsFromThisLoad) {
const p = this._parsePointsFromUserInfo(userInfoRes);
if (p != null && !Number.isNaN(Number(p))) {
this.currentPoints = Number(p);
}
}
if (streakRes.data) {
const streak = streakRes.data.currentStreak || 0;
this.streakDays = Array.from({ length: 7 }, (_, i) => ({
day: i + 1,
completed: i < streak,
special: i === 6
}));
}
if (tasksRes.data && tasksRes.data.tasks && tasksRes.data.tasks.length > 0) {
this.taskList = tasksRes.data.tasks.map(task => ({
id: task.id || task.taskId,
title: task.title || task.name,
desc: task.desc || task.description || '',
points: task.points || task.reward || 0,
completed: task.completed || task.status === 'done',
icon: task.icon || '📝'
}));
}
} catch (error) {
console.error('加载打卡数据失败:', error);
}
},
showRules() {
uni.navigateTo({
url: '/pages/tool/points-rules'
})
},
/**
* BUG-001-A打卡 API 成功前不得修改 currentPoints禁止本地 +30 或与跳转前的乐观更新)。
* BUG-001-B仅 GET /api/front/user/infogetFrontUserInfo解析账户 integral打卡接口返回的 integral 为当日奖励分值,绝非总积分。
*/
async handleCheckin() {
if (this._checkinSubmitting) {
return;
}
if (this.todaySigned) {
uni.showToast({ title: '今日已打卡', icon: 'none' });
return;
}
this._checkinSubmitting = true;
this._suppressStalePointsLoad = true;
const pointsBeforeCheckin = Number(this.currentPoints) || 0;
const { userCheckin, userCheckinDaily, getFrontUserInfo } = await import('@/api/user.js');
uni.showLoading({ title: '打卡中...', mask: true });
try {
// 1) /api/front/user/checkin失败时回退 /api/front/user/sign/integral二者均不读取 body 更新积分
await this._requestCheckin(userCheckin, userCheckinDaily);
// 2) 成功后必须 GET user/info用服务端账户积分更新展示
let refreshed = await this._refreshCurrentPointsFromUserInfo(getFrontUserInfo).catch((err) => {
console.warn('打卡后刷新积分失败:', err);
return false;
});
if (refreshed && Number(this.currentPoints) === pointsBeforeCheckin) {
await new Promise((r) => setTimeout(r, 250));
refreshed =
(await this._refreshCurrentPointsFromUserInfo(getFrontUserInfo).catch(() => false)) || refreshed;
}
if (!refreshed) {
await this.loadCheckinData({ includePointsRefresh: true });
}
this.todaySigned = true;
await this.loadCheckinData({ skipPoints: true });
uni.navigateTo({
url: '/pages/tool/checkin-publish'
});
} catch (e) {
const msg = (typeof e === 'string' ? e : (e && (e.message || e.msg))) || '打卡失败';
if (msg.includes('今日已签到') || msg.includes('不可重复')) {
this.todaySigned = true;
await this.loadCheckinData({ skipPoints: true });
} else {
await this.loadCheckinData({ includePointsRefresh: true }).catch(() => {});
}
uni.showToast({ title: msg, icon: 'none' });
return;
} finally {
this._suppressStalePointsLoad = false;
this._checkinSubmitting = false;
uni.hideLoading();
}
},
/** 优先 GET user/checkin路由类失败时回退 user/sign/integral返回值勿用于 currentPointsdata 为签到档位配置integral 为奖励分非余额)。 */
async _requestCheckin(userCheckin, userCheckinDaily) {
try {
await userCheckin();
} catch (err) {
const msg = String((typeof err === 'string' ? err : (err && (err.message || err.msg))) || '');
const m = msg.toLowerCase();
// CRMEB 前端对 code=404 常 reject 文案「没有找到相关数据」,不含数字 404须一并识别才能回退到 sign/integral
const needFallback =
m.includes('404') ||
m.includes('405') ||
m.includes('not found') ||
m.includes('no static resource') ||
msg.includes('没有找到相关数据') ||
msg.includes('找不到');
if (!needFallback) throw err;
await userCheckinDaily();
}
},
async _refreshCurrentPointsFromUserInfo(getFrontUserInfoFn) {
const gen = ++this._userInfoFetchGen;
const getInfo =
typeof getFrontUserInfoFn === 'function'
? getFrontUserInfoFn
: (await import('@/api/user.js')).getFrontUserInfo;
const infoRes = await getInfo({ _: Date.now() });
if (gen !== this._userInfoFetchGen) {
return false;
}
const serverPoints = this._parsePointsFromUserInfo(infoRes);
if (serverPoints != null && !Number.isNaN(Number(serverPoints))) {
this.currentPoints = Number(serverPoints);
return true;
}
return false;
},
_parsePointsFromUserInfo(infoRes) {
if (!infoRes) return null;
const payload = infoRes.data;
const d = (payload && payload.data != null && typeof payload === 'object') ? payload.data : payload;
if (d == null || typeof d !== 'object') return null;
// 勿把打卡/签到接口的 SystemGroupDataSignConfigVo含 day + integral 奖励)当成个人中心 integral
if (d.day != null && d.nickname == null && d.nowMoney == null) {
return null;
}
const v = d.integral ?? d.points ?? d.totalPoints ?? (d.user && (d.user.integral ?? d.user.points ?? d.user.totalPoints));
return v != null && v !== '' ? v : null;
},
getTodayIndex() {
// 根据连续打卡数据计算当前是第几天
// streakDays 中已有 completed 状态,找第一个未完成的位置即为今天
const index = this.streakDays.findIndex(day => !day.completed)
return index >= 0 ? index : this.streakDays.length - 1
},
handleTask(task) {
switch (task.id) {
case 1: // 饮食记录上传
uni.navigateTo({
url: '/pages/tool/checkin-publish'
})
break
case 2: // 打卡视频制作
uni.navigateTo({
url: '/pages/tool/checkin-publish?type=video'
})
break
case 3: // 内容分享
uni.navigateTo({
url: '/pages/tool/invite-rewards'
})
break
default:
uni.showToast({
title: '功能开发中',
icon: 'none'
})
}
},
handleExchange(item) {
uni.navigateTo({
url: '/pages/tool/welcome-gift'
})
}
}
}
</script>
<style lang="scss" scoped>
.checkin-page {
min-height: 100vh;
background: linear-gradient(180deg, #fafbfc 0%, #f0f2f5 100%);
padding-bottom: 20rpx;
}
/* 积分规则按钮 */
.rules-btn-top {
position: fixed;
top: calc(var(--status-bar-height, 0) + 8rpx);
right: 60rpx;
z-index: 100;
background: rgba(255, 255, 255, 0.75);
border-radius: 20rpx;
padding: 12rpx 24rpx;
box-shadow: 2rpx 4rpx 2rpx rgba(33, 33, 33, 0.3);
text {
font-size: 24rpx;
color: #ff6b35;
font-weight: 500;
}
}
/* 打卡状态卡片 */
.checkin-card {
background: #ffffff;
border-radius: 48rpx;
padding: 40rpx;
margin: 32rpx 32rpx 0;
box-shadow: 0 16rpx 64rpx rgba(0, 0, 0, 0.06);
}
.points-header {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
margin-bottom: 40rpx;
.star-icon {
font-size: 36rpx;
}
.points-text {
font-size: 28rpx;
color: #b45309;
font-weight: 500;
}
}
.streak-container {
display: flex;
justify-content: space-between;
margin-bottom: 40rpx;
padding: 0 8rpx;
}
.streak-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
flex: 1;
}
.streak-circle {
width: 76rpx;
height: 76rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 12rpx -2rpx rgba(0, 0, 0, 0.1), 0 4rpx 8rpx -2rpx rgba(0, 0, 0, 0.1);
&.completed {
background: linear-gradient(135deg, #ff6b35 0%, #e85a2a 100%);
.check-icon {
font-size: 36rpx;
color: #ffffff;
}
}
&.pending {
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
.circle-icon {
font-size: 36rpx;
color: #9fa5c0;
}
}
&.special {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
.crown-icon {
font-size: 32rpx;
}
}
}
.day-number {
font-size: 24rpx;
color: #6b7280;
}
.checkin-btn {
background: linear-gradient(135deg, #ff6b35 0%, #ff7a4a 50%, #ff6b35 100%);
border-radius: 32rpx;
padding: 20rpx;
text-align: center;
box-shadow: 0 16rpx 48rpx rgba(255, 107, 53, 0.35);
text {
font-size: 32rpx;
color: #ffffff;
font-weight: 500;
}
}
.checkin-btn-disabled {
background: linear-gradient(135deg, #c4c4c4 0%, #e0e0e0 100%);
box-shadow: none;
opacity: 0.9;
}
/* 通用区块样式 */
.section {
margin: 48rpx 32rpx 0;
.section-title {
font-size: 28rpx;
color: #6b7280;
margin-bottom: 32rpx;
padding-left: 8rpx;
}
}
/* 打卡任务列表 */
.task-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.task-item {
background: #ffffff;
border-radius: 32rpx;
padding: 40rpx;
display: flex;
align-items: center;
gap: 32rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.04);
}
.task-icon {
width: 96rpx;
height: 96rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: inset 0 4rpx 8rpx rgba(0, 0, 0, 0.05);
text {
font-size: 48rpx;
}
}
.task-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.task-header {
display: flex;
align-items: center;
gap: 16rpx;
.task-name {
font-size: 30rpx;
color: #2e3e5c;
font-weight: 500;
}
.points-badge {
background: #fff4f0;
border-radius: 40rpx;
padding: 4rpx 16rpx;
text {
font-size: 24rpx;
color: #ff6b35;
}
}
}
.task-desc {
font-size: 24rpx;
color: #9fa5c0;
}
.task-action-btn {
background: linear-gradient(135deg, #ff6b35 0%, #e85a2a 100%);
border-radius: 24rpx;
padding: 16rpx 32rpx;
box-shadow: 0 8rpx 12rpx -2rpx rgba(0, 0, 0, 0.1), 0 4rpx 8rpx -2rpx rgba(0, 0, 0, 0.1);
text {
font-size: 24rpx;
color: #ffffff;
}
}
/* 积分兑换列表 */
.exchange-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.exchange-item {
background: #ffffff;
border-radius: 32rpx;
padding: 40rpx;
display: flex;
align-items: center;
gap: 32rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.04);
}
.exchange-icon {
width: 96rpx;
height: 96rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: inset 0 4rpx 8rpx rgba(0, 0, 0, 0.05);
text {
font-size: 48rpx;
}
}
.exchange-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
.exchange-name {
font-size: 30rpx;
color: #2e3e5c;
font-weight: 500;
}
.exchange-desc {
font-size: 24rpx;
color: #9fa5c0;
line-height: 1.6;
}
}
.exchange-btn {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
border-radius: 24rpx;
padding: 16rpx 32rpx;
box-shadow: 0 8rpx 12rpx -2rpx rgba(0, 0, 0, 0.1), 0 4rpx 8rpx -2rpx rgba(0, 0, 0, 0.1);
text {
font-size: 24rpx;
color: #ffffff;
}
}
/* 提示信息 */
.tip-banner {
background: linear-gradient(135deg, #fef3c7 0%, #fef9e7 50%, #fef3c7 100%);
border: 1rpx solid rgba(253, 230, 138, 0.5);
border-radius: 32rpx;
padding: 32rpx;
margin: 48rpx 32rpx 0;
display: flex;
gap: 24rpx;
align-items: flex-start;
.tip-icon {
font-size: 36rpx;
flex-shrink: 0;
}
.tip-text {
font-size: 24rpx;
color: #92400e;
line-height: 1.6;
flex: 1;
}
}
/* 底部安全距离 */
.safe-bottom {
height: 40rpx;
}
</style>