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

21 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 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

职责:集中存放所有静态配置常量

// ========== 图片原始尺寸 ==========
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 替代递归 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 @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

职责:左右翻页箭头

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保留在页面级

五、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 }

六、性能优化汇总

# 优化项 收益
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规则网格 + 排除列表(推荐)

适用场景:背景图是规则网格,只有少数位置需要留空

// 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自定义网格模板灵活度最高

适用场景:背景图是不规则网格,需要精确控制每个位置

// 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混合方案推荐用于你的场景

结合规则网格和手动微调

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

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

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

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