refactor: 重构广场的页面

This commit is contained in:
zerosaturation 2026-04-13 01:02:57 +08:00
parent ca2fe80adb
commit 65a5bf82cc
13 changed files with 2161 additions and 2595 deletions

View File

@ -8,6 +8,15 @@
}
}
},
{
"path": "pages/square/debug-grid",
"style": {
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
}
}
},
{
"path": "pages/starbook/index",
"style": {

View File

@ -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
<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>
<!-- Top3 单独处理,不在 swiper 内 -->
<view class="top3-area" @click="$emit('top3Click')">
<BannerTop3 />
</view>
</view>
```
@ -311,7 +333,7 @@ const startDialogRotation = () => {
<image v-for="i in 3" :key="i" class="background-tile" :style="..." src="/static/background/mainbg.png" />
</view>
<!-- Cabin 层 -->
<!-- Cabin 层(只渲染可见格子中的小屋) -->
<view class="cabin-layer" :style="cabinLayerStyle">
<CabinItem
v-for="cabin in visibleCabins"
@ -348,6 +370,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,261 +400,176 @@ 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,
// 格子间距
rows: 11, // 总行数
cols: 4, // 每行格子数
startX: -260, // 起始 X相对于背景图左上角
startY: -20, // 起始 Y
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],
// ... 可根据背景图调整
]
staggered: true, // 奇偶行交错
staggerOffsetX: 260, // 交错偏移量
cellWidth: 200, // 格子宽度(用于碰撞检测)
cellHeight: 150, // 格子高度
excludeRows: [0, 1, 2], // 排除的行(前 3 行被 banner 遮挡)
}
}
// 自动生成坐标函数
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) {
export function generateGridCoordinates(config = GRID_CONFIG) {
const { rows, cols, startX, startY, spacingX, spacingY, staggered, staggerOffsetX, excludeRows } = config.grid
const coords = []
template.rows.forEach((row, rowIndex) => {
row.cells.forEach((cell, colIndex) => {
if (cell.enabled) {
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: cell.x,
y: row.y,
row: rowIndex,
col: colIndex,
x: startX + col * spacingX + offsetX,
y: startY + row * spacingY,
row,
col,
index: coords.length
})
}
})
})
}
return coords
}
```
#### 方案 C混合方案推荐用于你的场景
结合规则网格和手动微调
### 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
// 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
const coords = generateGridCoordinates(config)
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++
}
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
@ -650,31 +588,35 @@ export { GRID_CONFIG } from './gridLayout.js'
<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'
import { GRID_CONFIG, generateGridCoordinates } from './config/cabin.js'
const spacingX = ref(GRID_CONFIG.baseGrid.spacingX)
// 实时预览网格调整效果
const spacingX = ref(GRID_CONFIG.grid.spacingX)
const debugCoords = computed(() => generateGridCoordinates({
...GRID_CONFIG,
grid: { ...GRID_CONFIG.grid, spacingX: spacingX.value }
}))
</script>
```
### 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: 实现其他 composablesuseSwipe、useBanner、useDialogRotation
Step 5: 实现 componentsCabinItem、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 测试
```

View File

@ -0,0 +1,70 @@
<template>
<view class="banner-carousel" @click.stop>
<swiper
class="banner-swiper"
:autoplay="true"
:interval="4000"
:duration="400"
:circular="true"
:indicator-dots="false"
>
<swiper-item @click.stop="$emit('top3Click')">
<BannerTop3 />
</swiper-item>
<swiper-item
v-for="item in bannerActivities"
:key="item.id"
@click.stop="$emit('activityClick', item)"
>
<image
class="banner-activity-img"
:src="item.cover_image || '/static/avatar/1.jpeg'"
mode="aspectFill"
/>
</swiper-item>
</swiper>
</view>
</template>
<script setup>
import BannerTop3 from '../../components/BannerTop3.vue'
defineProps({
bannerActivities: {
type: Array,
default: () => []
}
})
defineEmits(['activityClick', 'top3Click'])
</script>
<style scoped>
.banner-carousel {
position: fixed;
top: 216rpx;
left: 0;
right: 0;
width: 100%;
z-index: 100;
padding: 0 8rpx;
box-sizing: border-box;
}
.banner-swiper {
width: 100%;
height: 312rpx;
border-radius: 24rpx;
overflow: hidden;
}
.banner-activity-img {
width: 100%;
height: 100%;
display: block;
}
:deep(.uni-swiper-slide) {
overflow: hidden;
}
</style>

View File

@ -0,0 +1,117 @@
<template>
<view
:id="'cabin-' + cabin.key"
class="cabin-wrapper"
:class="{
'cabin-nickname-mine': cabin.isMine || cabin.nickname === currentUserNickname,
'cabin-slots-zero': cabin.sharedBoothSlotsRemaining === 0
}"
:style="{ left: cabin.x + 'px', top: cabin.y + 'px', width: cabin.w + 'px' }"
@click="handleClick"
>
<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>
</template>
<script setup>
const props = defineProps({
cabin: {
type: Object,
required: true
},
currentUserNickname: {
type: String,
default: ''
}
})
const emit = defineEmits(['click'])
const handleClick = () => {
emit('click', props.cabin)
}
</script>
<style scoped>
.cabin-wrapper {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: auto;
}
.cabin-icon {
display: block;
}
.cabin-slots-zero .cabin-icon {
filter: grayscale(100%);
opacity: 0.6;
}
.cabin-nickname {
font-size: 20rpx;
color: #ffffff;
text-align: center;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.6);
margin-top: 4rpx;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cabin-nickname--empty {
color: rgba(255, 255, 255, 0.75);
font-style: italic;
}
.cabin-slots-dialog {
position: absolute;
top: -50rpx;
left: 50%;
transform: translateX(-50%);
background-image: url('/static/icon/tips-bg.png');
background-size: 100% 100%;
background-repeat: no-repeat;
padding: 8rpx 24rpx 18rpx;
width: max-content;
z-index: 10;
}
.cabin-slots-text {
font-size: 18rpx;
font-weight: 600;
white-space: nowrap;
text-align: center;
}
.text-white {
color: #ffffff;
}
.text-orange {
color: #FFB800;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow:
0 0 10rpx rgba(255, 184, 0, 0.8),
0 2rpx 4rpx rgba(0, 0, 0, 0.5);
}
</style>

