topfans/frontend/pages/square/DESIGN.md
2026-04-13 16:16:41 +08:00

646 lines
20 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 | 前 27 个 `[0,0]` 空坐标参与渲染 | 无意义计算 |
| M4 | 魔法数字散布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
// ========== 小屋类型定义 ==========
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 const GRID_CONFIG = {
backgroundWidth: 2012,
backgroundHeight: 1918,
grid: {
rows: 11,
cols: 4,
startX: -260,
startY: -20,
spacingX: 515,
spacingY: 200,
staggered: true,
staggerOffsetX: 260,
cellWidth: 200,
cellHeight: 150,
excludeRows: [0, 1, 2],
}
}
// ========== 网格坐标生成函数 ==========
export function generateGridCoordinates(config = GRID_CONFIG) {
const { rows, cols, startX, startY, spacingX, spacingY, staggered, staggerOffsetX, excludeRows } = config.grid
const coords = []
for (let row = 0; row < rows; row++) {
if (excludeRows.includes(row)) continue
for (let col = 0; col < cols; col++) {
const offsetX = staggered && row % 2 === 1 ? staggerOffsetX : 0
coords.push({
x: startX + col * spacingX + offsetX,
y: startY + row * spacingY,
row,
col,
index: coords.length
})
}
}
return coords
}
```
**注意**`CABIN_COORDS` 已移除,改用 `generateGridCoordinates()` 动态生成。
---
### 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` — 可视小屋数组ref
- `currentPage` — 当前页ref
- `scaledCoords` — 缩放后坐标ref
- `cabinRenderDefs` — cabin 渲染尺寸配置
- `fetchPage(page)` / `ensurePages(center)` / `initCabin(opts)`
- `resetSquare()` — 重置广场
**关键设计**
1. **坐标来源**:使用 `generateGridCoordinates()` 动态生成,不再依赖硬编码
2. **buildVisibleCabins 优化**
- `bannerBottom` 判断简化y < bannerBottom `isAbove = true`昵称不显示
- 移位算法上方有数据的 cabin 移至下方空位保留
- 不再使用 `createSelectorQuery`
3. **数据更新策略**
- 数据更新时增量 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 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>
<!-- Top3 单独处理,不在 swiper 内 -->
<view class="top3-area" @click="$emit('top3Click')">
<BannerTop3 />
</view>
</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保留在页面级
- 使用 `useCabin` 实现视口网格渲染
---
## 五、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 }` useSwipe 中实现 |
---
## 六、性能优化汇总
| # | 优化项 | 收益 |
|---|--------|------|
| 1 | 视口网格渲染 | DOM 节点减少 90%7230 |
| 2 | 按需加载用户数据 | 内存占用降低 50% |
| 3 | `aspectFit` 替代 `scaleToFill` | 减少图片重绘 |
| 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 核心设计
#### 架构图
```
┌─────────────────────────────────────────┐
│ 背景图(无限横向平铺) │
│ ┌──────────────────────────────────┐ │
│ │ 可视区域Viewport │ │
│ │ ┌───┬───┬───┬───┐ │ │
│ │ │ 1 │ 2 │ 3 │ 4 │ ← 自动检测 │ │
│ │ ├───┼───┼───┼───┤ 可见格子 │ │
│ │ │ 5 │ 6 │ 7 │ 8 │ │ │
│ │ ├───┼───┼───┼───┤ │ │
│ │ │ 9 │10 │11 │12 │ │ │
│ │ └───┴───┴───┴───┘ │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
只渲染可见格子中的小屋
```
#### 工作流程
```
1. 定义背景网格规则(行数、列数、间距)
2. 根据当前滚动位置计算可视区域
3. 计算可视区域内的格子坐标
4. 从用户数据中取对应数量的数据
5. 将小屋渲染到格子中
6. 滚动时动态更新(格子复用)
```
### 7.4 网格配置
#### `config/cabin.js` 中的 `GRID_CONFIG`
```javascript
export const GRID_CONFIG = {
backgroundWidth: 2012,
backgroundHeight: 1918,
grid: {
rows: 11, // 总行数
cols: 4, // 每行格子数
startX: -260, // 起始 X相对于背景图左上角
startY: -20, // 起始 Y
spacingX: 515, // 水平间距
spacingY: 200, // 垂直间距
staggered: true, // 奇偶行交错
staggerOffsetX: 260, // 交错偏移量
cellWidth: 200, // 格子宽度(用于碰撞检测)
cellHeight: 150, // 格子高度
excludeRows: [0, 1, 2], // 排除的行(前 3 行被 banner 遮挡)
}
}
```
#### 坐标生成函数
```javascript
export function generateGridCoordinates(config = GRID_CONFIG) {
const { rows, cols, startX, startY, spacingX, spacingY, staggered, staggerOffsetX, excludeRows } = config.grid
const coords = []
for (let row = 0; row < rows; row++) {
if (excludeRows.includes(row)) continue
for (let col = 0; col < cols; col++) {
const offsetX = staggered && row % 2 === 1 ? staggerOffsetX : 0
coords.push({
x: startX + col * spacingX + offsetX,
y: startY + row * spacingY,
row,
col,
index: coords.length
})
}
}
return coords
}
```
### 7.5 可视区域计算
```javascript
export function getVisibleCells(config, viewportLeft, viewportRight, tileWidth) {
const coords = generateGridCoordinates(config)
const visibleCells = []
const startTile = Math.floor(viewportLeft / tileWidth)
const endTile = Math.ceil(viewportRight / tileWidth)
for (let tileN = startTile; tileN <= endTile; tileN++) {
const tileOffsetX = tileN * tileWidth
coords.forEach(coord => {
const cellX = tileOffsetX + coord.x
// 检查格子是否在可视区域内
if (cellX - config.grid.cellWidth >= viewportLeft &&
cellX + config.grid.cellWidth <= viewportRight) {
visibleCells.push({
x: cellX,
y: coord.y,
row: coord.row,
col: coord.col,
tileN,
globalIndex: tileN * coords.length + coord.index
})
}
})
}
return visibleCells
}
```
### 7.6 手动微调
个别位置需要精确调整时使用 `manualAdjustments`
```javascript
export const GRID_CONFIG = {
// ... base config ...
manualAdjustments: {
// index: { x, y }
39: { x: 1505, y: 520 }, // 第 40 个位置 y 坐标微调
}
}
export function generateCoordinates(config) {
const coords = generateGridCoordinates(config)
Object.entries(config.manualAdjustments || {}).forEach(([index, adjustment]) => {
if (coords[index]) {
coords[index].x = adjustment.x ?? coords[index].x
coords[index].y = adjustment.y ?? coords[index].y
}
})
return coords
}
```
### 7.7 调试工具
为了方便调整网格参数建议添加调试页面
```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, generateGridCoordinates } from './config/cabin.js'
const spacingX = ref(GRID_CONFIG.grid.spacingX)
const debugCoords = computed(() => generateGridCoordinates({
...GRID_CONFIG,
grid: { ...GRID_CONFIG.grid, spacingX: spacingX.value }
}))
</script>
```
### 7.8 迁移步骤
```
Step 1: 创建 config/cabin.js包含 GRID_CONFIG + generateGridCoordinates
Step 2: 创建 debug-grid.vue 调试工具,调整网格参数对齐背景图
Step 3: 实现 composables/useCabin.js使用 generateGridCoordinates
Step 4: 实现其他 composablesuseSwipe、useBanner、useDialogRotation
Step 5: 实现 componentsCabinItem、BannerCarousel、NavArrows
Step 6: 重写 square.vue组合所有模块
Step 7: 功能验证 + iOS/Android 测试
```
### 7.9 优势对比
| 维度 | 旧方案硬编码 | 新方案网格系统 |
|------|-----------------|-------------------|
| 维护成本 | 72 个坐标手动维护 | 只需调整几个参数 |
| 扩展性 | 增加格子需重新计算 | 自动生成 |
| 背景图更换 | 需重新标注所有坐标 | 只需调整网格参数 |
| 视觉一致性 | 手动标注易出错 | 自动对齐一致性高 |
| 调试效率 | 需反复测试 | 可视化调试工具 |
---
## 八、执行顺序
```
Step 1: config/cabin.js包含网格配置 + 坐标生成)
Step 2: debug-grid.vue调试工具
Step 3: composables/useCabin.js
Step 4: composables/useSwipe.js
Step 5: composables/useBanner.js
Step 6: composables/useDialogRotation.js
Step 7: components/CabinItem.vue
Step 8: components/BannerCarousel.vue
Step 9: components/NavArrows.vue
Step 10: square.vue重写
Step 11: 功能验证 + iOS/Android 测试
```