From da6c0a5ef29dac9691966f608cd5170c50dce1b4 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Mon, 25 May 2026 23:35:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E7=80=91=E5=B8=83?= =?UTF-8?q?=E6=B5=81=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/gallery_repository.go | 31 +- .../pages/square/components/WaterfallGrid.vue | 370 +++++++++++------- frontend/pages/square/config/mockData.js | 42 +- 3 files changed, 265 insertions(+), 178 deletions(-) diff --git a/backend/services/galleryService/repository/gallery_repository.go b/backend/services/galleryService/repository/gallery_repository.go index c4c3a8a..7a23422 100644 --- a/backend/services/galleryService/repository/gallery_repository.go +++ b/backend/services/galleryService/repository/gallery_repository.go @@ -88,8 +88,9 @@ type InspirationFlowItem struct { Name string CoverURL string LikeCount int32 + Level string // 藏品等级: N, R, SR, SSR, UR OwnerNickname string - Span int32 // 卡片大小: 0-30→1, 31-100→2, 101-200→3, 200+→4 + Span int32 // 卡片大小: N→1, R→2, SR→3, SSR→4, UR→5 MaterialType string // 素材类型: hot(人气王者), potential(潜力之星), new(新鲜上架) CreatedAt int64 // 创建时间(用于判断是否为潜力之星) } @@ -545,8 +546,9 @@ func (r *galleryRepository) GetRandomExhibitions(starID int64, materialType stri var err error if materialType == "" || materialType == "all" || materialType == "random" { err = baseQuery. - Select(`exhibitions.id as exhibition_id, exhibitions.asset_id, a.name, a.cover_url, a.like_count, fp.nickname as owner_nickname, a.material_type, a.created_at`). + Select(`exhibitions.id as exhibition_id, exhibitions.asset_id, a.name, a.cover_url, a.like_count, COALESCE(alr.current_level, 'N') as level, fp.nickname as owner_nickname, a.material_type, a.created_at`). Joins("JOIN assets a ON a.id = exhibitions.asset_id"). + Joins("LEFT JOIN asset_level_records alr ON alr.asset_id = a.id"). Joins("JOIN fan_profiles fp ON exhibitions.occupier_uid = fp.user_id AND exhibitions.occupier_star_id = fp.star_id"). Where("a.status = 1 AND a.is_active = true"). Order("RANDOM()"). @@ -556,7 +558,8 @@ func (r *galleryRepository) GetRandomExhibitions(starID int64, materialType stri } else { // baseQuery 已经包含了 assets JOIN,不需要重复添加 err = baseQuery. - Select(`exhibitions.id as exhibition_id, exhibitions.asset_id, a.name, a.cover_url, a.like_count, fp.nickname as owner_nickname, a.material_type, a.created_at`). + Select(`exhibitions.id as exhibition_id, exhibitions.asset_id, a.name, a.cover_url, a.like_count, COALESCE(alr.current_level, 'N') as level, fp.nickname as owner_nickname, a.material_type, a.created_at`). + Joins("LEFT JOIN asset_level_records alr ON alr.asset_id = a.id"). Joins("JOIN fan_profiles fp ON exhibitions.occupier_uid = fp.user_id AND exhibitions.occupier_star_id = fp.star_id"). Where("a.status = 1 AND a.is_active = true"). Order("RANDOM()"). @@ -571,7 +574,7 @@ func (r *galleryRepository) GetRandomExhibitions(starID int64, materialType stri // Read-repair: 检查并补充 material_type(多类型共存,不降级) for _, item := range items { - item.Span = calcSpanByLikes(item.LikeCount) + item.Span = calcSpanByLevel(item.Level) correctTypes := calcMaterialTypes(item.LikeCount, item.CreatedAt) // 如果缺少任何类型,追加(不覆盖原有类型) @@ -640,17 +643,23 @@ func calcMaterialTypes(likes int32, createdAt int64) string { return strings.Join(types, ",") } -// calcSpanByLikes 根据点赞数计算卡片大小 -// 0-30 → span 1, 31-100 → span 2, 101-200 → span 3, 200+ → span 4 -func calcSpanByLikes(likes int32) int32 { - if likes <= 30 { +// calcSpanByLevel 根据藏品等级计算卡片大小 +// N → span 1, R → span 2, SR → span 3, SSR → span 4, UR → span 5 +func calcSpanByLevel(level string) int32 { + switch level { + case "N": return 1 - } else if likes <= 100 { + case "R": return 2 - } else if likes <= 200 { + case "SR": return 3 + case "SSR": + return 4 + case "UR": + return 5 + default: + return 1 // 默认返回1 } - return 4 } // GetSlotOwnerUserID 获取展位所有者的用户ID diff --git a/frontend/pages/square/components/WaterfallGrid.vue b/frontend/pages/square/components/WaterfallGrid.vue index 8340e6f..d0d0d91 100644 --- a/frontend/pages/square/components/WaterfallGrid.vue +++ b/frontend/pages/square/components/WaterfallGrid.vue @@ -4,30 +4,32 @@ @touchend="onTouchEnd" @touchcancel="onTouchEnd"> - - - + + + + - - + + - - - - + + + + - - - - - {{ formatLikes(card.likes) }} + + + + + {{ formatLikes(card.likes) }} + - + @@ -104,6 +106,14 @@ const startIOSAutoScroll = () => { const elapsed = (Date.now() % duration) const pos = (elapsed / duration) * scrollDist emit('scroll', pos) + + // iOS 预加载检查(CSS 动画不触发 onScroll,所以在这里检查) + const remainingScroll = scrollDist - pos - props.screenWidth + if (remainingScroll < Math.max(scrollDist / 2, props.screenWidth)) { + if (!isLoadingMore && !appendFailed && !isInitialLoading) { + scheduleAppend() + } + } }, 16) } @@ -162,10 +172,10 @@ const startAutoScroll = () => { } scrollLeft.value = autoScrollPos - // 预加载 + // 预加载(不依赖 useMockData,真实 API 也能追加) const remainingScroll = totalWidth.value - autoScrollPos - props.screenWidth if (remainingScroll < Math.max(totalWidth.value / 2, props.screenWidth)) { - if (!isLoadingMore && !appendFailed && !isInitialLoading && props.useMockData) { + if (!isLoadingMore && !appendFailed && !isInitialLoading) { appendMore() } } @@ -320,174 +330,231 @@ const innerStyle = computed(() => { }) // ========== 布局引擎 ========== -// 核心思路:按列放置,每列的卡片 span 之和 = ROWS,保证零空缺 -// 同一列内所有卡片宽度统一 = 整列宽(span=ROWS 对应的宽),高度各自按 span 计算 -// 支持两种模式: -// 1. 后端传 span:直接按 span 分组成列 -// 2. 后端不传 span:使用分类特定的 span 阈值计算 +// 核心思路:4×4 网格装箱,按块放置(每块16格),优先竖形状,放不下换横,最后填空白 +// span 对应面积: +// span 1: 1×1 = 1格 +// span 2: 优先 1×2(竖) 或 2×1(横) = 2格 +// span 3: 2×2 = 4格 +// span 4: 优先 2×3(竖) 或 3×2(横) = 6格 +// span 5: 3×3 = 9格 +// 注意:所有图片都是 4:3 比例 class WaterfallLayout { - constructor(containerH, category = 'random', gap = GAP) { + constructor(containerW, containerH, gap = GAP) { + this.containerW = containerW this.containerH = containerH - this.category = category + this.category = 'hot' // 默认分类 this.gap = gap - this.rowH = Math.floor((containerH - gap * (ROWS - 1)) / ROWS) - // 列宽固定 = rowH × 9/16,严格竖长 9:16,span 只影响高度 - this.colW = Math.round(this.rowH * 4 / 3) - this.curX = 0 - // 跨批次追踪连续 N 级卡片数量,凑满 5 个后在第 6 个 N 卡前插入空白格 - this.consecutiveNCount = 0 + this.COLS = 4 + this.ROWS = 4 + this.BLOCK_SIZE = this.COLS * this.ROWS // 16格 + this.IMG_RATIO = 4 / 3 // 图片宽高比 4:3 + + // 计算每格尺寸(预留 gap)- 基于 4:3 图片比例计算 + // cellW 是基准宽度,cellH 根据图片比例计算以保持 4:3 + const totalGapW = gap * (this.COLS - 1) + const totalGapH = gap * (this.ROWS - 1) + this.cellW = Math.floor((containerW - totalGapW) / this.COLS) + this.cellH = Math.floor(this.cellW * 3 / 4) // 保持 4:3 比例 + + // 实际行高可能不足以填满容器,调整 + const usedH = this.cellH * this.ROWS + totalGapH + if (usedH > containerH) { + // 高度超出,压缩 cellH + this.cellH = Math.floor((containerH - totalGapH) / this.ROWS) + } } - _isNLevel(item) { - const span = item.span != null ? item.span : this._span(item.likes || 0) - return span === 1 + // span → 形状列表(优先竖的) + // 竖 = 高 > 宽(占列多),横 = 宽 > 高(占行多) + _getShapes(span) { + switch (span) { + case 1: return [{ w: 1, h: 1, vertical: false }] + case 2: return [ + { w: 1, h: 2, vertical: true }, // 竖:窄长 + { w: 2, h: 1, vertical: false } // 横:扁宽 + ] + case 3: return [{ w: 2, h: 2, vertical: false }] + case 4: return [ + { w: 2, h: 3, vertical: true }, // 竖:高>宽 + { w: 3, h: 2, vertical: false } // 横:宽>高 + ] + case 5: return [{ w: 3, h: 3, vertical: false }] + default: return [{ w: 1, h: 1, vertical: false }] + } } - _cardSize(span) { - const h = Math.round((span * this.rowH + (span - 1) * this.gap) * SCALE) - const w = Math.round(h * 4 / 3) - return { w, h } - } - - // 点赞数 → span(使用分类特定的阈值) - _span(likes) { + // 点赞数 → span + _calcSpan(likes) { return calcSpan(this.category, likes) } - // 将用户列表按点赞数决定 span,分组成列,每列 span 之和 = ROWS - _groupIntoColumns(users) { - // 复制一份避免修改原数组 - const remaining = users.map((u, idx) => ({ - ...u, - originalIndex: idx, - span: u.span != null ? u.span : this._span(u.likes || 0) - })) + // 创建空网格 + _createGrid() { + return Array(this.ROWS).fill(null).map(() => Array(this.COLS).fill(0)) + } - const columns = [] - let colIndex = 0 + // 检查是否能放入 + _canPlace(grid, row, col, w, h) { + if (row + h > this.ROWS || col + w > this.COLS) return false + for (let r = row; r < row + h; r++) { + for (let c = col; c < col + w; c++) { + if (grid[r][c] !== 0) return false + } + } + return true + } - while (remaining.length > 0) { - const col = [] - let sum = 0 - const colStart = users.length - remaining.length + // 标记占用 + _markGrid(grid, row, col, w, h) { + for (let r = row; r < row + h; r++) { + for (let c = col; c < col + w; c++) { + grid[r][c] = 1 + } + } + } - // 尝试放入能填满当前列的卡片 - let i = 0 - while (i < remaining.length) { - const rawSpan = remaining[i].span - const isN = remaining[i]._blank ? false : this._isNLevel(remaining[i]) + // 找空白位置(列优先:先左右,再上下) + _findSpace(grid, w, h) { + for (let c = 0; c <= this.COLS - w; c++) { + for (let r = 0; r <= this.ROWS - h; r++) { + if (this._canPlace(grid, r, c, w, h)) { + return { row: r, col: c } + } + } + } + return null + } - // N 级连续 5 张后,第 6 个 N 卡前先插入 1-2 个空白格 - if (isN && this.consecutiveNCount >= 5) { - const blankCount = Math.random() < 0.5 ? 1 : 2 - for (let b = 0; b < blankCount; b++) { - if (sum + 1 <= ROWS) { - remaining.splice(i, 0, { id: idCounter++, span: 1, _blank: true, likes: 0 }) + // 填空白格(列优先:先左右,再上下) + _fillBlanks(grid, placed, baseX, baseY) { + for (let c = 0; c < this.COLS; c++) { + for (let r = 0; r < this.ROWS; r++) { + if (grid[r][c] === 0) { + placed.push({ + id: idCounter++, + left: baseX + c * (this.cellW + this.gap), + top: baseY + r * (this.cellH + this.gap), + w: this.cellW, + h: this.cellH, + _blank: true, + radius: 8, + }) + grid[r][c] = 1 + } + } + } + } + + // 放置一块(4×4 = 16格) + _placeBlock(items, blockIndex) { + const grid = this._createGrid() + const placed = [] + const baseX = blockIndex * (this.COLS * (this.cellW + this.gap) + this.gap) + const baseY = 0 + + let consecutiveSpan1 = 0 + + for (const item of items) { + const span = item.span != null ? item.span : this._calcSpan(item.likes || 0) + + // span1 连续放置 5 个后,先留 1-2 个空白格再继续 + if (span === 1 && consecutiveSpan1 >= 5) { + const blanks = Math.random() < 0.5 ? 1 : 2 + for (let b = 0; b < blanks; b++) { + const pos = this._findSpace(grid, 1, 1) + if (pos) { + this._markGrid(grid, pos.row, pos.col, 1, 1) + placed.push({ + id: idCounter++, + left: baseX + pos.col * (this.cellW + this.gap), + top: baseY + pos.row * (this.cellH + this.gap), + w: this.cellW, + h: this.cellH, + _blank: true, + radius: 8, + }) + } + } + consecutiveSpan1 = 0 // 重置计数 + } + + const shape = this._getShapes(span)[0] // 只用第一个形状 + let success = false + + // 优先第一个形状,找不到就全网格搜索能放的位置 + if (shape) { + const pos = this._findSpace(grid, shape.w, shape.h) + if (pos) { + this._markGrid(grid, pos.row, pos.col, shape.w, shape.h) + const left = baseX + pos.col * (this.cellW + this.gap) + const top = baseY + pos.row * (this.cellH + this.gap) + const w = shape.w * this.cellW + (shape.w - 1) * this.gap + const h = shape.h * this.cellH + (shape.h - 1) * this.gap + placed.push({ ...item, left, top, w, h, radius: 8 }) + success = true + } + + // 第一个形状放不下,全网格搜索其他可用的位置 + if (!success) { + for (let c = 0; c <= this.COLS - shape.w && !success; c++) { + for (let r = 0; r <= this.ROWS - shape.h && !success; r++) { + if (grid[r][c] === 0) { + this._markGrid(grid, r, c, shape.w, shape.h) + const left = baseX + c * (this.cellW + this.gap) + const top = baseY + r * (this.cellH + this.gap) + const w = shape.w * this.cellW + (shape.w - 1) * this.gap + const h = shape.h * this.cellH + (shape.h - 1) * this.gap + placed.push({ ...item, left, top, w, h, radius: 8 }) + success = true + } } } - this.consecutiveNCount = 0 - // 空白格仅占位,不断 i,直接 break 让外层开新列处理剩余卡片 - break - } - - // 如果当前卡片加入会导致超出 ROWS,尝试下一个 - if (sum + rawSpan > ROWS) { - i++ - // 已检查完所有卡片仍无法放入 → 当前列已满但未凑满 ROWS,强开新列 - if (i >= remaining.length && col.length > 0) { - break - } - continue - } - - // 可以放入,从 remaining 中移除并加入 col - const item = remaining.splice(i, 1)[0] - col.push(item) - sum += rawSpan - - // 非 N 卡重置连续计数;N 卡累加计数 - if (!item._blank) { - if (this._isNLevel(item)) { - this.consecutiveNCount++ - } else { - this.consecutiveNCount = 0 - } - } - - // 如果已经达到 ROWS,结束这列 - if (sum >= ROWS) break - } - - // 如果放不下任何卡片(剩余卡片都太大),强制放入最小的 - if (col.length === 0 && remaining.length > 0) { - const item = remaining.shift() - col.push(item) - sum = item.span - if (!item._blank) { - if (this._isNLevel(item)) { - this.consecutiveNCount++ - } else { - this.consecutiveNCount = 0 - } } } - colIndex++ - - // 凑不满则补 _pad - while (sum < ROWS) { - col.push({ id: idCounter++, span: 1, _pad: true, likes: 0 }) - sum++ + if (!success) { + console.warn('[WaterfallLayout] 无法放置卡片 span=' + span) } - columns.push(col) - } - return columns - } - _placeColumn(colUsers) { - const result = [] - let curY = 0 - const colX = this.curX - const maxSpan = Math.max(...colUsers.map(u => u.span || 1)) - const colW = this._cardSize(maxSpan).w - - for (const u of colUsers) { - const span = u.span || 1 - const { w, h } = this._cardSize(span) - if (!u._pad && !u._blank) { - result.push({ ...u, left: colX, top: curY, w, h, radius: 8 }) + if (span === 1 && success) { + consecutiveSpan1++ } - curY += h + this.gap } - this.curX += colW + this.gap - return result + // 填空白 + this._fillBlanks(grid, placed, baseX, baseY) + return placed } - // 计算全部卡片布局(重置状态) + // 计算全部卡片布局 compute(users) { this.curX = 0 - this.consecutiveNCount = 0 - const columns = this._groupIntoColumns(users) const result = [] - for (const col of columns) { - result.push(...this._placeColumn(col)) + + // 每 16 张卡片为一块 + for (let i = 0; i < users.length; i += this.BLOCK_SIZE) { + const block = users.slice(i, i + this.BLOCK_SIZE) + const blockResult = this._placeBlock(block, Math.floor(i / this.BLOCK_SIZE)) + result.push(...blockResult) } + + this.totalWidth = (Math.ceil(users.length / this.BLOCK_SIZE)) * + (this.COLS * (this.cellW + this.gap) + this.gap) return result } - // 追加更多卡片(保持当前 X 继续) + // 追加更多卡片(追加到下一块) addCards(users) { - const columns = this._groupIntoColumns(users) const result = [] - for (const col of columns) { - result.push(...this._placeColumn(col)) - } + const blockIndex = Math.floor(allUsers.value.length / this.BLOCK_SIZE) + const blockResult = this._placeBlock(users, blockIndex) + result.push(...blockResult) + this.totalWidth = (blockIndex + 1) * (this.COLS * (this.cellW + this.gap) + this.gap) return result } getTotalWidth() { - return this.curX + return this.totalWidth || 0 } } @@ -747,6 +814,7 @@ const appendMore = async () => { const placed = layout.addCards(withData) cards.value = [...cards.value, ...placed] + allUsers.value = [...allUsers.value, ...withData] totalWidth.value = layout.getTotalWidth() // totalWidth 变化后重启 iOS CSS 动画,确保新宽度生效 @@ -819,7 +887,7 @@ onMounted(() => { const sysInfo = uni.getSystemInfoSync() isIOS = sysInfo.platform === 'ios' const containerH = props.screenHeight - props.bannerBottom - layout = new WaterfallLayout(containerH, props.category) + layout = new WaterfallLayout(props.screenWidth, containerH, GAP) loadUsers().then(() => { isInitialLoading = false nextTick(() => { diff --git a/frontend/pages/square/config/mockData.js b/frontend/pages/square/config/mockData.js index 31c7212..a5437e9 100644 --- a/frontend/pages/square/config/mockData.js +++ b/frontend/pages/square/config/mockData.js @@ -69,38 +69,48 @@ const NICKNAMES = [ // ========== 分类 span 阈值配置 ========== // 每个分类有不同的 span 计算规则 +// span 对应网格中的面积: +// span 1: 1×1 = 1格(正方形) +// span 2: 1×2/2×1 = 2格(竖长/横长) +// span 3: 2×2 = 4格(正方形) +// span 4: 2×3/3×2 = 6格(竖长/横长) +// span 5: 3×3 = 9格(正方形) export const SPAN_CONFIG = { // 统一 span 阈值 hot: { thresholds: [ - { max: 30, span: 1 }, // 0-30 → span 1 - { max: 100, span: 2 }, // 31-100 → span 2 - { max: 200, span: 3 }, // 101-200 → span 3 - { max: Infinity, span: 4 }, // 200+ → span 4 + { max: 30, span: 1 }, // 0-30 → span 1 (1格) + { max: 100, span: 2 }, // 31-100 → span 2 (2格) + { max: 200, span: 3 }, // 101-200 → span 3 (4格) + { max: 400, span: 4 }, // 201-400 → span 4 (6格) + { max: Infinity, span: 5 }, // 401+ → span 5 (9格) ] }, new: { thresholds: [ - { max: 30, span: 1 }, // 0-30 → span 1 - { max: 100, span: 2 }, // 31-100 → span 2 - { max: 200, span: 3 }, // 101-200 → span 3 - { max: Infinity, span: 4 }, // 200+ → span 4 + { max: 30, span: 1 }, + { max: 100, span: 2 }, + { max: 200, span: 3 }, + { max: 400, span: 4 }, + { max: Infinity, span: 5 }, ] }, potential: { thresholds: [ - { max: 30, span: 1 }, // 0-30 → span 1 - { max: 100, span: 2 }, // 31-100 → span 2 - { max: 200, span: 3 }, // 101-200 → span 3 - { max: Infinity, span: 4 }, // 200+ → span 4 + { max: 30, span: 1 }, + { max: 100, span: 2 }, + { max: 200, span: 3 }, + { max: 400, span: 4 }, + { max: Infinity, span: 5 }, ] }, random: { thresholds: [ - { max: 30, span: 1 }, // 0-30 → span 1 - { max: 100, span: 2 }, // 31-100 → span 2 - { max: 200, span: 3 }, // 101-200 → span 3 - { max: Infinity, span: 4 }, // 200+ → span 4 + { max: 30, span: 1 }, + { max: 100, span: 2 }, + { max: 200, span: 3 }, + { max: 400, span: 4 }, + { max: Infinity, span: 5 }, ] }, }