Files
msh-system/msh_single_uniapp/pages/tool/checkin.vue
msh-agent df0273de36 feat(checkin): P2-1 打卡任务/福利/邀请最小化方案(test-0415 反馈1-1)
按 PRD 简化版落地,零后端改动:

checkin.vue:
- 任务「内容分享」→ switchTab 到社区 /pages/tool_main/community
- 积分兑换列表 handleExchange → 一律 switchTab 到商城首页 /pages/index/index

welcome-gift.vue:
- 福利大礼包卡片可点击 → 跳商品详情页 /pages/goods/goods_details/index?id=
  · welfareProductId 从 storage 读,运营在 admin 配置后缓存
  · 缺省退化到商城首页
- 去除「步骤1 长按识别二维码」整段及对应 qrCode 资源
- 「立即添加」按钮改用 button open-type='contact' 唤起小程序客服
- H5/APP 兜底走 chatUrl web_page;无配置弹 toast

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 03:30:04 +08:00

750 lines
20 KiB
Vue
Raw Permalink 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({ _: Date.now() }).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禁止本地累加或与跳转前的乐观更新
* BUG-001-B打卡成功后仅 GET /api/front/user/infogetFrontUserInfo取账户积分禁止用打卡/签到接口 body 里的 integral多为当日奖励分非账户余额禁止硬编码 +30。
*/
async handleCheckin() {
if (this._checkinSubmitting) {
return;
}
if (this.todaySigned) {
uni.showToast({ title: '今日已打卡', icon: 'none' });
return;
}
// 先置位,避免与 onShow/loadCheckinData 并发写 currentPointsBUG-001-A
this._checkinSubmitting = true;
this._suppressStalePointsLoad = true;
try {
uni.showLoading({ title: '打卡中...', mask: true });
const { userCheckin, userCheckinDaily, getFrontUserInfo } = await import('@/api/user.js');
// 1) GET /api/front/user/checkin失败时回退 GET /api/front/user/sign/integral二者均触发服务端签到/累积分;成功前不得改 currentPoints且不得把返回体 integral 当作账户余额。
await this._requestCheckin(userCheckin, userCheckinDaily);
// 2) 仅在打卡接口成功后GET /api/front/user/infogetFrontUserInfo → request.get('user/info'))拉取服务端最新 integral 写入 currentPoints
const refreshed = await this._refreshUserInfoPointsAfterCheckin(getFrontUserInfo);
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 {
void (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;
void (await userCheckinDaily());
}
},
/**
* 打卡接口已成功后:多次 GET /api/front/user/info直到解析到合法账户积分或达上限仅用 user/info 响应更新 currentPoints整段重试共用一次 _userInfoFetchGen避免重试间自增导致误丢弃有效响应
* @returns {Promise<boolean>} 是否至少有一次成功解析并写入 currentPoints
*/
async _refreshUserInfoPointsAfterCheckin(getFrontUserInfoFn) {
const getInfo =
typeof getFrontUserInfoFn === 'function'
? getFrontUserInfoFn
: (await import('@/api/user.js')).getFrontUserInfo;
const lockGen = ++this._userInfoFetchGen;
const maxAttempts = 4;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (attempt > 0) {
await new Promise((r) => setTimeout(r, 120 * attempt));
}
try {
const infoRes = await getInfo({ _: Date.now() });
if (lockGen !== this._userInfoFetchGen) {
return false;
}
const serverPoints = this._parsePointsFromUserInfo(infoRes);
if (serverPoints != null && !Number.isNaN(Number(serverPoints))) {
this.currentPoints = Number(serverPoints);
return true;
}
} catch (err) {
console.warn('打卡后刷新积分失败:', err);
}
}
return false;
},
/** 区分个人中心 user/info 与签到档位 VO后者 integral 为当日奖励,非余额) */
_looksLikeUserCenterForIntegral(d) {
if (!d || typeof d !== 'object') return false;
// UserCenterResponse 含 vip/rechargeSwitch/couponCount 等SystemGroupDataSignConfigVo 仅有 day/title/integral/experience
return (
d.nickname != null ||
d.phone ||
d.avatar != null ||
d.nowMoney != null ||
d.level != null ||
d.couponCount != null ||
d.isPromoter != null ||
d.collectCount != null ||
d.brokeragePrice != null ||
typeof d.vip === 'boolean' ||
d.rechargeSwitch != null ||
(d.user &&
typeof d.user === 'object' &&
(d.user.nickname != null || d.user.phone || d.user.avatar != null || d.user.nowMoney != null))
);
},
_parsePointsFromUserInfo(infoRes) {
if (!infoRes) return null;
if (infoRes.code != null && Number(infoRes.code) !== 200) {
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 + title + integral 为当日奖励分)当成账户总积分
if (d.day != null && d.title != null) {
return null;
}
// 勿把打卡/签到接口的 SystemGroupDataSignConfigVo含 day + integral 奖励)当成个人中心 integral
if (d.day != null && d.nickname == null && d.nowMoney == null) {
return null;
}
// 仅有 day + integral、无个人中心字段时integral 为奖励分(常见 +30禁止当作余额
if (d.day != null && !this._looksLikeUserCenterForIntegral(d)) {
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) {
// test-0415 P2-1内容分享类任务跳转到社区
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: // 内容分享 → 社区tabBar
uni.switchTab({
url: '/pages/tool_main/community'
})
break
default:
uni.showToast({
title: '功能开发中',
icon: 'none'
})
}
},
handleExchange(item) {
// test-0415 P2-1积分兑换类任务统一跳转到商城首页tabBar
uni.switchTab({
url: '/pages/index/index'
})
}
}
}
</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>