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

20 KiB
Raw Blame History

square.vue 重构设计文档

一、背景

  • 文件frontend/pages/square/square.vue1196 行)
  • 技术栈uni-app + Vue 3 Composition API<script setup>
  • 目标:功能逻辑不变,优化首屏性能、响应速度,修复 iOS/Android 兼容性问题

二、旧版问题汇总

2.1 性能问题

# 问题 影响
P1 无图片懒加载72 个 cabin icon + 背景图全部立即加载 首屏慢、内存高
P2 buildVisibleCabins 每次重建整个数组340次循环 翻页卡顿
P3 isCabinInViewportcreateSelectorQuery 异步查询 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

职责:集中存放静态配置常量 + 网格坐标生成系统

// ========== 图片原始尺寸 ==========
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 替代递归 setTimeout2-3秒随机间隔
  • 简化可视判断:只检查 cabin.showNickname && cabin.sharedBoothSlotsRemaining !== null
  • 每次随机选 2-3 个显示
  • 不再依赖 createSelectorQuery
// 核心逻辑
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 活动数据加载

导出bannerActivitiesrefloadBannerActivities()

逻辑:封装原 loadBannerActivities,并发获取 OSS URL。


4.6 components/CabinItem.vue

职责:渲染单个 cabin

Props

{
  cabin: Object,          // 小屋数据
  currentUserNickname: String,
  bannerBottom: Number    // banner 底部边界px
}

模板

<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

{
  bannerActivities: Array,
  currentStarId: [String, Number]
}

EmitsactivityClick(item)top3Click

<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

职责:左右翻页箭头

Emitsscroll(direction)

<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 — 主页面

职责:组合所有模块 + 管理弹窗状态 + 协调初始化

模板结构

<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保留在页面级
  • 使用 useCabin 实现视口网格渲染

五、CSS 兼容修复

#
height: 150% iOS 不支持 min-height: 100vh; height: calc(100vh + 50vh)
mode="scaleToFill" 变形 mode="aspectFit"cabinmode="aspectFill"banner
字体无引号 部分机型失效 'ZaoZiGongFangJianHei-1', 'PingFang SC', sans-serif
touch 无 passive iOS 掉帧 { passive: true }(在 useSwipe 中实现)

六、性能优化汇总

# 优化项 收益
1 视口网格渲染 DOM 节点减少 90%72→30
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

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 遮挡)
  }
}

坐标生成函数

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 可视区域计算

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

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 调试工具

为了方便调整网格参数,建议添加调试页面:

// 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 测试