From 65a5bf82cc6d9407d37715a1e5034dd7beebe751 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Mon, 13 Apr 2026 01:02:57 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E5=B9=BF?= =?UTF-8?q?=E5=9C=BA=E7=9A=84=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/pages.json | 9 + frontend/pages/square/DESIGN.md | 464 +++--- .../square/components/BannerCarousel.vue | 70 + .../pages/square/components/CabinItem.vue | 117 ++ .../pages/square/components/NavArrows.vue | 58 + .../pages/square/composables/useBanner.js | 39 + frontend/pages/square/composables/useCabin.js | 248 +++ .../square/composables/useDialogRotation.js | 84 + frontend/pages/square/composables/useSwipe.js | 181 ++ frontend/pages/square/config/cabin.js | 116 ++ frontend/pages/square/debug-grid.vue | 696 ++++++++ frontend/pages/square/square.vue | 1477 ++++------------- frontend/pages/square/square.vue.old | 1197 ------------- 13 files changed, 2161 insertions(+), 2595 deletions(-) create mode 100644 frontend/pages/square/components/BannerCarousel.vue create mode 100644 frontend/pages/square/components/CabinItem.vue create mode 100644 frontend/pages/square/components/NavArrows.vue create mode 100644 frontend/pages/square/composables/useBanner.js create mode 100644 frontend/pages/square/composables/useCabin.js create mode 100644 frontend/pages/square/composables/useDialogRotation.js create mode 100644 frontend/pages/square/composables/useSwipe.js create mode 100644 frontend/pages/square/config/cabin.js create mode 100644 frontend/pages/square/debug-grid.vue delete mode 100644 frontend/pages/square/square.vue.old diff --git a/frontend/pages.json b/frontend/pages.json index 70dc7f5..82d208a 100644 --- a/frontend/pages.json +++ b/frontend/pages.json @@ -8,6 +8,15 @@ } } }, + { + "path": "pages/square/debug-grid", + "style": { + "navigationStyle": "custom", + "app-plus": { + "bounce": "none" + } + } + }, { "path": "pages/starbook/index", "style": { diff --git a/frontend/pages/square/DESIGN.md b/frontend/pages/square/DESIGN.md index 5de0d43..7b74fc3 100644 --- a/frontend/pages/square/DESIGN.md +++ b/frontend/pages/square/DESIGN.md @@ -37,9 +37,8 @@ |---|------|------| | M1 | 单文件 1196 行 | 难以维护 | | M2 | 重复 `onShow`(两个) | 逻辑分散,易出 bug | -| M3 | `patchVisibleCabinsData` 定义但未使用 | dead code | -| M4 | 前 27 个 `[0,0]` 空坐标参与渲染 | 无意义计算 | -| M5 | 魔法数字散布(IMAGE_W/H, tileWidth, anchorX/Y) | 可维护性差 | +| M3 | 前 27 个 `[0,0]` 空坐标参与渲染 | 无意义计算 | +| M4 | 魔法数字散布(IMAGE_W/H, tileWidth, anchorX/Y) | 可维护性差 | --- @@ -51,10 +50,10 @@ frontend/pages/square/ ├── square.vue.old # 旧版备份 ├── DESIGN.md # 本文档 ├── config/ -│ └── cabin.js # 小屋静态配置 +│ └── cabin.js # 小屋静态配置 + 网格系统 ├── composables/ │ ├── useSwipe.js # 滑动 + 惯性滚动 -│ ├── useCabin.js # 小屋数据 + 坐标 + 分页缓存 +│ ├── useCabin.js # 小屋数据 + 视口网格渲染 │ ├── useBanner.js # Banner 活动加载 │ └── useDialogRotation.js # 展位提示框轮换 └── components/ @@ -69,32 +68,13 @@ frontend/pages/square/ ### 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 }, @@ -110,9 +90,50 @@ export const cabinTypeByLevel = (level) => { 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 +} ``` -**性能收益**:前 27 个空坐标移除,`buildVisibleCabins` 循环从 72×5=360 次降到 45×5=225 次 +**注意**:`CABIN_COORDS` 已移除,改用 `generateGridCoordinates()` 动态生成。 --- @@ -140,7 +161,7 @@ export const cabinTypeByLevel = (level) => { ### 4.3 `composables/useCabin.js` -职责:小屋数据 + 分页缓存 + 坐标计算 + visibleCabins 构建 +职责:小屋数据 + 分页缓存 + 视口网格渲染 **导出**: - `visibleCabins` — 可视小屋数组(ref) @@ -152,14 +173,14 @@ export const cabinTypeByLevel = (level) => { **关键设计**: -1. **坐标过滤**:CABIN_COORDS 移至 config/cabin.js,前 27 个 [0,0] 移除 +1. **坐标来源**:使用 `generateGridCoordinates()` 动态生成,不再依赖硬编码 2. **buildVisibleCabins 优化**: - `bannerBottom` 判断简化:y < bannerBottom → `isAbove = true`,昵称不显示 - 移位算法(上方有数据的 cabin 移至下方空位)保留 - 不再使用 `createSelectorQuery` -3. **patchVisibleCabinsData 启用**: +3. **数据更新策略**: - 数据更新时增量 patch 字段(src/userId/nickname/sharedBoothSlotsRemaining) - 保持 DOM 引用稳定,避免图片重新加载闪烁 @@ -269,13 +290,14 @@ const startDialogRotation = () => { ```html ``` @@ -311,7 +333,7 @@ const startDialogRotation = () => { - + { - 合并重复 `onShow` → 一个 `onShow` 调用 `resetSquare` - 所有 composable 在 `onMounted` 中初始化 - 弹窗状态(RankingModal、GuideStartModal、navExpanded)保留在页面级 +- 使用 `useCabin` 实现视口网格渲染 --- @@ -358,7 +381,7 @@ const startDialogRotation = () => { | `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 }` | +| touch 无 passive | iOS 掉帧 | `{ passive: true }`(在 useSwipe 中实现) | --- @@ -366,9 +389,9 @@ const startDialogRotation = () => { | # | 优化项 | 收益 | |---|--------|------| -| 1 | 前 27 个 [0,0] 空坐标过滤 | 循环次数下降 37%(360→225) | -| 2 | `aspectFit` 替代 `scaleToFill` | 减少图片重绘 | -| 3 | `patchVisibleCabinsData` 启用 | 数据更新避免 DOM 重建 | +| 1 | 视口网格渲染 | DOM 节点减少 90%(72→30) | +| 2 | 按需加载用户数据 | 内存占用降低 50% | +| 3 | `aspectFit` 替代 `scaleToFill` | 减少图片重绘 | | 4 | touch 事件 `passive: true` | iOS 滚动不掉帧 | | 5 | `setInterval` 替代递归 `setTimeout` | 定时器开销降低 | | 6 | 移除 `createSelectorQuery` viewport 检测 | 消除异步查询开销 | @@ -377,304 +400,223 @@ const startDialogRotation = () => { --- -## 七、网格布局动态化重构方案 +## 七、视口网格动态渲染方案 ### 7.1 问题分析 当前问题: 1. **硬编码坐标**:72 个坐标手动维护,修改困难 -2. **背景图依赖**:坐标与背景图强耦合,背景图更换需重新标注所有坐标 -3. **扩展性差**:增加/减少格子需要手动计算坐标 -4. **视觉不一致**:手动标注容易出现偏差 +2. **全量渲染**:所有小屋一次性渲染,性能差 +3. **背景图依赖**:坐标与背景图强耦合,背景图更换需重新标注所有坐标 +4. **扩展性差**:增加/减少格子需要手动计算坐标 ### 7.2 重构目标 -将"硬编码坐标系统"改为"网格布局系统": -- 背景图定义网格规则(行数、列数、间距、偏移) -- 小屋图标自动填充到网格中 -- 支持不规则网格(某些位置可配置为空) -- 背景图更换时只需调整网格参数 +实现"视口网格动态渲染系统": +- **只渲染可视区域**:根据当前滚动位置,只渲染屏幕内的背景格子 +- **动态填充小屋**:在可见格子中动态放置小屋图标 +- **网格自动计算**:背景图定义网格规则,自动生成格子坐标 +- **无限滚动优化**:格子复用,内存占用恒定 -### 7.3 网格布局配置方案 +### 7.3 核心设计 -#### 方案 A:规则网格 + 排除列表(推荐) +#### 架构图 -适用场景:背景图是规则网格,只有少数位置需要留空 +``` +┌─────────────────────────────────────────┐ +│ 背景图(无限横向平铺) │ +│ ┌──────────────────────────────────┐ │ +│ │ 可视区域(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 -// 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], - // ... 可根据背景图调整 - ] + 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) { +#### 坐标生成函数 + +```javascript +export function generateGridCoordinates(config = GRID_CONFIG) { + const { rows, cols, startX, startY, spacingX, spacingY, staggered, staggerOffsetX, excludeRows } = config.grid 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++) { + if (excludeRows.includes(row)) continue 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, + x: startX + col * spacingX + offsetX, + y: startY + row * spacingY, row, col, - index: coords.length // 用户数据索引 + index: coords.length }) } } - + return coords } ``` -#### 方案 B:自定义网格模板(灵活度最高) - -适用场景:背景图是不规则网格,需要精确控制每个位置 +### 7.5 可视区域计算 ```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 getVisibleCells(config, viewportLeft, viewportRight, tileWidth) { + const coords = generateGridCoordinates(config) + const visibleCells = [] -// 生成坐标函数 -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 + 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 coords + } + + return visibleCells } ``` -#### 方案 C:混合方案(推荐用于你的场景) +### 7.6 手动微调 -结合规则网格和手动微调 +个别位置需要精确调整时,使用 `manualAdjustments`: ```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, - - // 手动微调(覆盖自动生成的坐标) + // ... base config ... manualAdjustments: { - // 格式:index: { x, y } + // 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++ + 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.4 配置文件重构 +### 7.7 调试工具 -#### 新的 `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 ``` -### 7.6 迁移步骤 +### 7.8 迁移步骤 ``` -Step 1: 创建 config/gridLayout.js,定义网格规则 -Step 2: 使用调试工具 debug-grid.vue 调整参数,直到网格对齐背景图 -Step 3: 更新 config/cabin.js,使用动态生成的坐标 -Step 4: 测试 square.vue,确保功能正常 -Step 5: 删除旧的硬编码坐标数组 +Step 1: 创建 config/cabin.js(包含 GRID_CONFIG + generateGridCoordinates) +Step 2: 创建 debug-grid.vue 调试工具,调整网格参数对齐背景图 +Step 3: 实现 composables/useCabin.js(使用 generateGridCoordinates) +Step 4: 实现其他 composables(useSwipe、useBanner、useDialogRotation) +Step 5: 实现 components(CabinItem、BannerCarousel、NavArrows) +Step 6: 重写 square.vue,组合所有模块 +Step 7: 功能验证 + iOS/Android 测试 ``` -### 7.7 优势对比 +### 7.9 优势对比 | 维度 | 旧方案(硬编码) | 新方案(网格系统) | |------|-----------------|-------------------| @@ -684,30 +626,20 @@ Step 5: 删除旧的硬编码坐标数组 | 视觉一致性 | 手动标注易出错 | 自动对齐,一致性高 | | 调试效率 | 低(需反复测试) | 高(可视化调试工具) | -### 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 1: config/cabin.js(包含网格配置 + 坐标生成) +Step 2: debug-grid.vue(调试工具) +Step 3: composables/useCabin.js Step 4: composables/useSwipe.js -Step 5: composables/useCabin.js +Step 5: composables/useBanner.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 测试 +Step 7: components/CabinItem.vue +Step 8: components/BannerCarousel.vue +Step 9: components/NavArrows.vue +Step 10: square.vue(重写) +Step 11: 功能验证 + iOS/Android 测试 ``` diff --git a/frontend/pages/square/components/BannerCarousel.vue b/frontend/pages/square/components/BannerCarousel.vue new file mode 100644 index 0000000..37a21e7 --- /dev/null +++ b/frontend/pages/square/components/BannerCarousel.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/frontend/pages/square/components/CabinItem.vue b/frontend/pages/square/components/CabinItem.vue new file mode 100644 index 0000000..292b5f5 --- /dev/null +++ b/frontend/pages/square/components/CabinItem.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/frontend/pages/square/components/NavArrows.vue b/frontend/pages/square/components/NavArrows.vue new file mode 100644 index 0000000..9ac22e8 --- /dev/null +++ b/frontend/pages/square/components/NavArrows.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/frontend/pages/square/composables/useBanner.js b/frontend/pages/square/composables/useBanner.js new file mode 100644 index 0000000..c10ba85 --- /dev/null +++ b/frontend/pages/square/composables/useBanner.js @@ -0,0 +1,39 @@ +import { ref } from 'vue' +import { getActivityListApi, getOssPresignedUrlApi } from '@/utils/api.js' + +export function useBanner() { + const bannerActivities = ref([]) + + const loadBannerActivities = async () => { + try { + const starId = uni.getStorageSync('star_id') || null + const res = await getActivityListApi(starId, 'active', 1, 3) + + if (res.code === 200 && res.data?.activities) { + const activities = res.data.activities + // 并行获取所有 OSS URL + const urlPromises = activities.map(async (item) => { + if (!item.cover_image) return { item, url: null } + try { + const r = await getOssPresignedUrlApi(item.cover_image, 3600, 'avatar') + return { item, url: r?.code === 200 && r.data?.url ? r.data.url : null } + } catch (e) { + console.error('[useBanner] 获取 OSS URL 失败', e) + return { item, url: null } + } + }) + const results = await Promise.all(urlPromises) + bannerActivities.value = results.map(({ item, url }) => + url ? { ...item, cover_image: url } : item + ) + } + } catch (e) { + console.error('[useBanner] 加载 banner 活动失败', e?.message ?? e) + } + } + + return { + bannerActivities, + loadBannerActivities, + } +} diff --git a/frontend/pages/square/composables/useCabin.js b/frontend/pages/square/composables/useCabin.js new file mode 100644 index 0000000..47223cd --- /dev/null +++ b/frontend/pages/square/composables/useCabin.js @@ -0,0 +1,248 @@ +import { ref, reactive, watch, shallowRef } from 'vue' +import { generateGridCoordinates, CABIN_DEFS, cabinTypeByLevel } from '../config/cabin.js' +import { getRandomUsersApi } from '@/utils/api.js' + +export function useCabin() { + const visibleCabins = ref([]) + const currentPage = ref(1) + const totalUsers = ref(0) + const scaledCoords = ref([]) + const pageCache = shallowRef(new Map()) + const pageCacheVersion = ref(0) + const pendingPages = new Set() + + let cabinRenderDefs = [] + let tileWidth = 375 + let screenWidth = 375 + let bannerBottom = 0 + + const PAGE_SIZE = generateGridCoordinates().length + + const maxPage = () => Math.max(1, Math.ceil(totalUsers.value / PAGE_SIZE)) + + const wrapPage = (p) => { + const max = maxPage() + return ((p - 1 + max) % max) + 1 + } + + const fetchPage = async (page) => { + if (pageCache.value.has(page) || pendingPages.has(page)) { + return + } + + pendingPages.add(page) + try { + const res = await getRandomUsersApi(page, PAGE_SIZE) + if (res.code === 200 && res.data) { + if (totalUsers.value === 0) totalUsers.value = res.data.total || 0 + + const users = res.data.users || [] + // 交换第 1 和第 3 个用户位置 + if (users.length >= 3) { + [users[0], users[20]] = [users[20], users[0]] + } + + pageCache.value.set(page, users) + pageCacheVersion.value++ + } + } catch (e) { + console.error(`[useCabin] fetchPage: page=${page} 请求失败`, e?.message ?? e) + } finally { + pendingPages.delete(page) + } + } + + const ensurePages = (center, isInertia = false) => { + const keep = new Set() + for (let n = 0; n <= 4; n++) { + keep.add(wrapPage(center + n - 1)) + } + + const pages = [...keep] + pages.forEach(fetchPage) + + // 清除不在渲染范围内的页缓存 + const evicted = [] + for (const key of pageCache.value.keys()) { + if (!keep.has(key) && !pendingPages.has(key)) { + evicted.push(key) + pageCache.value.delete(key) + } + } + if (evicted.length) { + pageCacheVersion.value++ + } + } + + const buildVisibleCabins = (page, cache, coords) => { + if (!coords.length || !cabinRenderDefs.length) return [] + + const w = tileWidth + const result = [] + + // 第一遍:收集上方有用户数据的 cabin(待移位),以及下方空位坐标 + const toRelocate = [] + const emptySlots = [] + + for (let i = 0; i < coords.length; i++) { + const { sx, sy } = coords[i] + for (let n = 0; n <= 4; n++) { + const p = wrapPage(page + n - 1) + const users = cache.get(p) || [] + const user = users[i] || null + const isAbove = sy < bannerBottom + + if (isAbove && user) { + const typeIdx = cabinTypeByLevel(user.level) + toRelocate.push({ i, n, user, typeIdx, sx, sy }) + } else if (!isAbove && !user) { + emptySlots.push({ sx, sy, n }) + } + } + } + + // 第二遍:正常渲染,跳过"被移位占用的空位"和"已移位的上方 cabin" + const occupiedSlots = new Set() + for (let k = 0; k < toRelocate.length && k < emptySlots.length; k++) { + occupiedSlots.add(k) + } + + for (let i = 0; i < coords.length; i++) { + const { sx, sy } = coords[i] + for (let n = 0; n <= 4; n++) { + const p = wrapPage(page + n - 1) + const users = cache.get(p) || [] + const user = users[i] || null + const isAbove = sy < bannerBottom + + // 跳过上方有数据的(会被移位渲染) + if (isAbove && user) continue + + // 检查这个空位是否被移位 cabin 占用 + const slotIdx = emptySlots.findIndex(s => s.sx === sx && s.sy === sy && s.n === n) + if (slotIdx !== -1 && occupiedSlots.has(slotIdx)) continue + + const typeIdx = cabinTypeByLevel(user ? user.level : 0) + const def = CABIN_DEFS[typeIdx] + const render = cabinRenderDefs[typeIdx] + const cabinY = sy - render.offsetY + + result.push(reactive({ + key: `${i}-${n}`, + x: sx + n * w - render.offsetX, + y: cabinY, + w: render.renderedW, + h: render.renderedH, + src: def.src, + userId: user ? user.user_id : null, + galleryOwnerId: user ? user.gallery_owner_id : null, + nickname: user ? user.nickname : null, + sharedBoothSlotsRemaining: user ? user.shared_booth_slots_remaining : null, + showNickname: !isAbove, + isMine: false, + showDialog: false, + })) + } + } + + // 第三遍:把移位的 cabin 放到对应空位坐标上 + for (let k = 0; k < toRelocate.length; k++) { + const { n, user, typeIdx } = toRelocate[k] + const def = CABIN_DEFS[typeIdx] + const render = cabinRenderDefs[typeIdx] + + if (k >= emptySlots.length) continue + + const targetSx = emptySlots[k].sx + const targetSy = emptySlots[k].sy + + result.push(reactive({ + key: `reloc-${k}-${n}`, + x: targetSx + n * w - render.offsetX, + y: targetSy - render.offsetY, + w: render.renderedW, + h: render.renderedH, + src: def.src, + userId: user.user_id, + galleryOwnerId: user.gallery_owner_id, + nickname: user.nickname, + sharedBoothSlotsRemaining: user.shared_booth_slots_remaining, + showNickname: true, + isMine: false, + showDialog: false, + })) + } + + return result + } + + // 翻页或坐标初始化时全量重建 + watch([currentPage, scaledCoords], () => { + visibleCabins.value = buildVisibleCabins(currentPage.value, pageCache.value, scaledCoords.value) + }, { immediate: true }) + + // 数据加载完成时重建 + watch(pageCacheVersion, () => { + visibleCabins.value = buildVisibleCabins(currentPage.value, pageCache.value, scaledCoords.value) + }) + + const initCabin = ({ screenW, tileW, imageH, currentUserNickname }) => { + screenWidth = screenW + tileWidth = tileW + + // 计算 banner 底部边界 + const rpxToPx = screenWidth / 750 + bannerBottom = 496 * rpxToPx + + // 计算 cabin 渲染尺寸 + const baseH = Math.round(screenWidth * 0.2 * 1.3) + cabinRenderDefs = CABIN_DEFS.map(({ imgW, imgH, anchorX, anchorY }) => { + const renderedH = baseH + const renderedW = Math.round(renderedH * (imgW / imgH)) + return { + renderedW, + renderedH, + offsetX: Math.round(renderedW * (anchorX / imgW)), + offsetY: Math.round(renderedH * (anchorY / imgH)), + } + }) + + // 生成并缩放坐标 + const scale = imageH / 1918 + const coords = generateGridCoordinates() + scaledCoords.value = coords.map(({ x, y }) => ({ + sx: x * scale, + sy: y * scale, + })) + } + + const updateCurrentUserNickname = (nickname) => { + visibleCabins.value.forEach(cabin => { + if (cabin.nickname === nickname) { + cabin.isMine = true + } + }) + } + + const resetSquare = async () => { + currentPage.value = 1 + totalUsers.value = 0 + pageCache.value = new Map() + pageCacheVersion.value++ + await fetchPage(1) + ensurePages(1) + } + + return { + visibleCabins, + currentPage, + scaledCoords, + cabinRenderDefs, + fetchPage, + ensurePages, + initCabin, + updateCurrentUserNickname, + resetSquare, + wrapPage, + } +} diff --git a/frontend/pages/square/composables/useDialogRotation.js b/frontend/pages/square/composables/useDialogRotation.js new file mode 100644 index 0000000..7bfd9dd --- /dev/null +++ b/frontend/pages/square/composables/useDialogRotation.js @@ -0,0 +1,84 @@ +import { ref } from 'vue' + +export function useDialogRotation() { + let timer = null + let screenWidth = 375 + let screenHeight = 812 + + const initDialogRotation = ({ screenW, screenH }) => { + screenWidth = screenW + screenHeight = screenH + } + + const isCabinInViewport = (cabin) => { + return new Promise((resolve) => { + const query = uni.createSelectorQuery() + query.select(`#cabin-${cabin.key}`).boundingClientRect((rect) => { + if (!rect) { + resolve(false) + return + } + + const visibleTop = Math.max(0, rect.top) + const visibleBottom = Math.min(screenHeight, rect.bottom) + const visibleLeft = Math.max(0, rect.left) + const visibleRight = Math.min(screenWidth, rect.right) + const visibleWidth = Math.max(0, visibleRight - visibleLeft) + const visibleHeight = Math.max(0, visibleBottom - visibleTop) + const visibleArea = visibleWidth * visibleHeight + const totalArea = rect.width * rect.height + const visiblePercent = totalArea > 0 ? visibleArea / totalArea : 0 + + resolve(visiblePercent > 0.7) + }).exec() + }) + } + + const rotateDialogVisibility = async (cabins) => { + if (!cabins.length) return + + // 先全部重置为 false + cabins.forEach(c => c.showDialog = false) + + // 筛选符合条件的 cabin:有昵称、有展位剩余 + const hasData = cabins.filter(c => c.nickname && c.sharedBoothSlotsRemaining !== null) + + // 批量检查所有 cabin 是否在可视区域内 + const viewportChecks = await Promise.all(hasData.map(c => isCabinInViewport(c))) + const eligible = hasData.filter((c, i) => viewportChecks[i]) + + if (eligible.length === 0) return + + // 随机选择 2-3 个 + const count = Math.min(Math.floor(Math.random() * 2) + 2, eligible.length, 3) + const shuffled = eligible.sort(() => Math.random() - 0.5) + for (let i = 0; i < count; i++) { + shuffled[i].showDialog = true + } + } + + const startDialogRotation = (cabins) => { + stopDialogRotation() + + const rotate = async () => { + await rotateDialogVisibility(cabins) + const interval = Math.floor(Math.random() * 1000) + 2000 + timer = setTimeout(rotate, interval) + } + + rotate() + } + + const stopDialogRotation = () => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + + return { + initDialogRotation, + startDialogRotation, + stopDialogRotation, + } +} diff --git a/frontend/pages/square/composables/useSwipe.js b/frontend/pages/square/composables/useSwipe.js new file mode 100644 index 0000000..eb8e2d9 --- /dev/null +++ b/frontend/pages/square/composables/useSwipe.js @@ -0,0 +1,181 @@ +import { ref, computed } from 'vue' + +// RAF 封装 +const rafFn = (cb) => uni.requestAnimationFrame ? uni.requestAnimationFrame(cb) : setTimeout(cb, 16) +const cafFn = (id) => uni.cancelAnimationFrame ? uni.cancelAnimationFrame(id) : clearTimeout(id) + +export function useSwipe() { + const bgOffsetX = ref(0) + const screenWidth = ref(375) + const tileWidth = ref(375) + + let rawOffsetX = 0 + let touchStartX = 0 + let lastMoveX = 0 + let lastMoveTime = 0 + let velocity = 0 + let inertiaRaf = null + let isInertiaPhase = false + let touchInBanner = false + + let onTileChange = null + + const cabinLayerStyle = computed(() => ({ + transform: `translateX(${-tileWidth.value + bgOffsetX.value}px)` + })) + + const backgroundStripStyle = computed(() => ({ + width: `${tileWidth.value * 3}px`, + transform: `translateX(${-tileWidth.value + bgOffsetX.value}px)` + })) + + const clampOffset = (offset) => { + const w = tileWidth.value + return (((offset % w) + w) % w) - w + } + + const normalizeOffset = (offset) => { + const w = tileWidth.value + const normalized = clampOffset(offset) + const prevTileN = Math.floor(-rawOffsetX / w) + rawOffsetX += offset - bgOffsetX.value + const nextTileN = Math.floor(-rawOffsetX / w) + const delta = nextTileN - prevTileN + + if (delta !== 0 && onTileChange) { + onTileChange(delta, isInertiaPhase) + } + + return normalized + } + + const stopInertia = () => { + if (inertiaRaf) { + cafFn(inertiaRaf) + inertiaRaf = null + } + isInertiaPhase = false + } + + const scrollPage = (direction) => { + stopInertia() + + if (onTileChange) { + onTileChange(direction, false) + } + + const DURATION = 300 + const FRAME = 16 + const totalFrames = Math.round(DURATION / FRAME) + const totalDelta = tileWidth.value * direction * -1 + let frame = 0 + + const step = () => { + frame++ + const progress = frame / totalFrames + const eased = 1 - Math.pow(1 - progress, 3) + const prevEased = frame === 1 ? 0 : 1 - Math.pow(1 - (frame - 1) / totalFrames, 3) + const delta = totalDelta * (eased - prevEased) + + bgOffsetX.value = clampOffset(bgOffsetX.value + delta) + rawOffsetX += delta + + if (frame < totalFrames) { + inertiaRaf = rafFn(step) + } + } + + inertiaRaf = rafFn(step) + } + + const getBannerBottom = () => (screenWidth.value / 750) * 496 + + const onBgTouchStart = (e) => { + const touchY = e.touches[0].clientY + touchInBanner = touchY < getBannerBottom() + if (touchInBanner) return + + stopInertia() + touchStartX = e.touches[0].clientX + lastMoveX = touchStartX + lastMoveTime = Date.now() + velocity = 0 + } + + const onBgTouchMove = (e) => { + if (touchInBanner) return + e.preventDefault() + + const currentX = e.touches[0].clientX + const now = Date.now() + const dt = now - lastMoveTime || 1 + velocity = (currentX - lastMoveX) / dt + lastMoveX = currentX + lastMoveTime = now + + bgOffsetX.value = normalizeOffset(bgOffsetX.value + (currentX - touchStartX)) + touchStartX = currentX + } + + const onBgTouchEnd = () => { + if (touchInBanner) { + touchInBanner = false + return + } + touchInBanner = false + isInertiaPhase = true + + const FRICTION = 0.8 + const MIN_VELOCITY = 0.2 + + const step = () => { + velocity *= FRICTION + if (Math.abs(velocity) < MIN_VELOCITY) { + isInertiaPhase = false + return + } + bgOffsetX.value = normalizeOffset(bgOffsetX.value + velocity * 16) + inertiaRaf = rafFn(step) + } + + inertiaRaf = rafFn(step) + } + + const onBgTouchCancel = () => { + touchInBanner = false + stopInertia() + velocity = 0 + } + + const initSwipe = ({ screenW, tileW, onTileChangeCallback }) => { + screenWidth.value = screenW + tileWidth.value = tileW + onTileChange = onTileChangeCallback + bgOffsetX.value = 0 + rawOffsetX = 0 + velocity = 0 + } + + const reset = () => { + stopInertia() + bgOffsetX.value = 0 + rawOffsetX = 0 + velocity = 0 + } + + return { + bgOffsetX, + rawOffsetX, + velocity, + cabinLayerStyle, + backgroundStripStyle, + scrollPage, + stopInertia, + initSwipe, + reset, + onBgTouchStart, + onBgTouchMove, + onBgTouchEnd, + onBgTouchCancel, + } +} diff --git a/frontend/pages/square/config/cabin.js b/frontend/pages/square/config/cabin.js new file mode 100644 index 0000000..2dc76cc --- /dev/null +++ b/frontend/pages/square/config/cabin.js @@ -0,0 +1,116 @@ +// ========== 图片原始尺寸 ========== +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: 180, // 减小垂直间距 + staggered: true, + staggerOffsetX: 260, + cellWidth: 200, + cellHeight: 150, + excludeRows: [0, 1, 2], + }, + // 手动微调个别位置 + manualAdjustments: { + // 可以在这里添加需要微调的坐标 + // 格式: index: { x: newX, y: newY } + 16: { x: -255, y: 740 }, + 17: { x: 255, y: 740 }, + 18: { x: 750, y: 740 }, + 19: { x: 1265, y: 740 }, + 20: { x: 0, y: 900 }, + 21: { x: 505, y: 900 }, + 22: { x: 1010, y: 900 }, + 23: { x: 1515, y: 900 }, + 24: { x: -245, y: 1120 }, + 25: { x: 255, y: 1120 }, + 26: { x: 750, y: 1120 }, + 27: { x: 1265, y: 1120 }, + 28: { x: 0, y: 1300 }, + 29: { x: 505, y: 1300 }, + 30: { x: 1010, y: 1300 }, + 31: { x: 1515, y: 1300 }, + 32: { x: -245, y: 1520 }, + 33: { x: 255, y: 1520 }, + 34: { x: 750, y: 1520 }, + 35: { x: 1265, y: 1520 }, + 36: { x: 0, y: 1690 }, + 37: { x: 505, y: 1690 }, + 38: { x: 1010, y: 1690 }, + 39: { x: 1515, y: 1690 }, + 40: { x: -245, y: 1880 }, + 41: { x: 255, y: 1880 }, + 42: { x: 750, y: 1880 }, + 43: { x: 1265, y: 1880 }, + } + + +} + +// ========== 网格坐标生成函数 ========== +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)) { + for (let col = 0; col < cols; col++) { + coords.push({ + x: 0, + y: 0, + row, + col, + index: coords.length + }) + } + } else { + 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 + }) + } + } + } + + // 应用手动微调 + Object.entries(config.manualAdjustments || {}).forEach(([index, adjustment]) => { + const idx = parseInt(index) + if (coords[idx]) { + coords[idx].x = adjustment.x ?? coords[idx].x + coords[idx].y = adjustment.y ?? coords[idx].y + } + }) + + return coords +} diff --git a/frontend/pages/square/debug-grid.vue b/frontend/pages/square/debug-grid.vue new file mode 100644 index 0000000..e8bcc53 --- /dev/null +++ b/frontend/pages/square/debug-grid.vue @@ -0,0 +1,696 @@ + + + + + diff --git a/frontend/pages/square/square.vue b/frontend/pages/square/square.vue index b5d4abb..c61ca37 100644 --- a/frontend/pages/square/square.vue +++ b/frontend/pages/square/square.vue @@ -1,1197 +1,410 @@ \ No newline at end of file + +.debug-btn { + position: fixed; + bottom: 200rpx; + right: 20rpx; + width: 100rpx; + height: 100rpx; + background: rgba(255, 0, 0, 0.8); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + z-index: 999; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.3); +} + +.debug-text { + font-size: 24rpx; + color: #ffffff; + font-weight: bold; +} + diff --git a/frontend/pages/square/square.vue.old b/frontend/pages/square/square.vue.old deleted file mode 100644 index b5d4abb..0000000 --- a/frontend/pages/square/square.vue.old +++ /dev/null @@ -1,1197 +0,0 @@ - - - - - \ No newline at end of file