View File

@ -0,0 +1,58 @@
<template>
<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>
</template>
<script setup>
defineEmits(['scroll'])
</script>
<style scoped>
.nav-arrows {
position: fixed;
top: 50%;
left: 24rpx;
right: 24rpx;
transform: translateY(-50%);
display: flex;
justify-content: space-between;
align-items: center;
pointer-events: none;
z-index: 10;
}
.arrow-btn {
width: 56rpx;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(165deg,
#f0e4b1 0%,
#f08399 50%,
#b94e73 90%,
#834b9e 100%);
border-radius: 12rpx;
pointer-events: auto;
cursor: pointer;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.25);
}
.arrow-btn:active {
opacity: 0.8;
}
.arrow-text {
font-size: 56rpx;
color: #ffffff;
font-weight: 600;
line-height: 1;
margin-top: -6rpx;
}
</style>

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -0,0 +1,696 @@
<template>
<view
class="debug-container"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- 背景条3张图并排无限滚动 -->
<view class="background-strip" :style="backgroundStripStyle">
<image
v-for="i in 3"
:key="i"
class="background-tile"
:style="{ width: tileWidth + 'px', height: '100%' }"
src="/static/background/mainbg.png"
/>
</view>
<!-- 网格点层 -->
<view class="grid-layer" :style="gridLayerStyle">
<!-- 渲染 5 tile 的网格点n=0,1,2,3,4 -->
<view
v-for="point in visiblePoints"
:key="point.key"
class="grid-point"
:class="{
'grid-point--excluded': point.isExcluded,
'grid-point--selected': selectedIndex === point.originalIndex
}"
:style="{
left: point.x + 'px',
top: point.y + 'px',
transform: 'translate(-50%, -50%)'
}"
@click="selectPoint(point.originalIndex)"
>
<view class="grid-dot"></view>
<text class="grid-label">{{ point.originalIndex }}</text>
<text class="grid-coords">({{ Math.round(point.originalX) }}, {{ Math.round(point.originalY) }})</text>
</view>
</view>
<!-- 控制面板 -->
<view class="control-panel" @click.stop>
<view class="panel-header">
<text class="panel-title">网格调试工具</text>
<button class="btn-close" @click="goBack">返回</button>
</view>
<!-- 单点调试模式 -->
<view v-if="selectedIndex !== null" class="point-edit-section">
<view class="point-edit-header">
<text class="point-edit-title">编辑点位 #{{ selectedIndex }}</text>
<button class="btn-deselect" @click="deselectPoint">取消选择</button>
</view>
<view class="point-edit-controls">
<view class="point-edit-row">
<text class="point-edit-label">X: {{ currentPoint.x }}</text>
<view class="point-edit-buttons">
<button class="btn-adjust" @click="adjustPoint('x', -10)">-10</button>
<button class="btn-adjust" @click="adjustPoint('x', -1)">-1</button>
<button class="btn-adjust" @click="adjustPoint('x', 1)">+1</button>
<button class="btn-adjust" @click="adjustPoint('x', 10)">+10</button>
</view>
</view>
<view class="point-edit-row">
<text class="point-edit-label">Y: {{ currentPoint.y }}</text>
<view class="point-edit-buttons">
<button class="btn-adjust" @click="adjustPoint('y', -10)">-10</button>
<button class="btn-adjust" @click="adjustPoint('y', -1)">-1</button>
<button class="btn-adjust" @click="adjustPoint('y', 1)">+1</button>
<button class="btn-adjust" @click="adjustPoint('y', 10)">+10</button>
</view>
</view>
<view class="point-edit-row">
<button class="btn-save-point" @click="savePointAdjustment">保存此点位调整</button>
<button class="btn-reset-point" @click="resetPoint">重置此点位</button>
</view>
</view>
</view>
<!-- 全局参数调整 -->
<view v-else class="global-controls">
<view class="control-section">
<text class="control-label">起始Y (startY): {{ config.grid.startY }}</text>
<slider
:value="config.grid.startY"
@change="updateStartY"
min="-200"
max="100"
step="10"
show-value
/>
</view>
<view class="control-section">
<text class="control-label">垂直间距 (spacingY): {{ config.grid.spacingY }}</text>
<slider
:value="config.grid.spacingY"
@change="updateSpacingY"
min="150"
max="250"
step="5"
show-value
/>
</view>
<view class="control-section">
<text class="control-label">水平间距 (spacingX): {{ config.grid.spacingX }}</text>
<slider
:value="config.grid.spacingX"
@change="updateSpacingX"
min="450"
max="600"
step="5"
show-value
/>
</view>
<view class="control-section">
<text class="control-label">交错偏移 (staggerOffsetX): {{ config.grid.staggerOffsetX }}</text>
<slider
:value="config.grid.staggerOffsetX"
@change="updateStaggerOffsetX"
min="200"
max="350"
step="5"
show-value
/>
</view>
</view>
<view class="control-section">
<button class="btn-export" @click="exportConfig">导出配置</button>
<button class="btn-reset" @click="resetConfig">重置全部</button>
</view>
<view class="coords-list">
<text class="coords-title">生成的坐标列表点击点位编辑</text>
<scroll-view class="coords-scroll" scroll-y>
<view
v-for="(coord, i) in originalCoords"
:key="i"
class="coord-item"
:class="{ 'coord-item--selected': selectedIndex === i }"
@click="selectPoint(i)"
>
<text class="coord-index">{{ i }}:</text>
<text class="coord-value">[{{ coord.x }}, {{ coord.y }}]</text>
<text v-if="coord.x === 0 && coord.y === 0" class="coord-excluded">(排除)</text>
<text v-if="hasAdjustment(i)" class="coord-adjusted">(已调整)</text>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { GRID_CONFIG, generateGridCoordinates, IMAGE_W, IMAGE_H } from './config/cabin.js'
const config = ref(JSON.parse(JSON.stringify(GRID_CONFIG)))
const selectedIndex = ref(null)
//
const screenWidth = ref(375)
const screenHeight = ref(812)
const tileWidth = ref(375)
const scale = ref(1)
//
const bgOffsetX = ref(0)
let touchStartX = 0
let lastMoveX = 0
//
const originalCoords = computed(() => {
return generateGridCoordinates(config.value)
})
//
const scaledCoords = computed(() => {
return originalCoords.value.map(coord => ({
x: coord.x * scale.value,
y: coord.y * scale.value,
originalX: coord.x,
originalY: coord.y,
isExcluded: coord.x === 0 && coord.y === 0
}))
})
// 5tile
const visiblePoints = computed(() => {
const w = tileWidth.value
const points = []
for (let n = 0; n <= 4; n++) {
scaledCoords.value.forEach((coord, i) => {
points.push({
key: `${i}-${n}`,
x: coord.x + n * w,
y: coord.y,
originalX: coord.originalX,
originalY: coord.originalY,
originalIndex: i,
isExcluded: coord.isExcluded
})
})
}
return points
})
//
const backgroundStripStyle = computed(() => {
const centerOffset = (screenWidth.value - tileWidth.value) / 2
return {
width: `${tileWidth.value * 3}px`,
transform: `translateX(${-tileWidth.value + centerOffset + bgOffsetX.value}px)`
}
})
//
const gridLayerStyle = computed(() => {
const centerOffset = (screenWidth.value - tileWidth.value) / 2
return {
transform: `translateX(${-tileWidth.value + centerOffset + bgOffsetX.value}px)`
}
})
//
const currentPoint = computed(() => {
if (selectedIndex.value === null) return { x: 0, y: 0 }
return originalCoords.value[selectedIndex.value] || { x: 0, y: 0 }
})
//
const clampOffset = (offset) => {
const w = tileWidth.value
return (((offset % w) + w) % w) - w
}
//
const onTouchStart = (e) => {
touchStartX = e.touches[0].clientX
lastMoveX = touchStartX
}
const onTouchMove = (e) => {
e.preventDefault()
const currentX = e.touches[0].clientX
const delta = currentX - lastMoveX
lastMoveX = currentX
bgOffsetX.value = clampOffset(bgOffsetX.value + delta)
}
const onTouchEnd = () => {
//
}
const selectPoint = (index) => {
selectedIndex.value = index
console.log('选中点位:', index, originalCoords.value[index])
}
const deselectPoint = () => {
selectedIndex.value = null
}
const adjustPoint = (axis, delta) => {
if (selectedIndex.value === null) return
const idx = selectedIndex.value
if (!config.value.manualAdjustments) {
config.value.manualAdjustments = {}
}
const current = originalCoords.value[idx]
if (!config.value.manualAdjustments[idx]) {
config.value.manualAdjustments[idx] = { x: current.x, y: current.y }
}
if (axis === 'x') {
config.value.manualAdjustments[idx].x += delta
} else {
config.value.manualAdjustments[idx].y += delta
}
config.value = { ...config.value }
}
const savePointAdjustment = () => {
uni.showToast({
title: `点位 #${selectedIndex.value} 已保存`,
icon: 'success'
})
console.log('保存点位调整:', selectedIndex.value, config.value.manualAdjustments[selectedIndex.value])
}
const resetPoint = () => {
if (selectedIndex.value === null) return
if (config.value.manualAdjustments && config.value.manualAdjustments[selectedIndex.value]) {
delete config.value.manualAdjustments[selectedIndex.value]
config.value = { ...config.value }
uni.showToast({
title: '点位已重置',
icon: 'success'
})
}
}
const hasAdjustment = (index) => {
return config.value.manualAdjustments && config.value.manualAdjustments[index]
}
const updateStartY = (e) => {
config.value.grid.startY = e.detail.value
}
const updateSpacingY = (e) => {
config.value.grid.spacingY = e.detail.value
}
const updateSpacingX = (e) => {
config.value.grid.spacingX = e.detail.value
}
const updateStaggerOffsetX = (e) => {
config.value.grid.staggerOffsetX = e.detail.value
}
const exportConfig = () => {
const configStr = JSON.stringify(config.value, null, 2)
console.log('========== 当前配置 ==========')
console.log(configStr)
const coordsCode = originalCoords.value.map(c => ` [${c.x}, ${c.y}]`).join(',\n')
console.log('\n========== 坐标数组 ==========')
console.log('[\n' + coordsCode + '\n]')
if (config.value.manualAdjustments && Object.keys(config.value.manualAdjustments).length > 0) {
console.log('\n========== 手动调整 ==========')
console.log('manualAdjustments: {')
Object.entries(config.value.manualAdjustments).forEach(([idx, adj]) => {
console.log(` ${idx}: { x: ${adj.x}, y: ${adj.y} },`)
})
console.log('}')
}
uni.showModal({
title: '配置已导出',
content: '请查看控制台输出,复制到 config/cabin.js',
showCancel: false
})
}
const resetConfig = () => {
uni.showModal({
title: '确认重置',
content: '将重置所有参数和调整,是否继续?',
success: (res) => {
if (res.confirm) {
config.value = JSON.parse(JSON.stringify(GRID_CONFIG))
selectedIndex.value = null
uni.showToast({
title: '已重置',
icon: 'success'
})
}
}
})
}
const goBack = () => {
uni.navigateBack()
}
//
const init = () => {
const info = uni.getSystemInfoSync()
screenWidth.value = info.windowWidth
screenHeight.value = info.windowHeight
tileWidth.value = Math.round(info.windowHeight * (IMAGE_W / IMAGE_H))
scale.value = info.windowHeight / IMAGE_H
console.log('调试页面初始化:', {
screenWidth: screenWidth.value,
screenHeight: screenHeight.value,
tileWidth: tileWidth.value,
scale: scale.value
})
}
init()
</script>
<style scoped>
.debug-container {
position: relative;
width: 100vw;
min-height: calc(100vh + 650rpx);
overflow: hidden;
background: #1a1a1a;
padding-bottom: 650rpx;
}
.background-strip {
position: absolute;
top: 0;
left: 0;
height: 100vh;
display: flex;
z-index: 0;
will-change: transform;
}
.background-tile {
flex-shrink: 0;
height: 100%;
}
.grid-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
}
.grid-point {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
z-index: 10;
pointer-events: auto;
}
.grid-point--excluded {
opacity: 0.3;
}
.grid-dot {
width: 20rpx;
height: 20rpx;
background: #ff0000;
border-radius: 50%;
border: 2rpx solid #ffffff;
box-shadow: 0 0 10rpx rgba(255, 0, 0, 0.8);
}
.grid-point--selected .grid-dot {
background: #00ff00;
width: 30rpx;
height: 30rpx;
box-shadow: 0 0 20rpx rgba(0, 255, 0, 1);
}
.grid-label {
font-size: 20rpx;
color: #ffffff;
background: rgba(0, 0, 0, 0.7);
padding: 2rpx 8rpx;
border-radius: 4rpx;
margin-top: 4rpx;
font-weight: bold;
}
.grid-point--selected .grid-label {
background: rgba(0, 255, 0, 0.9);
color: #000000;
font-size: 24rpx;
}
.grid-coords {
font-size: 18rpx;
color: #00ff00;
background: rgba(0, 0, 0, 0.7);
padding: 2rpx 6rpx;
border-radius: 4rpx;
margin-top: 2rpx;
}
.control-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #2a2a2a;
border-top: 2rpx solid #444;
padding: 20rpx;
max-height: 600rpx;
overflow-y: auto;
z-index: 100;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.panel-title {
font-size: 32rpx;
color: #ffffff;
font-weight: bold;
}
.btn-close {
padding: 10rpx 20rpx;
background: #ff4444;
color: #ffffff;
border: none;
border-radius: 8rpx;
font-size: 24rpx;
}
.control-section {
margin-bottom: 30rpx;
}
.control-label {
display: block;
font-size: 26rpx;
color: #ffffff;
margin-bottom: 10rpx;
}
.btn-export,
.btn-reset {
width: 48%;
padding: 20rpx;
background: #4CAF50;
color: #ffffff;
border: none;
border-radius: 8rpx;
font-size: 28rpx;
margin-right: 4%;
}
.btn-reset {
background: #ff9800;
margin-right: 0;
}
.coords-list {
margin-top: 30rpx;
background: #1a1a1a;
border-radius: 8rpx;
padding: 20rpx;
}
.coords-title {
display: block;
font-size: 28rpx;
color: #ffffff;
margin-bottom: 10rpx;
font-weight: bold;
}
.coords-scroll {
max-height: 400rpx;
}
.coord-item {
display: flex;
align-items: center;
padding: 8rpx 0;
border-bottom: 1rpx solid #333;
cursor: pointer;
}
.coord-item--selected {
background: rgba(0, 255, 0, 0.2);
}
.coord-index {
font-size: 24rpx;
color: #00ff00;
width: 80rpx;
font-weight: bold;
}
.coord-value {
font-size: 24rpx;
color: #ffffff;
flex: 1;
}
.coord-excluded {
font-size: 20rpx;
color: #ff0000;
margin-left: 10rpx;
}
.coord-adjusted {
font-size: 20rpx;
color: #00ff00;
margin-left: 10rpx;
}
.point-edit-section {
background: #1a1a1a;
border-radius: 8rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.point-edit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.point-edit-title {
font-size: 28rpx;
color: #00ff00;
font-weight: bold;
}
.btn-deselect {
padding: 8rpx 16rpx;
background: #666;
color: #ffffff;
border: none;
border-radius: 6rpx;
font-size: 22rpx;
}
.point-edit-controls {
display: flex;
flex-direction: column;
gap: 15rpx;
}
.point-edit-row {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.point-edit-label {
font-size: 24rpx;
color: #ffffff;
font-weight: bold;
}
.point-edit-buttons {
display: flex;
gap: 10rpx;
}
.btn-adjust {
flex: 1;
padding: 15rpx;
background: #4CAF50;
color: #ffffff;
border: none;
border-radius: 6rpx;
font-size: 24rpx;
}
.btn-save-point,
.btn-reset-point {
width: 48%;
padding: 20rpx;
border: none;
border-radius: 8rpx;
font-size: 26rpx;
color: #ffffff;
}
.btn-save-point {
background: #2196F3;
margin-right: 4%;
}
.btn-reset-point {
background: #ff5722;
}
.global-controls {
margin-bottom: 20rpx;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff