From d1f005225a7e6437b23d29c6c118f0bfd8b8c41d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=96=B9=E6=88=90?= Date: Thu, 25 Dec 2025 20:35:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0=E3=80=81?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E7=AE=A1=E7=90=86=E3=80=81Vue=E7=BB=84?= =?UTF-8?q?=E5=90=88=E5=BC=8F=E5=87=BD=E6=95=B0=E5=8F=8A=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E9=A1=B5=E9=9D=A2=E7=BB=84=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E4=BA=86YifanSelector=E7=9A=84UI?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/YifanSelector.vue | 15 +- components/activity/ActivityHeader.vue | 259 ++++ components/activity/ActivityPageLayout.vue | 127 ++ components/activity/ActivityTabs.vue | 116 ++ components/activity/RecordsList.vue | 107 ++ components/activity/RewardsPopup.vue | 219 +++ components/activity/RewardsPreview.vue | 236 ++++ components/activity/index.js | 10 + composables/index.js | 8 + composables/useActivity.js | 69 + composables/useIssues.js | 89 ++ composables/useRecords.js | 65 + composables/useRewards.js | 107 ++ docs/代码重构分析/ALIGNMENT_代码冗余分析.md | 208 +++ docs/代码重构分析/DESIGN_组件化重构.md | 323 +++++ pages/activity/duiduipeng/index.vue | 741 +++------- pages/activity/wuxianshang/index.vue | 1354 ++++--------------- pages/activity/yifanshang/index.vue | 1058 +++------------ utils/activity.js | 149 ++ utils/cache.js | 145 ++ utils/format.js | 76 ++ 21 files changed, 2999 insertions(+), 2482 deletions(-) create mode 100644 components/activity/ActivityHeader.vue create mode 100644 components/activity/ActivityPageLayout.vue create mode 100644 components/activity/ActivityTabs.vue create mode 100644 components/activity/RecordsList.vue create mode 100644 components/activity/RewardsPopup.vue create mode 100644 components/activity/RewardsPreview.vue create mode 100644 components/activity/index.js create mode 100644 composables/index.js create mode 100644 composables/useActivity.js create mode 100644 composables/useIssues.js create mode 100644 composables/useRecords.js create mode 100644 composables/useRewards.js create mode 100644 docs/代码重构分析/ALIGNMENT_代码冗余分析.md create mode 100644 docs/代码重构分析/DESIGN_组件化重构.md create mode 100644 utils/activity.js create mode 100644 utils/cache.js create mode 100644 utils/format.js diff --git a/components/YifanSelector.vue b/components/YifanSelector.vue index 791d1a9..f551815 100644 --- a/components/YifanSelector.vue +++ b/components/YifanSelector.vue @@ -354,8 +354,7 @@ async function onPaymentConfirm(paymentData) { /* 网格包装 */ .grid-wrapper { - padding-bottom: 200rpx; /* 留出底部操作栏空间 */ - padding: 0 20rpx 200rpx; + padding: 0 20rpx 140rpx; /* 减少底部padding */ } /* 号码网格 - 调整为更合理的列数,适配不同屏幕 */ @@ -476,22 +475,22 @@ async function onPaymentConfirm(paymentData) { text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1); } -/* ============= 底部操作栏 - 高级重置 ============= */ +/* ============= 底部操作栏 - 对对碰风格胶囊浮动 ============= */ .action-bar { position: fixed; + left: 32rpx; + right: 32rpx; bottom: calc(40rpx + env(safe-area-inset-bottom)); - left: 30rpx; - right: 30rpx; background: rgba(255, 255, 255, 0.85); backdrop-filter: blur(30rpx); padding: 24rpx 40rpx; - box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12); border-radius: 999rpx; display: flex; flex-direction: row; justify-content: space-between; align-items: center; z-index: 100; + box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12); border: 1rpx solid rgba(255, 255, 255, 0.6); animation: slideUp 0.4s cubic-bezier(0.23, 1, 0.32, 1) backwards; } @@ -533,6 +532,10 @@ async function onPaymentConfirm(paymentData) { transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); border: none; + &::after { + border: none; + } + &:active { transform: scale(0.92); } diff --git a/components/activity/ActivityHeader.vue b/components/activity/ActivityHeader.vue new file mode 100644 index 0000000..7ef4d4b --- /dev/null +++ b/components/activity/ActivityHeader.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/components/activity/ActivityPageLayout.vue b/components/activity/ActivityPageLayout.vue new file mode 100644 index 0000000..317d5bf --- /dev/null +++ b/components/activity/ActivityPageLayout.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/components/activity/ActivityTabs.vue b/components/activity/ActivityTabs.vue new file mode 100644 index 0000000..f3e88fa --- /dev/null +++ b/components/activity/ActivityTabs.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/components/activity/RecordsList.vue b/components/activity/RecordsList.vue new file mode 100644 index 0000000..5b8a54c --- /dev/null +++ b/components/activity/RecordsList.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/components/activity/RewardsPopup.vue b/components/activity/RewardsPopup.vue new file mode 100644 index 0000000..1578142 --- /dev/null +++ b/components/activity/RewardsPopup.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/components/activity/RewardsPreview.vue b/components/activity/RewardsPreview.vue new file mode 100644 index 0000000..e049896 --- /dev/null +++ b/components/activity/RewardsPreview.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/components/activity/index.js b/components/activity/index.js new file mode 100644 index 0000000..6d6583d --- /dev/null +++ b/components/activity/index.js @@ -0,0 +1,10 @@ +/** + * Activity 组件统一导出 + */ + +export { default as ActivityPageLayout } from './ActivityPageLayout.vue' +export { default as ActivityHeader } from './ActivityHeader.vue' +export { default as ActivityTabs } from './ActivityTabs.vue' +export { default as RewardsPreview } from './RewardsPreview.vue' +export { default as RewardsPopup } from './RewardsPopup.vue' +export { default as RecordsList } from './RecordsList.vue' diff --git a/composables/index.js b/composables/index.js new file mode 100644 index 0000000..89d1f26 --- /dev/null +++ b/composables/index.js @@ -0,0 +1,8 @@ +/** + * Composables 统一导出 + */ + +export { useActivity } from './useActivity' +export { useIssues } from './useIssues' +export { useRewards } from './useRewards' +export { useRecords } from './useRecords' diff --git a/composables/useActivity.js b/composables/useActivity.js new file mode 100644 index 0000000..8854094 --- /dev/null +++ b/composables/useActivity.js @@ -0,0 +1,69 @@ +/** + * 活动数据管理 Composable + */ +import { ref, computed } from 'vue' +import { getActivityDetail } from '@/api/appUser' +import { cleanUrl } from '@/utils/format' +import { statusToText } from '@/utils/activity' + +/** + * 活动数据管理 + * @param {Ref} activityIdRef - 活动ID的响应式引用 + */ +export function useActivity(activityIdRef) { + const detail = ref({}) + const loading = ref(false) + + const coverUrl = computed(() => { + const d = detail.value || {} + return cleanUrl(d.image || d.banner || d.cover || '') + }) + + const statusText = computed(() => statusToText(detail.value?.status)) + + const pricePerDraw = computed(() => { + const cents = Number(detail.value?.price_draw || 0) + return cents / 100 + }) + + const activityName = computed(() => { + const d = detail.value || {} + return d.name || d.title || '' + }) + + const scheduledTime = computed(() => detail.value?.scheduled_time || detail.value?.scheduledTime || '') + + async function fetchDetail() { + const id = activityIdRef?.value || activityIdRef + if (!id) return + loading.value = true + try { + const data = await getActivityDetail(id) + detail.value = data || {} + } catch (e) { + console.error('fetchDetail error', e) + detail.value = {} + } finally { + loading.value = false + } + } + + function setNavigationTitle(fallback = '活动') { + const title = activityName.value || fallback + try { + uni.setNavigationBarTitle({ title }) + } catch (_) { } + } + + return { + detail, + loading, + coverUrl, + statusText, + pricePerDraw, + activityName, + scheduledTime, + fetchDetail, + setNavigationTitle + } +} diff --git a/composables/useIssues.js b/composables/useIssues.js new file mode 100644 index 0000000..0d6f587 --- /dev/null +++ b/composables/useIssues.js @@ -0,0 +1,89 @@ +/** + * 期数据管理 Composable + */ +import { ref, computed } from 'vue' +import { getActivityIssues } from '@/api/appUser' +import { normalizeIssues, pickLatestIssueId } from '@/utils/activity' + +/** + * 期数据管理 + * @param {Ref} activityIdRef - 活动ID的响应式引用 + */ +export function useIssues(activityIdRef) { + const issues = ref([]) + const selectedIssueIndex = ref(0) + const loading = ref(false) + + const currentIssueId = computed(() => { + const arr = issues.value || [] + const cur = arr[selectedIssueIndex.value] + return (cur && cur.id) || '' + }) + + const currentIssue = computed(() => { + const arr = issues.value || [] + return arr[selectedIssueIndex.value] || null + }) + + const currentIssueTitle = computed(() => { + const cur = currentIssue.value + if (!cur) return '-' + return cur.title || ('第' + (cur.no || '-') + '期') + }) + + const currentIssueStatusText = computed(() => { + const cur = currentIssue.value + return (cur && cur.status_text) || '' + }) + + async function fetchIssues() { + const id = activityIdRef?.value || activityIdRef + if (!id) return + loading.value = true + try { + const data = await getActivityIssues(id) + issues.value = normalizeIssues(data) + const latestId = pickLatestIssueId(issues.value) + setSelectedById(latestId) + } catch (e) { + console.error('fetchIssues error', e) + issues.value = [] + } finally { + loading.value = false + } + } + + function setSelectedById(id) { + const arr = issues.value || [] + const idx = Math.max(0, arr.findIndex(x => x && x.id === id)) + selectedIssueIndex.value = idx + } + + function prevIssue() { + const arr = issues.value || [] + if (!arr.length) return + const next = Math.max(0, Number(selectedIssueIndex.value || 0) - 1) + selectedIssueIndex.value = next + } + + function nextIssue() { + const arr = issues.value || [] + if (!arr.length) return + const next = Math.min(arr.length - 1, Number(selectedIssueIndex.value || 0) + 1) + selectedIssueIndex.value = next + } + + return { + issues, + selectedIssueIndex, + loading, + currentIssueId, + currentIssue, + currentIssueTitle, + currentIssueStatusText, + fetchIssues, + setSelectedById, + prevIssue, + nextIssue + } +} diff --git a/composables/useRecords.js b/composables/useRecords.js new file mode 100644 index 0000000..913aa11 --- /dev/null +++ b/composables/useRecords.js @@ -0,0 +1,65 @@ +/** + * 购买记录管理 Composable + */ +import { ref } from 'vue' +import { getIssueDrawLogs } from '@/api/appUser' + +/** + * 购买记录管理 + */ +export function useRecords() { + const winRecords = ref([]) + const loading = ref(false) + + /** + * 获取购买记录 + * @param {string} activityId - 活动ID + * @param {string} issueId - 期ID + */ + async function fetchWinRecords(activityId, issueId) { + if (!activityId || !issueId) return + + loading.value = true + try { + const res = await getIssueDrawLogs(activityId, issueId) + const list = (res && res.list) || (Array.isArray(res) ? res : []) + + // 聚合同一奖品的记录 + const aggregate = {} + list.forEach(it => { + const key = it.reward_id || it.id + if (!aggregate[key]) { + aggregate[key] = { + id: key, + title: it.reward_name || it.title || it.name || '-', + image: it.reward_image || it.image || '', + count: 0 + } + } + aggregate[key].count += 1 + }) + + const total = list.length || 1 + winRecords.value = Object.values(aggregate).map(it => ({ + ...it, + percent: ((it.count / total) * 100).toFixed(1) + })) + } catch (e) { + console.error('fetchWinRecords error', e) + winRecords.value = [] + } finally { + loading.value = false + } + } + + function clearRecords() { + winRecords.value = [] + } + + return { + winRecords, + loading, + fetchWinRecords, + clearRecords + } +} diff --git a/composables/useRewards.js b/composables/useRewards.js new file mode 100644 index 0000000..6e89d36 --- /dev/null +++ b/composables/useRewards.js @@ -0,0 +1,107 @@ +/** + * 奖励数据管理 Composable + */ +import { ref, computed, watch } from 'vue' +import { getActivityIssueRewards } from '@/api/appUser' +import { normalizeRewards, groupRewardsByLevel } from '@/utils/activity' +import { cleanUrl } from '@/utils/format' +import { getRewardCacheItem, setRewardCache, isFresh } from '@/utils/cache' + +/** + * 奖励数据管理 + * @param {Ref} activityIdRef - 活动ID的响应式引用 + * @param {Ref} currentIssueIdRef - 当前期ID的响应式引用 + */ +export function useRewards(activityIdRef, currentIssueIdRef) { + const rewardsMap = ref({}) + const loading = ref(false) + + const currentIssueRewards = computed(() => { + const issueId = currentIssueIdRef?.value || currentIssueIdRef + const m = rewardsMap.value || {} + return (issueId && Array.isArray(m[issueId])) ? m[issueId] : [] + }) + + const rewardGroups = computed(() => { + return groupRewardsByLevel(currentIssueRewards.value) + }) + + /** + * 获取多期的奖励数据(带缓存) + * @param {Array} issueList - 期列表 + */ + async function fetchRewardsForIssues(issueList) { + const activityId = activityIdRef?.value || activityIdRef + if (!activityId) return + + const list = issueList || [] + const toFetch = [] + + // 先从缓存加载 + list.forEach(issue => { + const cached = getRewardCacheItem(activityId, issue.id) + if (cached) { + rewardsMap.value = { ...rewardsMap.value, [issue.id]: cached } + } else { + toFetch.push(issue) + } + }) + + if (!toFetch.length) return + + loading.value = true + try { + const promises = toFetch.map(it => getActivityIssueRewards(activityId, it.id)) + const results = await Promise.allSettled(promises) + + results.forEach((res, i) => { + const issueId = toFetch[i]?.id + if (!issueId) return + const value = res.status === 'fulfilled' ? normalizeRewards(res.value, cleanUrl) : [] + rewardsMap.value = { ...rewardsMap.value, [issueId]: value } + setRewardCache(activityId, issueId, value) + }) + } catch (e) { + console.error('fetchRewardsForIssues error', e) + } finally { + loading.value = false + } + } + + /** + * 获取单期的奖励数据 + * @param {string} issueId - 期ID + */ + async function fetchRewardsForIssue(issueId) { + const activityId = activityIdRef?.value || activityIdRef + if (!activityId || !issueId) return + + // 先检查缓存 + const cached = getRewardCacheItem(activityId, issueId) + if (cached) { + rewardsMap.value = { ...rewardsMap.value, [issueId]: cached } + return + } + + loading.value = true + try { + const res = await getActivityIssueRewards(activityId, issueId) + const value = normalizeRewards(res, cleanUrl) + rewardsMap.value = { ...rewardsMap.value, [issueId]: value } + setRewardCache(activityId, issueId, value) + } catch (e) { + console.error('fetchRewardsForIssue error', e) + } finally { + loading.value = false + } + } + + return { + rewardsMap, + loading, + currentIssueRewards, + rewardGroups, + fetchRewardsForIssues, + fetchRewardsForIssue + } +} diff --git a/docs/代码重构分析/ALIGNMENT_代码冗余分析.md b/docs/代码重构分析/ALIGNMENT_代码冗余分析.md new file mode 100644 index 0000000..436bd26 --- /dev/null +++ b/docs/代码重构分析/ALIGNMENT_代码冗余分析.md @@ -0,0 +1,208 @@ +# bindbox-mini 代码冗余分析 + +## 项目概述 + +bindbox-mini 是一个基于 uni-app 的微信小程序项目,主要实现盲盒/抽赏类活动功能。 + +### 技术栈 +- 框架:uni-app (Vue 3 Composition API) +- 样式:SCSS +- 状态管理:Vue ref/computed + +### 核心页面 +| 页面 | 路径 | 行数 | 功能描述 | +|------|------|------|----------| +| 一番赏 | `pages/activity/yifanshang/index.vue` | 1229 | 格位选择抽奖 | +| 对对碰 | `pages/activity/duiduipeng/index.vue` | 2291 | 配对游戏 | +| 无限赏 | `pages/activity/wuxianshang/index.vue` | 1559 | 多次抽奖 | +| 扭蛋(啪嗒) | `pages/activity/pata/index.vue` | 399 | 入口页面 | + +--- + +## 🔴 已识别的冗余问题 + +### 1. 模板结构重复 + +三个主要活动页面(yifanshang/duiduipeng/wuxianshang)共享**几乎相同的页面布局结构**: + +```vue + + + + + + + + + + + + + + + +``` + +**冗余程度**:约100-150行相似模板代码 × 3个页面 = ~400行冗余 + +--- + +### 2. 工具函数重复 + +以下函数在多个页面中**完全重复定义**: + +| 函数名 | 出现位置 | 功能 | +|--------|----------|------| +| `cleanUrl(u)` | yifanshang, duiduipeng, wuxianshang | 清理URL字符串 | +| `truthy(v)` | yifanshang, duiduipeng, wuxianshang | 判断真值 | +| `detectBoss(i)` | yifanshang, duiduipeng, wuxianshang | 检测BOSS奖 | +| `unwrap(list)` | yifanshang, duiduipeng, wuxianshang | 解包API返回 | +| `normalizeIssues(list)` | yifanshang, duiduipeng, wuxianshang | 标准化期数据 | +| `normalizeRewards(list)` | yifanshang, duiduipeng, wuxianshang | 标准化奖励数据 | +| `statusToText(s)` | yifanshang, duiduipeng, wuxianshang | 状态转文本 | +| `formatPercent(v)` | yifanshang, duiduipeng, wuxianshang | 格式化百分比 | +| `levelToAlpha(level)` | duiduipeng, wuxianshang | 等级数字转字母 | +| `isFresh(ts)` | yifanshang, duiduipeng, wuxianshang | 判断缓存新鲜度 | +| `getRewardCache()` | yifanshang, duiduipeng, wuxianshang | 获取奖励缓存 | +| `pickLatestIssueId(list)` | yifanshang, duiduipeng, wuxianshang | 查找最新期ID | +| `setSelectedById(id)` | yifanshang, duiduipeng, wuxianshang | 设置选中期 | +| `prevIssue()` / `nextIssue()` | yifanshang, duiduipeng, wuxianshang | 期数切换 | + +**冗余程度**:约200-300行工具函数 × 3个页面 = ~700行冗余 + +--- + +### 3. API调用逻辑重复 + +以下API调用模式在多个页面中重复: + +```javascript +// fetchDetail - 获取活动详情(3处重复) +async function fetchDetail(id) { + const data = await getActivityDetail(id) + detail.value = data || {} + statusText.value = statusToText(detail.value.status) + // ... +} + +// fetchIssues - 获取期列表(3处重复) +async function fetchIssues(id) { + const data = await getActivityIssues(id) + issues.value = normalizeIssues(data) + // ... +} + +// fetchRewardsForIssues - 获取奖励(3处重复) +async function fetchRewardsForIssues(activityId) { + // ~50行相似代码 +} + +// fetchWinRecords - 获取购买记录(3处重复) +async function fetchWinRecords(actId, issId) { + // ~30行相似代码 +} +``` + +**冗余程度**:约150-200行API调用代码 × 3个页面 = ~500行冗余 + +--- + +### 4. 样式代码重复 + +以下SCSS样式在三个页面中几乎**完全相同**: + +```scss +// 基础布局(~80行) +.page-wrapper, .bg-decoration, .orb, @keyframes float +.page-bg, .bg-image, .bg-mask, .main-scroll + +// 头部卡片(~100行) +.header-card, .header-cover, .header-info, .header-title +.header-price-row, .price-symbol, .price-num, .price-unit +.header-tags, .tag-item, .header-actions, .action-btn, .action-icon + +// 板块容器(~50行) +.section-container, .section-header, .section-title, .section-more + +// Tabs切换(~50行) +.modern-tabs, .tab-item, .active-dot + +// 奖池预览(~80行) +.preview-scroll, .preview-item, .preview-img, .preview-name, .prize-tag + +// 购买记录(~60行) +.records-list, .record-item, .record-img, .record-info + +// 弹窗样式(~100行) +.rewards-overlay, .rewards-mask, .rewards-panel, .rewards-header, .rewards-list +``` + +**冗余程度**:约500-600行样式代码 × 3个页面 = ~1500行冗余 + +--- + +### 5. 状态管理重复 + +以下响应式状态在多个页面中重复定义: + +```javascript +// 每个页面都有类似的状态定义 +const detail = ref({}) +const issues = ref([]) +const rewardsMap = ref({}) +const currentIssueId = ref('') +const selectedIssueIndex = ref(0) +const activityId = ref('') +const tabActive = ref('pool') +const winRecords = ref([]) +const rewardsVisible = ref(false) +// ... +``` + +--- + +## 📊 冗余统计汇总 + +| 类别 | 估算冗余行数 | 占比 | +|------|-------------|------| +| 模板结构 | ~400行 | 13% | +| 工具函数 | ~700行 | 22% | +| API调用逻辑 | ~500行 | 16% | +| SCSS样式 | ~1500行 | 48% | +| **合计** | **~3100行** | **100%** | + +当前三个主要活动页面总计约 **5079行**(1229+2291+1559),冗余代码约占 **61%**。 + +--- + +## ❓ 需要确认的问题 + +1. **重构方向**:是希望进行完整的组件化重构,还是仅提取共用工具函数? + +2. **优先级**: + - 先处理工具函数冗余?(影响最小,风险最低) + - 先处理模板/组件冗余?(收益最大,但改动较大) + - 先处理样式冗余?(提取公共样式文件) + +3. **兼容性考虑**:是否需要保留现有的页面独立性(便于后续定制化)? + +4. **测试策略**:目前项目有自动化测试吗?重构后如何验证功能正确性? + +--- + +## 🎯 初步建议 + +### 方案A:渐进式重构(推荐) + +1. **第一步**:提取共用工具函数到 `utils/activity.js` +2. **第二步**:提取共用样式到 `styles/activity-common.scss` +3. **第三步**:创建共用组件(ActivityHeader, ActivityTabs, RewardsPopup) +4. **第四步**:重构各活动页面使用共用组件 + +### 方案B:完全组件化 + +创建通用活动页面框架 `ActivityPageLayout.vue`,各玩法页面只需实现差异化部分。 + +--- + +*文档创建时间:2025-12-25* diff --git a/docs/代码重构分析/DESIGN_组件化重构.md b/docs/代码重构分析/DESIGN_组件化重构.md new file mode 100644 index 0000000..add1d5e --- /dev/null +++ b/docs/代码重构分析/DESIGN_组件化重构.md @@ -0,0 +1,323 @@ +# bindbox-mini 组件化重构设计 + +## 架构目标 + +将三个活动页面(yifanshang/duiduipeng/wuxianshang)共约5079行代码减少至约2500行,消除61%的冗余。 + +--- + +## 架构设计图 + +```mermaid +graph TB + subgraph Utils[工具层 utils/] + A1[activity.js
活动相关工具函数] + A2[format.js
格式化工具] + A3[cache.js
缓存管理] + end + + subgraph Composables[组合式函数 composables/] + B1[useActivity.js
活动数据管理] + B2[useIssues.js
期数据管理] + B3[useRewards.js
奖励数据管理] + B4[usePayment.js
支付流程] + end + + subgraph Components[组件层 components/] + subgraph Layout[布局组件] + C1[ActivityPageLayout.vue
活动页面框架] + C2[ActivityHeader.vue
头部卡片] + end + subgraph Biz[业务组件] + C3[ActivityTabs.vue
Tab切换] + C4[RewardsPopup.vue
奖品弹窗] + C5[RecordsList.vue
购买记录] + C6[RewardsPreview.vue
奖池预览] + end + subgraph Existing[已有组件] + C7[PaymentPopup.vue] + C8[FlipGrid.vue] + end + end + + subgraph Pages[页面层 pages/activity/] + D1[yifanshang - 选号+专属业务] + D2[duiduipeng - 对对碰游戏+专属业务] + D3[wuxianshang - 多档抽奖+专属业务] + end + + Utils --> Composables + Composables --> Pages + Components --> Pages +``` + +--- + +## 详细模块设计 + +### 1. 工具函数层 `utils/` + +#### `utils/activity.js` - 活动相关工具 [NEW] + +```javascript +// 数据标准化 +export function unwrap(list) { /* ... */ } +export function normalizeIssues(list) { /* ... */ } +export function normalizeRewards(list) { /* ... */ } + +// 值判断 +export function truthy(v) { /* ... */ } +export function detectBoss(i) { /* ... */ } +export function levelToAlpha(level) { /* ... */ } + +// 状态转换 +export function statusToText(s) { /* ... */ } +``` + +#### `utils/format.js` - 格式化工具 [NEW] + +```javascript +export function cleanUrl(u) { /* ... */ } +export function formatPercent(v) { /* ... */ } +export function formatDateTime(v) { /* ... */ } +export function formatPrice(cents) { /* ... */ } +``` + +#### `utils/cache.js` - 缓存管理 [NEW] + +```javascript +export function isFresh(ts, ttl = 24 * 60 * 60 * 1000) { /* ... */ } +export function getRewardCache() { /* ... */ } +export function setRewardCache(activityId, issueId, value) { /* ... */ } +``` + +--- + +### 2. 组合式函数层 `composables/` + +#### `composables/useActivity.js` [NEW] + +```javascript +export function useActivity(activityId) { + const detail = ref({}) + const coverUrl = computed(() => cleanUrl(detail.value?.image || detail.value?.banner || '')) + const statusText = computed(() => statusToText(detail.value?.status)) + const pricePerDraw = computed(() => (Number(detail.value?.price_draw || 0) / 100)) + + async function fetchDetail() { /* ... */ } + + return { detail, coverUrl, statusText, pricePerDraw, fetchDetail } +} +``` + +#### `composables/useIssues.js` [NEW] + +```javascript +export function useIssues(activityId) { + const issues = ref([]) + const selectedIssueIndex = ref(0) + const currentIssueId = computed(() => issues.value[selectedIssueIndex.value]?.id || '') + const currentIssueTitle = computed(() => /* ... */) + + async function fetchIssues() { /* ... */ } + function prevIssue() { /* ... */ } + function nextIssue() { /* ... */ } + function setSelectedById(id) { /* ... */ } + + return { issues, selectedIssueIndex, currentIssueId, currentIssueTitle, fetchIssues, prevIssue, nextIssue, setSelectedById } +} +``` + +#### `composables/useRewards.js` [NEW] + +```javascript +export function useRewards(activityId, currentIssueId) { + const rewardsMap = ref({}) + const currentIssueRewards = computed(() => rewardsMap.value[currentIssueId.value] || []) + const rewardGroups = computed(() => /* 按level分组 */) + + async function fetchRewardsForIssues(issueList) { /* 带缓存 */ } + + return { rewardsMap, currentIssueRewards, rewardGroups, fetchRewardsForIssues } +} +``` + +#### `composables/useRecords.js` [NEW] + +```javascript +export function useRecords() { + const winRecords = ref([]) + + async function fetchWinRecords(activityId, issueId) { /* ... */ } + + return { winRecords, fetchWinRecords } +} +``` + +--- + +### 3. 组件层 `components/` + +#### `ActivityPageLayout.vue` [NEW] - 页面框架组件 + +Props: +- `coverUrl: String` - 背景图URL + +Slots: +- `header` - 头部卡片区域 +- `content` - 主要内容(tabs等) +- `footer` - 底部操作栏 +- `modals` - 弹窗区域 + +#### `ActivityHeader.vue` [NEW] - 头部卡片 + +Props: +- `title: String` +- `price: Number` (分) +- `priceUnit: String` - 价格单位(如"/发"、"/次") +- `coverUrl: String` +- `tags: Array` +- `scheduledTime: String` (可选) + +Events: +- `@show-rules` +- `@go-cabinet` + +#### `ActivityTabs.vue` [NEW] - Tab切换 + +Props: +- `modelValue: String` - 当前tab ('pool' | 'records') +- `tabs: Array<{key, label}>` + +Events: +- `@update:modelValue` + +#### `RewardsPreview.vue` [NEW] - 奖池预览 + +Props: +- `rewards: Array` +- `grouped: Boolean` - 是否按等级分组显示 + +#### `RewardsPopup.vue` [NEW] - 奖品弹窗 + +Props: +- `visible: Boolean` +- `title: String` +- `rewardGroups: Array` - 按等级分组的奖励 + +Events: +- `@update:visible` + +#### `RecordsList.vue` [NEW] - 购买记录列表 + +Props: +- `records: Array` +- `emptyText: String` + +--- + +### 4. 样式层 `styles/` + +#### `styles/activity-common.scss` [NEW] + +提取共用样式(约600行): +- 页面布局:`.page-wrapper`, `.bg-decoration`, `.orb`, `@keyframes float` +- 背景处理:`.page-bg`, `.bg-image`, `.bg-mask` +- 入场动画:`.animate-enter`, `.stagger-*` +- 头部卡片样式(可在ActivityHeader组件内联) +- 板块容器:`.section-container`, `.section-header` +- Tabs样式(可在ActivityTabs组件内联) +- 预览列表:`.preview-scroll`, `.preview-item` +- 记录列表:`.records-list`, `.record-item` +- 弹窗样式(可在RewardsPopup组件内联) + +--- + +## 重构后页面结构示例 + +### yifanshang/index.vue (预计约400行→优化后) + +```vue + + + +``` + +--- + +## 文件变更清单 + +### 新增文件 + +| 文件路径 | 行数估算 | 说明 | +|----------|---------|------| +| `utils/activity.js` | ~80 | 活动工具函数 | +| `utils/format.js` | ~50 | 格式化工具 | +| `utils/cache.js` | ~40 | 缓存管理 | +| `composables/useActivity.js` | ~50 | 活动数据composable | +| `composables/useIssues.js` | ~80 | 期数据composable | +| `composables/useRewards.js` | ~80 | 奖励数据composable | +| `composables/useRecords.js` | ~40 | 记录composable | +| `components/ActivityPageLayout.vue` | ~150 | 页面框架 | +| `components/ActivityHeader.vue` | ~200 | 头部卡片 | +| `components/ActivityTabs.vue` | ~100 | Tab切换 | +| `components/RewardsPreview.vue` | ~120 | 奖池预览 | +| `components/RewardsPopup.vue` | ~150 | 奖品弹窗 | +| `components/RecordsList.vue` | ~80 | 记录列表 | +| **小计** | **~1220** | | + +### 修改文件 + +| 文件路径 | 原行数 | 预计行数 | 变化 | +|----------|-------|---------|------| +| `yifanshang/index.vue` | 1229 | ~400 | -829 | +| `duiduipeng/index.vue` | 2291 | ~800 | -1491 | +| `wuxianshang/index.vue` | 1559 | ~500 | -1059 | +| **小计** | **5079** | **~1700** | **-3379** | + +### 净变化 + +- 新增:~1220行 +- 删除:~3379行 +- **净减少:~2159行(42%)** + +--- + +*设计文档创建时间:2025-12-25* diff --git a/pages/activity/duiduipeng/index.vue b/pages/activity/duiduipeng/index.vue index ca95bcd..3f64617 100644 --- a/pages/activity/duiduipeng/index.vue +++ b/pages/activity/duiduipeng/index.vue @@ -1,232 +1,156 @@ diff --git a/pages/activity/yifanshang/index.vue b/pages/activity/yifanshang/index.vue index 4788479..d9fa3e3 100644 --- a/pages/activity/yifanshang/index.vue +++ b/pages/activity/yifanshang/index.vue @@ -1,83 +1,48 @@ diff --git a/utils/activity.js b/utils/activity.js new file mode 100644 index 0000000..878d1c0 --- /dev/null +++ b/utils/activity.js @@ -0,0 +1,149 @@ +/** + * 活动相关工具函数 + * 从 yifanshang/duiduipeng/wuxianshang 页面中提取的公共逻辑 + */ + +/** + * 解包API返回的数据 + * @param {any} list - API返回的数据 + * @returns {Array} 数组 + */ +export function unwrap(list) { + if (Array.isArray(list)) return list + const obj = list || {} + const data = obj.data || {} + const arr = obj.list || obj.items || data.list || data.items || data + return Array.isArray(arr) ? arr : [] +} + +/** + * 判断真值(支持多种格式) + * @param {any} v - 待判断的值 + * @returns {boolean} + */ +export function truthy(v) { + if (typeof v === 'boolean') return v + const s = String(v || '').trim().toLowerCase() + if (!s) return false + return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === '是' || s === 'boss是真的' || s === 'boss' || s === '大boss' +} + +/** + * 检测是否为BOSS奖 + * @param {Object} item - 奖品对象 + * @returns {boolean} + */ +export function detectBoss(item) { + const i = item || {} + return truthy(i.is_boss) || truthy(i.boss) || truthy(i.isBoss) || truthy(i.boss_true) || truthy(i.boss_is_true) || truthy(i.bossText) || truthy(i.tag) +} + +/** + * 等级数字转字母 (1 -> A, 2 -> B, ...) + * @param {number|string} level - 等级 + * @returns {string} + */ +export function levelToAlpha(level) { + if (level === 'BOSS') return 'BOSS' + const n = Number(level) + if (isNaN(n) || n <= 0) return String(level || '赏') + return String.fromCharCode(64 + n) +} + +/** + * 状态转文本 + * @param {number} status - 状态码 + * @returns {string} + */ +export function statusToText(status) { + if (status === 1) return '进行中' + if (status === 0) return '未开始' + if (status === 2) return '已结束' + return String(status || '') +} + +/** + * 标准化期列表数据 + * @param {any} list - API返回的期列表 + * @returns {Array} + */ +export function normalizeIssues(list) { + const arr = unwrap(list) + return arr.map((i, idx) => ({ + id: i.id ?? String(idx), + title: i.title ?? i.name ?? '', + no: i.no ?? i.index ?? i.issue_no ?? i.issue_number ?? null, + status_text: i.status_text ?? (i.status === 1 ? '进行中' : i.status === 0 ? '未开始' : i.status === 2 ? '已结束' : '') + })) +} + +/** + * 标准化奖励列表数据 + * @param {any} list - API返回的奖励列表 + * @param {Function} cleanUrl - URL清理函数 + * @returns {Array} + */ +export function normalizeRewards(list, cleanUrl = (u) => u) { + const arr = unwrap(list) + const items = arr.map((i, idx) => ({ + id: i.product_id ?? i.id ?? String(idx), + title: i.name ?? i.title ?? '', + image: cleanUrl(i.product_image ?? i.image ?? i.img ?? i.pic ?? i.banner ?? ''), + weight: Number(i.weight) || 0, + boss: detectBoss(i), + level: levelToAlpha(i.prize_level ?? i.level ?? (detectBoss(i) ? 'BOSS' : '赏')) + })) + const total = items.reduce((acc, it) => acc + (it.weight > 0 ? it.weight : 0), 0) + const enriched = items.map(it => ({ + ...it, + percent: total > 0 ? Math.round((it.weight / total) * 1000) / 10 : 0 + })) + enriched.sort((a, b) => (b.percent - a.percent)) + return enriched +} + +/** + * 查找最新的期ID + * @param {Array} list - 期列表 + * @returns {string} + */ +export function pickLatestIssueId(list) { + const arr = Array.isArray(list) ? list : [] + let latest = arr[arr.length - 1] && arr[arr.length - 1].id + let maxNo = -Infinity + arr.forEach(i => { + const n = Number(i.no) + if (!Number.isNaN(n) && Number.isFinite(n) && n > maxNo) { + maxNo = n + latest = i.id + } + }) + return latest || (arr[0] && arr[0].id) || '' +} + +/** + * 按等级分组奖励 + * @param {Array} rewards - 奖励列表 + * @returns {Array} 分组后的奖励 + */ +export function groupRewardsByLevel(rewards) { + const groups = {} + ; (rewards || []).forEach(item => { + const level = item.level || '赏' + if (!groups[level]) groups[level] = [] + groups[level].push(item) + }) + return Object.keys(groups).sort((a, b) => { + if (a === 'BOSS') return -1 + if (b === 'BOSS') return 1 + return a.localeCompare(b) + }).map(key => { + const levelRewards = groups[key] + const total = levelRewards.reduce((sum, item) => sum + (Number(item.percent) || 0), 0) + return { + level: key, + rewards: levelRewards, + totalPercent: total.toFixed(1) + } + }) +} diff --git a/utils/cache.js b/utils/cache.js new file mode 100644 index 0000000..4592ae9 --- /dev/null +++ b/utils/cache.js @@ -0,0 +1,145 @@ +/** + * 缓存管理工具 + */ + +const REWARD_CACHE_KEY = 'reward_cache_v1' +const MATCHING_GAME_CACHE_KEY = 'matching_game_cache_v1' + +/** + * 判断缓存是否新鲜 + * @param {number} timestamp - 缓存时间戳 + * @param {number} ttl - 有效期(毫秒),默认24小时 + * @returns {boolean} + */ +export function isFresh(timestamp, ttl = 24 * 60 * 60 * 1000) { + const now = Date.now() + const v = Number(timestamp || 0) + return now - v < ttl +} + +/** + * 获取奖励缓存 + * @returns {Object} + */ +export function getRewardCache() { + const obj = uni.getStorageSync(REWARD_CACHE_KEY) || {} + return typeof obj === 'object' && obj ? obj : {} +} + +/** + * 设置奖励缓存 + * @param {string} activityId - 活动ID + * @param {string} issueId - 期ID + * @param {any} value - 缓存值 + */ +export function setRewardCache(activityId, issueId, value) { + const cache = getRewardCache() + const act = cache[activityId] || {} + act[issueId] = { value, ts: Date.now() } + cache[activityId] = act + uni.setStorageSync(REWARD_CACHE_KEY, cache) +} + +/** + * 获取奖励缓存项 + * @param {string} activityId - 活动ID + * @param {string} issueId - 期ID + * @returns {any|null} + */ +export function getRewardCacheItem(activityId, issueId) { + const cache = getRewardCache() + const act = cache[activityId] || {} + const c = act[issueId] + if (c && isFresh(c.ts) && Array.isArray(c.value)) { + return c.value + } + return null +} + +/** + * 获取对对碰游戏缓存 + * @returns {Object} + */ +export function getMatchingGameCache() { + const obj = uni.getStorageSync(MATCHING_GAME_CACHE_KEY) || {} + return typeof obj === 'object' && obj ? obj : {} +} + +/** + * 读取对对碰游戏缓存项 + * @param {string} activityId - 活动ID + * @param {string} issueId - 期ID + * @returns {Object|null} + */ +export function readMatchingGameCacheEntry(activityId, issueId) { + const activityKey = String(activityId || '') + const issueKey = String(issueId || '') + if (!activityKey || !issueKey) return null + const cache = getMatchingGameCache() + const act = cache[activityKey] || {} + const entry = act && act[issueKey] + const ok = entry && typeof entry === 'object' && entry.game_id + return ok ? entry : null +} + +/** + * 写入对对碰游戏缓存项 + * @param {string} activityId - 活动ID + * @param {string} issueId - 期ID + * @param {Object} entry - 缓存数据 + */ +export function writeMatchingGameCacheEntry(activityId, issueId, entry) { + const activityKey = String(activityId || '') + const issueKey = String(issueId || '') + if (!activityKey || !issueKey) return + const cache = getMatchingGameCache() + const act = (cache[activityKey] && typeof cache[activityKey] === 'object') ? cache[activityKey] : {} + act[issueKey] = entry + cache[activityKey] = act + uni.setStorageSync(MATCHING_GAME_CACHE_KEY, cache) +} + +/** + * 清除对对碰游戏缓存项 + * @param {string} activityId - 活动ID + * @param {string} issueId - 期ID + */ +export function clearMatchingGameCacheEntry(activityId, issueId) { + const activityKey = String(activityId || '') + const issueKey = String(issueId || '') + const cache = getMatchingGameCache() + const act = cache[activityKey] + if (!act || typeof act !== 'object') return + if (act[issueKey] !== undefined) delete act[issueKey] + if (Object.keys(act).length === 0) delete cache[activityKey] + else cache[activityKey] = act + uni.setStorageSync(MATCHING_GAME_CACHE_KEY, cache) +} + +/** + * 查找最新的对对碰游戏缓存 + * @param {string} activityId - 活动ID + * @returns {Object|null} + */ +export function findLatestMatchingGameCacheEntry(activityId) { + const activityKey = String(activityId || '') + if (!activityKey) return null + const cache = getMatchingGameCache() + const act = cache[activityKey] + if (!act || typeof act !== 'object') return null + let bestIssueId = '' + let bestEntry = null + let bestTs = -Infinity + Object.keys(act).forEach(issueId => { + const entry = act[issueId] + if (!entry || typeof entry !== 'object' || !entry.game_id) return + const ts = Number(entry.ts || 0) + if (!bestEntry || ts > bestTs) { + bestTs = ts + bestIssueId = issueId + bestEntry = entry + } + }) + if (!bestEntry) return null + return { issue_id: bestIssueId, entry: bestEntry } +} diff --git a/utils/format.js b/utils/format.js new file mode 100644 index 0000000..9f6e535 --- /dev/null +++ b/utils/format.js @@ -0,0 +1,76 @@ +/** + * 格式化工具函数 + */ + +/** + * 清理URL字符串 + * @param {string} url - 原始URL + * @returns {string} 清理后的URL + */ +export function cleanUrl(url) { + const s = String(url || '').trim() + const m = s.match(/https?:\/\/[^\s'"`]+/) + if (m && m[0]) return m[0] + return s.replace(/[`'"]/g, '').trim() +} + +/** + * 格式化百分比 + * @param {number} value - 百分比值 + * @returns {string} + */ +export function formatPercent(value) { + const n = Number(value) + if (!Number.isFinite(n)) return '0%' + return `${n}%` +} + +/** + * 格式化日期时间 + * @param {string|number|Date} value - 日期值 + * @returns {string} + */ +export function formatDateTime(value) { + const s = String(value || '').trim() + if (!s) return '' + const d = new Date(s) + if (Number.isNaN(d.getTime())) return s + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + const hh = String(d.getHours()).padStart(2, '0') + const mm = String(d.getMinutes()).padStart(2, '0') + const ss = String(d.getSeconds()).padStart(2, '0') + return `${y}-${m}-${day} ${hh}:${mm}:${ss}` +} + +/** + * 格式化价格(分转元) + * @param {number} cents - 分 + * @param {number} decimals - 小数位数 + * @returns {string} + */ +export function formatPrice(cents, decimals = 2) { + const yuan = Number(cents || 0) / 100 + return yuan.toFixed(decimals) +} + +/** + * 解析时间为毫秒戳 + * @param {any} value - 时间值 + * @returns {number|null} + */ +export function parseTimeMs(value) { + if (value === undefined || value === null || value === '') return null + if (typeof value === 'number') { + if (!Number.isFinite(value)) return null + return value < 1e12 ? value * 1000 : value + } + const s = String(value).trim() + if (!s) return null + const asNum = Number(s) + if (Number.isFinite(asNum)) return asNum < 1e12 ? asNum * 1000 : asNum + const d = new Date(s) + if (Number.isNaN(d.getTime())) return null + return d.getTime() +}