diff --git a/addons/shopro/service/partner/IdentityService.php b/addons/shopro/service/partner/IdentityService.php index 4deabd9..4a550a4 100644 --- a/addons/shopro/service/partner/IdentityService.php +++ b/addons/shopro/service/partner/IdentityService.php @@ -111,6 +111,35 @@ class IdentityService } $available = max(0, (float) $released - (float) $used); + $windowDays = PartnerConfig::activeDays(); + $isActive = (new ActiveService())->isActive($agent); + $now = time(); + $lastTime = $last ? intval($last) : 0; + + $expireTime = 0; + $remainingSeconds = 0; + $daysRemaining = 0; + $activeStatus = 'inactive'; + + if ($isActive) { + if ($lastTime > 0) { + $orderDay = strtotime(date('Y-m-d 00:00:00', $lastTime)); + $expireTime = $orderDay + $windowDays * 86400; + $remainingSeconds = max(0, $expireTime - $now); + $daysRemaining = intval(ceil($remainingSeconds / 86400)); + $activeStatus = ($daysRemaining <= 5) ? 'expiring' : 'active'; + } else { + $activeStatus = 'active'; + $daysRemaining = $windowDays; + $expireTime = $now + $windowDays * 86400; + $remainingSeconds = $windowDays * 86400; + } + } else { + $activeStatus = 'inactive'; + $daysRemaining = 0; + $remainingSeconds = 0; + } + $data = is_object($agent) ? $agent->toArray() : $agent; return array_merge($data, $identity, [ 'is_venue' => intval($data['is_venue'] ?? 0), @@ -124,8 +153,12 @@ class IdentityService 'voucher_released' => self::formatMoney($released), 'voucher_used' => self::formatMoney($used), 'voucher_available' => self::formatMoney($available), - 'active_30d' => (new ActiveService())->isActive($agent), - 'last_self_order_time' => $last ? intval($last) : 0, + 'active_30d' => $isActive, + 'active_status' => $activeStatus, + 'days_remaining' => $daysRemaining, + 'expire_time' => $expireTime, + 'expire_seconds' => $remainingSeconds, + 'last_self_order_time' => $lastTime, ]); } @@ -147,6 +180,11 @@ class IdentityService 'madow_value' => '0.00', 'voucher_available' => '0.00', 'active_30d' => true, + 'active_status' => 'inactive', + 'days_remaining' => 0, + 'expire_time' => 0, + 'expire_seconds' => 0, + 'last_self_order_time' => 0, 'agent_form' => self::agentFormEnabled(), 'partner_mode' => PartnerConfig::isPartnerMode(), ]; @@ -166,6 +204,11 @@ class IdentityService 'madow_value' => $full['madow_value'], 'voucher_available' => $full['voucher_available'], 'active_30d' => $full['active_30d'], + 'active_status' => $full['active_status'] ?? 'inactive', + 'days_remaining' => $full['days_remaining'] ?? 0, + 'expire_time' => $full['expire_time'] ?? 0, + 'expire_seconds' => $full['expire_seconds'] ?? 0, + 'last_self_order_time' => $full['last_self_order_time'] ?? 0, 'agent_form' => self::agentFormEnabled(), 'partner_mode' => PartnerConfig::isPartnerMode(), ]; diff --git a/tests/verify_active_countdown_spec.js b/tests/verify_active_countdown_spec.js new file mode 100644 index 0000000..595bb0a --- /dev/null +++ b/tests/verify_active_countdown_spec.js @@ -0,0 +1,210 @@ +/** + * 30天收益保活倒计时看板与状态机综合测试用例 + * 覆盖: + * 1. 后端 IdentityService 保活到期时间与状态判定(正常活跃、临期预警、奖励暂停) + * 2. 前端 30 天秒级倒计时时分秒换算逻辑 + * 3. 前端 index.vue 模板节点及样式定义完整性 + * 4. mp-weixin 编译产物 index.wxml / index.wxss / index.js 完整性 + */ + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +let passCount = 0; +let failCount = 0; + +function it(desc, fn) { + try { + fn(); + console.log(` [PASS] ${desc}`); + passCount++; + } catch (err) { + console.error(` [FAIL] ${desc}`); + console.error(` Error: ${err.message}`); + failCount++; + } +} + +console.log('========================================================'); +console.log(' 30天收益保活倒计时看板与状态机覆盖性测试'); +console.log('========================================================\n'); + +// 1. 测试 IdentityService.php +const identityServicePath = path.resolve(__dirname, '../addons/shopro/service/partner/IdentityService.php'); +const identityContent = fs.readFileSync(identityServicePath, 'utf8'); + +it('IdentityService.php 存在并包含 active_status 字段逻辑', () => { + assert(fs.existsSync(identityServicePath), 'IdentityService.php 不存在'); + assert(identityContent.includes("'active_status'"), "未在 attachAgent 中计算 'active_status'"); +}); + +it('IdentityService.php 正确区分 active / expiring / inactive 三种状态', () => { + assert(identityContent.includes("'inactive'"), "缺少 inactive 状态判定"); + assert(identityContent.includes("'expiring'"), "缺少 expiring 状态判定"); + assert(identityContent.includes("'active'"), "缺少 active 状态判定"); +}); + +it('IdentityService.php 导出 expire_time / expire_seconds / days_remaining', () => { + assert(identityContent.includes("'expire_time'"), "缺少 expire_time"); + assert(identityContent.includes("'expire_seconds'"), "缺少 expire_seconds"); + assert(identityContent.includes("'days_remaining'"), "缺少 days_remaining"); +}); + +// 2. 模拟前端状态计算与倒计时逻辑 +function calculateActiveState(agent, user, nowSec) { + const lastTime = Number(agent.last_self_order_time || user.last_self_order_time || 0); + const isExplicitInactive = agent.active_30d === false || user.active_30d === false || agent.is_active_30d === false || user.is_active_30d === false; + + if (isExplicitInactive || (lastTime <= 0 && (agent.active_30d === false || user.active_30d === false))) { + return { + status: 'inactive', + title: '收益奖励已暂停', + badge: '暂缓计奖', + btnText: '去选购恢复' + }; + } + + let expireTime = Number(agent.expire_time || 0); + if (!expireTime) { + expireTime = lastTime > 0 ? (lastTime + 30 * 86400) : (nowSec + 30 * 86400); + } + + const remainingSeconds = Math.max(0, expireTime - nowSec); + const remainingDays = Math.ceil(remainingSeconds / 86400); + + if (remainingSeconds <= 0) { + return { + status: 'inactive', + title: '收益奖励已暂停', + badge: '暂缓计奖', + btnText: '去选购恢复' + }; + } + + if (remainingDays <= 5) { + return { + status: 'expiring', + title: '30天保活临期预警', + badge: `仅剩 ${remainingDays} 天`, + btnText: '立即自购保活' + }; + } + + return { + status: 'active', + title: '30天收益资格生效中', + badge: '正常活跃', + btnText: '去自购续期' + }; +} + +function calculateCountdown(expireTime, nowSec) { + const remaining = Math.max(0, expireTime - nowSec); + const d = Math.floor(remaining / 86400); + const h = Math.floor((remaining % 86400) / 3600); + const m = Math.floor((remaining % 3600) / 60); + const s = remaining % 60; + return { + days: d < 10 ? '0' + d : String(d), + hours: h < 10 ? '0' + h : String(h), + minutes: m < 10 ? '0' + m : String(m), + seconds: s < 10 ? '0' + s : String(s), + totalSeconds: remaining + }; +} + +// 3. 正常场景:自购在第 2 天(剩余 28 天) -> 正常活跃 +it('正常场景:剩余28天 -> 状态为 active 且倒计时天数格式为 28', () => { + const now = 1700000000; + const expire = now + 28 * 86400 + 3600 * 5 + 60 * 12 + 30; // 28天5小时12分30秒 + const state = calculateActiveState({ expire_time: expire, last_self_order_time: now - 2 * 86400 }, {}, now); + const cd = calculateCountdown(expire, now); + + assert.strictEqual(state.status, 'active'); + assert.strictEqual(state.badge, '正常活跃'); + assert.strictEqual(cd.days, '28'); + assert.strictEqual(cd.hours, '05'); + assert.strictEqual(cd.minutes, '12'); + assert.strictEqual(cd.seconds, '30'); +}); + +// 4. 临期场景:剩余 3 天(<= 5 天) -> 临期预警 +it('临期预警场景:剩余3天 -> 状态为 expiring,提示立即自购保活', () => { + const now = 1700000000; + const expire = now + 3 * 86400 + 3600 * 2 + 60 * 1 + 5; + const state = calculateActiveState({ expire_time: expire, last_self_order_time: now - 27 * 86400 }, {}, now); + const cd = calculateCountdown(expire, now); + + assert.strictEqual(state.status, 'expiring'); + assert.strictEqual(state.badge, '仅剩 4 天'); // ceil + assert.strictEqual(state.btnText, '立即自购保活'); + assert.strictEqual(cd.days, '03'); + assert.strictEqual(cd.hours, '02'); + assert.strictEqual(cd.minutes, '01'); + assert.strictEqual(cd.seconds, '05'); +}); + +// 5. 业务失败/过期场景:超过30天未自购 -> 奖励暂停 +it('过期场景:剩余时间为0 -> 状态为 inactive,按钮为去选购恢复', () => { + const now = 1700000000; + const expire = now - 100; + const state = calculateActiveState({ expire_time: expire, last_self_order_time: now - 31 * 86400 }, {}, now); + const cd = calculateCountdown(expire, now); + + assert.strictEqual(state.status, 'inactive'); + assert.strictEqual(state.title, '收益奖励已暂停'); + assert.strictEqual(state.btnText, '去选购恢复'); + assert.strictEqual(cd.totalSeconds, 0); + assert.strictEqual(cd.days, '00'); +}); + +// 6. 前端源码 index.vue 结构检查 +const indexVuePath = path.resolve(__dirname, '../uniapp/pages/commission/index.vue'); +const indexVueContent = fs.readFileSync(indexVuePath, 'utf8'); + +it('uniapp/pages/commission/index.vue 包含 active-countdown-card 卡片', () => { + assert(indexVueContent.includes('active-countdown-card'), '未找到 active-countdown-card 类'); + assert(indexVueContent.includes('countdown.days'), '未找到倒计时天数绑定'); + assert(indexVueContent.includes('countdown.hours'), '未找到倒计时小时绑定'); + assert(indexVueContent.includes('countdown.minutes'), '未找到倒计时分钟绑定'); + assert(indexVueContent.includes('countdown.seconds'), '未找到倒计时秒数绑定'); +}); + +it('uniapp/pages/commission/index.vue 包含定时器生命周期管理', () => { + assert(indexVueContent.includes('setInterval(updateCountdown, 1000)'), '缺少 1 秒定时器'); + assert(indexVueContent.includes('clearInterval(timer)'), '缺少定时器清理'); + assert(indexVueContent.includes('onShow'), '缺少 onShow 挂载'); + assert(indexVueContent.includes('onHide'), '缺少 onHide 清理'); + assert(indexVueContent.includes('onUnload'), '缺少 onUnload 清理'); +}); + +// 7. 微信小程序已编译产物检查 +const wxmlPath = path.resolve(__dirname, '../uniapp/unpackage/dist/dev/mp-weixin/pages/commission/index.wxml'); +const wxssPath = path.resolve(__dirname, '../uniapp/unpackage/dist/dev/mp-weixin/pages/commission/index.wxss'); +const jsPath = path.resolve(__dirname, '../uniapp/unpackage/dist/dev/mp-weixin/pages/commission/index.js'); + +it('mp-weixin 产物完整且包含 active-countdown-card 节点与样式', () => { + const wxml = fs.readFileSync(wxmlPath, 'utf8'); + const wxss = fs.readFileSync(wxssPath, 'utf8'); + const js = fs.readFileSync(jsPath, 'utf8'); + + assert(wxml.includes('active-countdown-card'), 'index.wxml 缺少 active-countdown-card'); + assert(wxml.includes('timer-digits'), 'index.wxml 缺少 timer-digits 倒计时节点'); + assert(wxss.includes('.active-countdown-card'), 'index.wxss 缺少 .active-countdown-card'); + assert(wxss.includes('.status-active'), 'index.wxss 缺少 .status-active'); + assert(wxss.includes('.status-expiring'), 'index.wxss 缺少 .status-expiring'); + assert(wxss.includes('.status-inactive'), 'index.wxss 缺少 .status-inactive'); + assert(js.includes('countdown'), 'index.js 缺少 countdown 响应式对象'); + assert(js.includes('activeState'), 'index.js 缺少 activeState 响应式计算属性'); +}); + +console.log(`\n--------------------------------------------------------`); +console.log(`测试结果统计: 通过 ${passCount} 项, 失败 ${failCount} 项`); +console.log(`========================================================\n`); + +if (failCount > 0) { + process.exit(1); +} else { + process.exit(0); +} diff --git a/uniapp/pages/commission/index.vue b/uniapp/pages/commission/index.vue index fe74363..1c9e107 100644 --- a/uniapp/pages/commission/index.vue +++ b/uniapp/pages/commission/index.vue @@ -3,13 +3,60 @@ - - - 近 30 日暂无本人订单,业务奖励已暂停。 - + + + + + + + + + + + + {{ activeState.title }} + {{ activeState.badge }} + + + + + + + + + + + 保活剩余: + {{ countdown.days }} + 天 + + + {{ countdown.hours }}时 + : + {{ countdown.minutes }}分 + : + {{ countdown.seconds }}秒 + + + + + + 近 30 日内暂无本人自购订单,所有收益分成已暂缓沉淀。 + + + + + + ● + {{ activeState.tip }} + + {{ activeState.lastOrderText }} + + + @@ -21,7 +68,8 @@ 团队作战中心 - 正常活跃 + 正常活跃 (剩{{ countdown.days }}天) + 临期预警 (仅剩{{ countdown.days }}天) 奖励暂停 @@ -48,8 +96,9 @@ @@ -94,35 +278,231 @@ background-size: 100% 100% !important; } - .active-warn { + .active-countdown-card { width: 694rpx; - margin: 0 auto 16rpx; - padding: 20rpx 24rpx; + margin: 16rpx auto 0; + padding: 24rpx 28rpx; box-sizing: border-box; - background: #fff4e5; - border-radius: 12rpx; - display: flex; - align-items: center; - justify-content: space-between; + border-radius: 16rpx; position: relative; z-index: 3; + transition: all 0.3s ease; - .warn-text { - flex: 1; - font-size: 24rpx; - line-height: 34rpx; - color: #a17545; - padding-right: 16rpx; + // 正常活跃状态 + &.status-active { + background: linear-gradient(135deg, #f0fdf4 0%, #ffffff 100%); + border: 1rpx solid #bbf7d0; + box-shadow: 0 4rpx 16rpx rgba(187, 247, 208, 0.35); + + .status-icon-box { + background: #dcfce7; + } + .status-title-text { + color: #166534; + } + .pill-active { + background: #dcfce7; + color: #15803d; + } + .btn-active { + background: #059669; + color: #ffffff; + } + .digit-box { + background: #ecfdf5; + border: 1rpx solid #a7f3d0; + .num { + color: #047857; + } + .unit { + color: #065f46; + } + } + .colon { + color: #059669; + } } - .warn-btn { - flex-shrink: 0; - height: 52rpx; - padding: 0 22rpx; - background: #ff6000; - border-radius: 26rpx; - font-size: 22rpx; - color: #ffffff; + // 临期预警状态 + &.status-expiring { + background: linear-gradient(135deg, #fff7ed 0%, #fffbeb 100%); + border: 1rpx solid #fed7aa; + box-shadow: 0 4rpx 18rpx rgba(253, 186, 116, 0.45); + + .status-icon-box { + background: #ffedd5; + } + .status-title-text { + color: #9a3412; + } + .pill-expiring { + background: #ffedd5; + color: #c2410c; + font-weight: bold; + } + .btn-expiring { + background: linear-gradient(90deg, #ea580c 0%, #f97316 100%); + color: #ffffff; + box-shadow: 0 4rpx 12rpx rgba(234, 88, 12, 0.3); + } + .digit-box { + background: #ffedd5; + border: 1rpx solid #fdba74; + .num { + color: #c2410c; + } + .unit { + color: #9a3412; + } + } + .colon { + color: #ea580c; + } + } + + // 奖励暂停状态 + &.status-inactive { + background: linear-gradient(135deg, #fef2f2 0%, #fff1f2 100%); + border: 1rpx solid #fecaca; + box-shadow: 0 4rpx 16rpx rgba(254, 202, 202, 0.35); + + .status-icon-box { + background: #fee2e2; + } + .status-title-text { + color: #991b1b; + } + .pill-inactive { + background: #fee2e2; + color: #b91c1c; + } + .btn-inactive { + background: #dc2626; + color: #ffffff; + } + } + + .card-header { + .status-icon-box { + width: 48rpx; + height: 48rpx; + border-radius: 24rpx; + .icon-symbol { + font-size: 26rpx; + font-weight: bold; + } + .text-green { color: #059669; } + .text-amber { color: #d97706; } + .text-red { color: #dc2626; } + } + + .status-title-text { + font-size: 28rpx; + font-weight: bold; + } + + .status-pill { + font-size: 20rpx; + padding: 4rpx 14rpx; + border-radius: 20rpx; + font-weight: 500; + } + + .action-btn { + height: 52rpx; + padding: 0 24rpx; + border-radius: 26rpx; + font-size: 22rpx; + font-weight: 500; + } + } + + .card-body { + .countdown-row { + background: rgba(255, 255, 255, 0.7); + padding: 12rpx 16rpx; + border-radius: 10rpx; + + .countdown-label { + .label-prefix { + font-size: 24rpx; + color: #64748b; + } + .label-days-highlight { + font-size: 30rpx; + font-weight: bold; + color: #0f172a; + margin: 0 2rpx; + font-family: OPPOSANS; + } + .label-days-text { + font-size: 22rpx; + color: #475569; + } + } + + .timer-digits { + .digit-box { + padding: 4rpx 8rpx; + border-radius: 6rpx; + display: flex; + align-items: baseline; + min-width: 52rpx; + justify-content: center; + + .num { + font-family: OPPOSANS, DIN, monospace; + font-size: 24rpx; + font-weight: bold; + } + .unit { + font-size: 18rpx; + margin-left: 2rpx; + } + } + + .colon { + font-weight: bold; + font-size: 24rpx; + margin: 0 4rpx; + } + } + } + + .inactive-row { + background: rgba(254, 226, 226, 0.4); + padding: 14rpx 18rpx; + border-radius: 10rpx; + + .inactive-desc { + font-size: 24rpx; + color: #991b1b; + line-height: 34rpx; + } + } + + .rule-footer { + font-size: 20rpx; + color: #94a3b8; + + .footer-dot { + font-size: 14rpx; + color: #cbd5e1; + margin-right: 6rpx; + } + + .footer-tip-text { + font-size: 22rpx; + color: #64748b; + } + + .footer-right-time { + font-size: 20rpx; + color: #94a3b8; + font-family: OPPOSANS; + flex-shrink: 0; + } + } } } @@ -159,6 +539,14 @@ color: #059669; font-weight: 500; } + .amber-badge { + font-size: 20rpx; + padding: 2rpx 10rpx; + border-radius: 12rpx; + background: #ffedd5; + color: #c2410c; + font-weight: 500; + } .warn-badge { font-size: 20rpx; padding: 2rpx 10rpx;