topfans/frontend/pages/square/DESIGN.md
2026-04-12 21:53:08 +08:00

714 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `square.vue` 重构设计文档
## 一、背景
- **文件**`frontend/pages/square/square.vue`1196 行)
- **技术栈**uni-app + Vue 3 Composition API`<script setup>`
- **目标**:功能逻辑不变,优化首屏性能、响应速度,修复 iOS/Android 兼容性问题
---
## 二、旧版问题汇总
### 2.1 性能问题
| # | 问题 | 影响 |
|---|------|------|
| P1 | 无图片懒加载72 个 cabin icon + 背景图全部立即加载 | 首屏慢、内存高 |
| P2 | `buildVisibleCabins` 每次重建整个数组340次循环 | 翻页卡顿 |
| P3 | `isCabinInViewport``createSelectorQuery` 异步查询 | iOS/Android 时序不一致、慢 |
| P4 | `dialogRotationTimer` 递归 `setTimeout` | 定时器开销 |
| P5 | 双重 `watch` + 深度监听 `visibleCabins` | 不必要的重渲染 |
| P6 | 72 个 cabin 全部 `v-for` 渲染,无虚拟列表 | 大量无用 DOM |
### 2.2 跨平台兼容问题
| # | 问题 | 影响 |
|---|------|------|
| C1 | `mode="scaleToFill"` | iOS/Android cabin 图标变形 |
| C2 | `height: 150%` | iOS Safari 不支持 |
| C3 | `font-family: ZaoZiGongFangJianHei-1` 无引号 | 安卓部分机型失效 |
| C4 | `createSelectorQuery` 返回时序 | Android/iOS 行为不一致 |
| C5 | touch 事件无 `passive` | iOS 滚动掉帧 |
### 2.3 代码组织问题
| # | 问题 | 影响 |
|---|------|------|
| M1 | 单文件 1196 行 | 难以维护 |
| M2 | 重复 `onShow`(两个) | 逻辑分散,易出 bug |
| M3 | `patchVisibleCabinsData` 定义但未使用 | dead code |
| M4 | 前 27 个 `[0,0]` 空坐标参与渲染 | 无意义计算 |
| M5 | 魔法数字散布IMAGE_W/H, tileWidth, anchorX/Y | 可维护性差 |
---
## 三、重构后文件结构
```
frontend/pages/square/
├── square.vue # 主页面(~300行协调层
├── square.vue.old # 旧版备份
├── DESIGN.md # 本文档
├── config/
│ └── cabin.js # 小屋静态配置
├── composables/
│ ├── useSwipe.js # 滑动 + 惯性滚动
│ ├── useCabin.js # 小屋数据 + 坐标 + 分页缓存
│ ├── useBanner.js # Banner 活动加载
│ └── useDialogRotation.js # 展位提示框轮换
└── components/
├── CabinItem.vue # 单个小屋
├── BannerCarousel.vue # 轮播 + Top3
└── NavArrows.vue # 左右翻页箭头
```
---
## 四、各模块详细设计
### 4.1 `config/cabin.js`
职责:集中存放所有静态配置常量
```javascript
// ========== 图片原始尺寸 ==========
export const IMAGE_W = 2012
export const IMAGE_H = 1918
// ========== 每页小屋数量(有效坐标数) ==========
// 前27个 [0,0] 空坐标已移除,减少无意义渲染
export const PAGE_SIZE = 45
// ========== 小屋坐标(背景原图坐标系) ==========
export const CABIN_COORDS = [
[-260, -20], [235, -20], [750, -20], [1265, -20],
[0, 150], [510, 150], [1010, 150], [1505, 150],
[-260, 350], [235, 350], [750, 350], [1265, 350],
[0, 530], [510, 530], [1010, 530], [1505, 520],
[-260, 730], [235, 730], [750, 730], [1265, 730],
[0, 910], [510, 910], [1010, 910], [1505, 910],
[-260, 1115], [235, 1115], [750, 1115], [1265, 1115],
[0, 1300], [510, 1300], [1010, 1300], [1505, 1300],
[-260, 1510], [235, 1510], [750, 1510], [1265, 1510],
[0, 1690], [510, 1690], [1010, 1690], [1505, 1690],
[-260, 1910], [235, 1910], [750, 1910], [1265, 1910],
]
// ========== 小屋类型定义 ==========
export const CABIN_DEFS = [
{ src: '/static/components/cabin1.png', imgW: 1000, imgH: 1000, anchorX: 500, anchorY: 680 },
{ src: '/static/components/cabin2.png', imgW: 1000, imgH: 1000, anchorX: 500, anchorY: 680 },
{ src: '/static/components/cabin3.png', imgW: 1000, imgH: 1351, anchorX: 500, anchorY: 965 },
{ src: '/static/components/cabin4.png', imgW: 1000, imgH: 1223, anchorX: 500, anchorY: 875 },
]
// ========== 根据等级返回 cabin 类型索引 ==========
export const cabinTypeByLevel = (level) => {
if (level >= 7) return 3
if (level >= 5) return 2
if (level >= 3) return 1
return 0
}
```
**性能收益**:前 27 个空坐标移除,`buildVisibleCabins` 循环从 72×5=360 次降到 45×5=225 次
---
### 4.2 `composables/useSwipe.js`
职责:背景滑动 + 惯性滚动 + 翻页动画
**导出**
- `bgOffsetX` — 背景偏移量ref
- `rawOffsetX` — 累计原始偏移量
- `cabinLayerStyle` — cabin 层 transformcomputed
- `backgroundStripStyle` — 背景 transformcomputed
- `scrollPage(direction)` — 箭头翻页
- `stopInertia()` — 停止惯性
- `initSwipe({ screenWidth, tileWidth, onTileChange })` — 初始化
- `onBgTouchStart / onBgTouchMove / onBgTouchEnd / onBgTouchCancel`
**关键优化**
- touch 事件添加 `{ passive: true }`iOS 滚动流畅
- RAF 封装:`rafFn` / `cafFn`
- 惯性衰减:`velocity *= 0.8`,小于 0.2 时停止
- `clampOffset` 归一化到 `[-tileWidth, 0)`
---
### 4.3 `composables/useCabin.js`
职责:小屋数据 + 分页缓存 + 坐标计算 + visibleCabins 构建
**导出**
- `visibleCabins` — 可视小屋数组ref
- `currentPage` — 当前页ref
- `scaledCoords` — 缩放后坐标ref
- `cabinRenderDefs` — cabin 渲染尺寸配置
- `fetchPage(page)` / `ensurePages(center)` / `initCabin(opts)`
- `resetSquare()` — 重置广场
**关键设计**
1. **坐标过滤**CABIN_COORDS 移至 config/cabin.js前 27 个 [0,0] 移除
2. **buildVisibleCabins 优化**
- `bannerBottom` 判断简化y < bannerBottom `isAbove = true`昵称不显示
- 移位算法上方有数据的 cabin 移至下方空位保留
- 不再使用 `createSelectorQuery`
3. **patchVisibleCabinsData 启用**
- 数据更新时增量 patch 字段src/userId/nickname/sharedBoothSlotsRemaining
- 保持 DOM 引用稳定避免图片重新加载闪烁
4. **watch 策略简化**
- `[currentPage, scaledCoords]` 全量重建位置变化
- `pageCacheVersion` patchVisibleCabinsData数据变化
- 移除深度 watch `visibleCabins`
5. **首次加载**onMounted fetchPage(1)确保第一帧有数据
---
### 4.4 `composables/useDialogRotation.js`
职责展位提示框随机轮换
**导出**`startDialogRotation()` / `stopDialogRotation()`
**优化点**
- `setInterval` 替代递归 `setTimeout`2-3秒随机间隔
- 简化可视判断只检查 `cabin.showNickname && cabin.sharedBoothSlotsRemaining !== null`
- 每次随机选 2-3 个显示
- 不再依赖 `createSelectorQuery`
```javascript
// 核心逻辑
const rotateDialogVisibility = () => {
cabins.forEach(c => c.showDialog = false)
const eligible = cabins.filter(c => c.nickname && c.sharedBoothSlotsRemaining !== null)
const count = Math.min(Math.floor(Math.random() * 2) + 2, eligible.length, 3)
eligible.sort(() => Math.random() - 0.5).slice(0, count).forEach(c => c.showDialog = true)
}
let timer = null
const startDialogRotation = () => {
timer = setInterval(() => {
rotateDialogVisibility()
}, 2000 + Math.random() * 1000)
}
```
---
### 4.5 `composables/useBanner.js`
职责Banner 活动数据加载
**导出**`bannerActivities`ref)、`loadBannerActivities()`
逻辑封装原 `loadBannerActivities`并发获取 OSS URL
---
### 4.6 `components/CabinItem.vue`
职责渲染单个 cabin
**Props**
```javascript
{
cabin: Object, // 小屋数据
currentUserNickname: String,
bannerBottom: Number // banner 底部边界px
}
```
**模板**
```html
<view class="cabin-wrapper" :style="{ left: cabin.x + 'px', top: cabin.y + 'px', width: cabin.w + 'px' }">
<image
class="cabin-icon"
:src="cabin.src"
:style="{ width: cabin.w + 'px', height: cabin.h + 'px' }"
mode="aspectFit"
/>
<text v-if="cabin.showNickname && cabin.nickname" class="cabin-nickname">
{{ cabin.nickname === currentUserNickname ? '我的小屋' : cabin.nickname }}
</text>
<text v-else class="cabin-nickname cabin-nickname--empty">小屋暂无人居住</text>
<view v-if="cabin.showDialog" class="cabin-slots-dialog">
<text class="cabin-slots-text text-white">剩余 <text class="text-orange">{{ cabin.sharedBoothSlotsRemaining }}</text> 个展位</text>
</view>
</view>
```
**性能优化**
- 使用 `aspectFit` 替代 `scaleToFill`
- 每个 cabin 独立管理自己的 `showDialog`无需父组件批量更新
- 图片加载使用 `LazyImage`项目已有组件
---
### 4.7 `components/BannerCarousel.vue`
职责封装 banner 轮播 + Top3
**Props**
```javascript
{
bannerActivities: Array,
currentStarId: [String, Number]
}
```
**Emits**`activityClick(item)`、`top3Click`
```html
<view class="banner-carousel">
<swiper :autoplay="true" :interval="4000" :duration="400" :circular="true" :indicator-dots="false">
<swiper-item @click="$emit('top3Click')">
<BannerTop3 />
</swiper-item>
<swiper-item v-for="item in bannerActivities" :key="item.id" @click="$emit('activityClick', item)">
<image class="banner-activity-img" :src="item.cover_image" mode="aspectFill" />
</swiper-item>
</swiper>
</view>
```
---
### 4.8 `components/NavArrows.vue`
职责左右翻页箭头
**Emits**`scroll(direction)`
```html
<view class="nav-arrows">
<view class="arrow-btn arrow-left" @click="$emit('scroll', -1)"><text class="arrow-text"></text></view>
<view class="arrow-btn arrow-right" @click="$emit('scroll', 1)"><text class="arrow-text"></text></view>
</view>
```
---
### 4.9 `square.vue` — 主页面
职责组合所有模块 + 管理弹窗状态 + 协调初始化
**模板结构**
```html
<view class="square-container"
@touchstart="onBgTouchStart" @touchmove="onBgTouchMove"
@touchend="onBgTouchEnd" @touchcancel="onBgTouchCancel">
<!-- 背景 -->
<view class="background-strip" :style="backgroundStripStyle">
<image v-for="i in 3" :key="i" class="background-tile" :style="..." src="/static/background/mainbg.png" />
</view>
<!-- Cabin 层 -->
<view class="cabin-layer" :style="cabinLayerStyle">
<CabinItem
v-for="cabin in visibleCabins"
:key="cabin.key"
:cabin="cabin"
:currentUserNickname="currentUserNickname"
:bannerBottom="bannerBottom"
/>
</view>
<!-- 箭头 -->
<NavArrows @scroll="scrollPage" />
<!-- Header -->
<Header :showGuideIcon="true" :showTaskIcon="true" :showStarActivityIcon="true" backIconColor="#e6e6e6" />
<!-- Banner -->
<BannerCarousel
:bannerActivities="bannerActivities"
:currentStarId="currentStarId"
@activityClick="handleActivityClick"
@top3Click="showRankingModal = true"
/>
<!-- 弹窗层 -->
<RankingModal ... />
<BottomNav ... />
<GuideStartModal ... />
<GuideOverlay />
</view>
```
**改动点**
- 合并重复 `onShow` 一个 `onShow` 调用 `resetSquare`
- 所有 composable `onMounted` 中初始化
- 弹窗状态RankingModalGuideStartModalnavExpanded保留在页面级
---
## 五、CSS 兼容修复
| # | | |
|---|----|----|
| `height: 150%` | iOS 不支持 | `min-height: 100vh; height: calc(100vh + 50vh)` |
| `mode="scaleToFill"` | 变形 | `mode="aspectFit"`cabin)、`mode="aspectFill"`banner |
| 字体无引号 | 部分机型失效 | `'ZaoZiGongFangJianHei-1', 'PingFang SC', sans-serif` |
| touch passive | iOS 掉帧 | `{ passive: true }` |
---
## 六、性能优化汇总
| # | 优化项 | 收益 |
|---|--------|------|
| 1 | 27 [0,0] 空坐标过滤 | 循环次数下降 37%360225 |
| 2 | `aspectFit` 替代 `scaleToFill` | 减少图片重绘 |
| 3 | `patchVisibleCabinsData` 启用 | 数据更新避免 DOM 重建 |
| 4 | touch 事件 `passive: true` | iOS 滚动不掉帧 |
| 5 | `setInterval` 替代递归 `setTimeout` | 定时器开销降低 |
| 6 | 移除 `createSelectorQuery` viewport 检测 | 消除异步查询开销 |
| 7 | CabinItem 组件化 | 组件粒度细化利于缓存 |
| 8 | `will-change: transform` 已有 | 保留GPU 加速 |
---
## 七、网格布局动态化重构方案
### 7.1 问题分析
当前问题
1. **硬编码坐标**72 个坐标手动维护修改困难
2. **背景图依赖**坐标与背景图强耦合背景图更换需重新标注所有坐标
3. **扩展性差**增加/减少格子需要手动计算坐标
4. **视觉不一致**手动标注容易出现偏差
### 7.2 重构目标
"硬编码坐标系统"改为"网格布局系统"
- 背景图定义网格规则行数列数间距偏移
- 小屋图标自动填充到网格中
- 支持不规则网格某些位置可配置为空
- 背景图更换时只需调整网格参数
### 7.3 网格布局配置方案
#### 方案 A规则网格 + 排除列表(推荐)
适用场景背景图是规则网格只有少数位置需要留空
```javascript
// config/gridLayout.js
export const GRID_CONFIG = {
// 背景图尺寸
backgroundWidth: 2012,
backgroundHeight: 1918,
// 网格定义
grid: {
rows: 11, // 行数
cols: 4, // 列数
// 网格起始点(背景图左上角为原点)
startX: -260,
startY: -20,
// 格子间距
spacingX: 515, // 水平间距
spacingY: 200, // 垂直间距
// 交错布局(奇偶行偏移)
staggered: true,
staggerOffsetX: 260, // 偶数行向右偏移
// 排除的格子(不渲染小屋的位置)
excludePositions: [
// 格式:[row, col],从 0 开始
// 例如:前 27 个位置留空
[0, 0], [0, 1], [0, 2], [0, 3],
[1, 0], [1, 1], [1, 2], [1, 3],
// ... 可根据背景图调整
]
}
}
// 自动生成坐标函数
export function generateGridCoordinates(config) {
const coords = []
const { rows, cols, startX, startY, spacingX, spacingY, staggered, staggerOffsetX, excludePositions } = config.grid
const excludeSet = new Set(excludePositions.map(([r, c]) => `${r},${c}`))
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
// 跳过排除的位置
if (excludeSet.has(`${row},${col}`)) continue
// 计算坐标
const offsetX = staggered && row % 2 === 1 ? staggerOffsetX : 0
const x = startX + col * spacingX + offsetX
const y = startY + row * spacingY
coords.push({
x,
y,
row,
col,
index: coords.length // 用户数据索引
})
}
}
return coords
}
```
#### 方案 B自定义网格模板灵活度最高
适用场景背景图是不规则网格需要精确控制每个位置
```javascript
// config/gridLayout.js
export const GRID_TEMPLATE = {
backgroundWidth: 2012,
backgroundHeight: 1918,
// 定义每一行的布局
rows: [
// 第 0 行4 个格子,交错布局
{
y: -20,
cells: [
{ x: -260, enabled: false }, // 留空
{ x: 235, enabled: false },
{ x: 750, enabled: false },
{ x: 1265, enabled: false }
]
},
// 第 1 行4 个格子
{
y: 150,
cells: [
{ x: 0, enabled: true },
{ x: 510, enabled: true },
{ x: 1010, enabled: true },
{ x: 1505, enabled: true }
]
},
// ... 继续定义其他行
]
}
// 生成坐标函数
export function generateTemplateCoordinates(template) {
const coords = []
template.rows.forEach((row, rowIndex) => {
row.cells.forEach((cell, colIndex) => {
if (cell.enabled) {
coords.push({
x: cell.x,
y: row.y,
row: rowIndex,
col: colIndex,
index: coords.length
})
}
})
})
return coords
}
```
#### 方案 C混合方案推荐用于你的场景
结合规则网格和手动微调
```javascript
// config/gridLayout.js
export const GRID_CONFIG = {
backgroundWidth: 2012,
backgroundHeight: 1918,
// 基础网格规则
baseGrid: {
rows: 11,
cols: 4,
startX: -260,
startY: -20,
spacingX: 515,
spacingY: 200,
staggered: true,
staggerOffsetX: 260
},
// 排除位置(前 27 个)
excludeCount: 27,
// 手动微调(覆盖自动生成的坐标)
manualAdjustments: {
// 格式index: { x, y }
39: { x: 1505, y: 520 }, // 第 40 个位置 y 坐标微调
// 可以继续添加需要微调的位置
}
}
// 生成坐标函数
export function generateCoordinates(config) {
const coords = []
const { rows, cols, startX, startY, spacingX, spacingY, staggered, staggerOffsetX } = config.baseGrid
let index = 0
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
// 跳过前 N 个位置
if (index < config.excludeCount) {
index++
continue
}
// 计算基础坐标
const offsetX = staggered && row % 2 === 1 ? staggerOffsetX : 0
let x = startX + col * spacingX + offsetX
let y = startY + row * spacingY
// 应用手动微调
if (config.manualAdjustments[index]) {
x = config.manualAdjustments[index].x ?? x
y = config.manualAdjustments[index].y ?? y
}
coords.push({ x, y, row, col, index })
index++
}
}
return coords
}
```
### 7.4 配置文件重构
#### 新的 `config/cabin.js`
```javascript
import { GRID_CONFIG, generateCoordinates } from './gridLayout.js'
// ========== 图片原始尺寸 ==========
export const IMAGE_W = GRID_CONFIG.backgroundWidth
export const IMAGE_H = GRID_CONFIG.backgroundHeight
// ========== 动态生成坐标 ==========
export const CABIN_COORDS = generateCoordinates(GRID_CONFIG).map(coord => [coord.x, coord.y])
// ========== 每页小屋数量 ==========
export const PAGE_SIZE = CABIN_COORDS.length
// ========== 小屋类型定义 ==========
export const CABIN_DEFS = [
{ src: '/static/components/cabin1.png', imgW: 1000, imgH: 1000, anchorX: 500, anchorY: 680 },
{ src: '/static/components/cabin2.png', imgW: 1000, imgH: 1000, anchorX: 500, anchorY: 680 },
{ src: '/static/components/cabin3.png', imgW: 1000, imgH: 1351, anchorX: 500, anchorY: 965 },
{ src: '/static/components/cabin4.png', imgW: 1000, imgH: 1223, anchorX: 500, anchorY: 875 },
]
// ========== 根据等级返回 cabin 类型索引 ==========
export const cabinTypeByLevel = (level) => {
if (level >= 7) return 3
if (level >= 5) return 2
if (level >= 3) return 1
return 0
}
// ========== 导出网格配置(用于调试和可视化) ==========
export { GRID_CONFIG } from './gridLayout.js'
```
### 7.5 可视化调试工具
为了方便调整网格参数建议添加一个调试页面
```javascript
// pages/square/debug-grid.vue
<template>
<view class="debug-container">
<image class="bg-image" src="/static/background/mainbg.png" mode="aspectFit" />
<!-- 网格线 -->
<view v-for="(coord, i) in debugCoords" :key="i"
class="grid-point"
:style="{ left: coord.scaledX + 'px', top: coord.scaledY + 'px' }">
<text class="grid-label">{{ i }}</text>
</view>
<!-- 参数调整面板 -->
<view class="control-panel">
<text>间距X: {{ spacingX }}</text>
<slider :value="spacingX" @change="updateSpacingX" min="400" max="600" />
<!-- 其他参数... -->
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { GRID_CONFIG, generateCoordinates } from './config/gridLayout.js'
const spacingX = ref(GRID_CONFIG.baseGrid.spacingX)
// 实时预览网格调整效果
</script>
```
### 7.6 迁移步骤
```
Step 1: 创建 config/gridLayout.js定义网格规则
Step 2: 使用调试工具 debug-grid.vue 调整参数,直到网格对齐背景图
Step 3: 更新 config/cabin.js使用动态生成的坐标
Step 4: 测试 square.vue确保功能正常
Step 5: 删除旧的硬编码坐标数组
```
### 7.7 优势对比
| 维度 | 旧方案硬编码 | 新方案网格系统 |
|------|-----------------|-------------------|
| 维护成本 | 72 个坐标手动维护 | 只需调整几个参数 |
| 扩展性 | 增加格子需重新计算 | 自动生成 |
| 背景图更换 | 需重新标注所有坐标 | 只需调整网格参数 |
| 视觉一致性 | 手动标注易出错 | 自动对齐一致性高 |
| 调试效率 | 需反复测试 | 可视化调试工具 |
### 7.8 推荐方案
根据你的需求推荐使用 **方案 C混合方案**
1. 使用规则网格自动生成大部分坐标
2. 对个别需要微调的位置使用 `manualAdjustments`
3. 配合可视化调试工具快速调整参数
这样既保持了灵活性又大幅降低了维护成本
---
## 八、执行顺序
```
Step 1: config/gridLayout.js新增
Step 2: config/cabin.js重构使用动态坐标
Step 3: pages/square/debug-grid.vue新增调试工具
Step 4: composables/useSwipe.js
Step 5: composables/useCabin.js
Step 6: composables/useDialogRotation.js
Step 7: composables/useBanner.js
Step 8: components/CabinItem.vue
Step 9: components/BannerCarousel.vue
Step 10: components/NavArrows.vue
Step 11: square.vue重写
Step 12: 功能验证 + iOS/Android 测试
```