feat :重构文档

This commit is contained in:
zerosaturation 2026-04-12 21:53:08 +08:00
parent d17589236f
commit 61880d605f
2 changed files with 1910 additions and 0 deletions

View File

@ -0,0 +1,713 @@
# `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` 中初始化
- 弹窗状态RankingModal、GuideStartModal、navExpanded保留在页面级
---
## 五、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%360→225 |
| 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 测试
```

File diff suppressed because it is too large Load Diff