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 @@
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ item.title }}
+
+ x{{ item.count }}
+ {{ item.percent }}%
+
+
+
+
+
+ 📝
+ {{ emptyText }}
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.title || '-' }}
+ BOSS
+
+ 单项概率 {{ formatPercent(item.percent) }}
+
+
+
+
+ {{ emptyText }}
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+ {{ item.boss ? 'BOSS' : group.level }}
+
+ {{ item.title }}
+
+
+
+
+
+
+
+
+
+ {{ item.boss ? 'BOSS' : (item.level || '赏') }}
+
+ {{ item.title }}
+
+
+
+
+
+
+ 📭
+ {{ emptyText }}
+
+
+
+
+
+
+
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 @@
-
-
-
-
+
+
+
+
+
+
+
+
+ 加载中...
+ 暂无可选卡牌类型
+
+
+ selectCardType(it)"
+ >
+
+ {{ it.name }}
+ ×{{ it.quantity }}
+
+
+
-
-
-
-
+
+
-
-
+
+
-
-
- 加载中...
- 暂无可选卡牌类型
-
-
- selectCardType(it)"
- >
-
- {{ it.name }}
- ×{{ it.quantity }}
-
-
-
-
-
-
-
-
-
- 本机奖池
-
-
-
- 购买记录
-
-
-
-
-
-
-
-
-
-
-
- {{ item.boss ? 'BOSS' : group.level }}
-
- {{ item.title }}
-
-
-
-
-
- 📭
- 暂无奖励配置
-
-
-
-
-
-
-
-
- {{ it.title }}
-
- x{{ it.count }}
-
-
-
-
-
- 📝
- 暂无购买记录
-
-
-
+
+
-
-
-
- ¥
- {{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}
- /次
-
-
- 继续游戏
-
-
- 立即参与
-
-
-
-
-
-
-
-
-
-
-
- 加载中...
- {{ gameError }}
-
-
-
- 总对数:{{ totalPairs }} 摸牌机会:{{ chance }}
-
- 牌组剩余 {{ deckRemaining }}
- 位置 {{ selectedPositionText }}
- ID {{ gameIdText }}
-
-
+
+
+
+
+ ¥
+ {{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}
+ /次
-
- onCellTap(cell)"
- >
-
-
- {{ cell.type }}
-
+
+ 继续游戏
+
+
+ 立即参与
+
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
- {{ item.title || '-' }}
- BOSS
+
+
+
+
+
+
+
+ 加载中...
+ {{ gameError }}
+
+
+
+ 总对数:{{ totalPairs }} 摸牌机会:{{ chance }}
+
+ 牌组剩余 {{ deckRemaining }}
+ 位置 {{ selectedPositionText }}
+ ID {{ gameIdText }}
+
+
+
+
+ onCellTap(cell)"
+ >
+
+
+ {{ cell.type }}
- 单项概率 {{ formatPercent(item.percent) }}
+
+
+
+
- 暂无奖品数据
-
-
-
+
-
+
+
+
+
+
+
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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
- {{ item.boss ? 'BOSS' : (item.grade || '赏') }}
-
- {{ item.title }}
-
-
-
+
+
+
+
+
+
+
+
-
+
@@ -96,120 +61,91 @@
/>
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ item.title || '-' }}
- BOSS
-
- 概率 {{ formatPercent(item.percent) }}
-
+
+
+
+
+
+
+
- 暂无奖品数据
-
-
-
-
+
+
+
+
+
+
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()
+}