feat: 修改瀑布流布局

This commit is contained in:
zerosaturation 2026-05-25 23:35:47 +08:00
parent e545f41c3b
commit da6c0a5ef2
3 changed files with 265 additions and 178 deletions

View File

@ -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
}
}
// GetSlotOwnerUserID 获取展位所有者的用户ID

View File

@ -4,7 +4,8 @@
@touchend="onTouchEnd" @touchcancel="onTouchEnd">
<view ref="waterfallInnerRef" :class="{ 'waterfall-inner': true, 'ios-css-animate': !iosScrollPaused }"
:style="innerStyle">
<view v-for="card in cards" :key="card.id" class="wf-card" :style="cardStyle(card)"
<block v-for="card in cards" :key="card.id">
<view v-if="card && !card._blank" class="wf-card" :style="cardStyle(card)"
@click="handleCardClick(card)">
<!-- 渐变边框光效 -->
<view class="wf-card-border" :style="borderStyle(card)" />
@ -28,6 +29,7 @@
</view>
</view>
</view>
</block>
</view>
</scroll-view>
</template>
@ -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:16span
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)
}))
const columns = []
let colIndex = 0
while (remaining.length > 0) {
const col = []
let sum = 0
const colStart = users.length - remaining.length
//
let i = 0
while (i < remaining.length) {
const rawSpan = remaining[i].span
const isN = remaining[i]._blank ? false : this._isNLevel(remaining[i])
// 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 })
}
}
this.consecutiveNCount = 0
// i break
break
//
_createGrid() {
return Array(this.ROWS).fill(null).map(() => Array(this.COLS).fill(0))
}
// ROWS
if (sum + rawSpan > ROWS) {
i++
// ROWS
if (i >= remaining.length && col.length > 0) {
break
//
_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
}
continue
}
return true
}
// 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
//
_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
}
}
}
colIndex++
// _pad
while (sum < ROWS) {
col.push({ id: idCounter++, span: 1, _pad: true, likes: 0 })
sum++
//
_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 }
}
columns.push(col)
}
return columns
}
return null
}
_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 })
//
_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
}
}
}
curY += h + this.gap
}
this.curX += colW + this.gap
return result
// 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
}
}
}
}
}
if (!success) {
console.warn('[WaterfallLayout] 无法放置卡片 span=' + span)
}
if (span === 1 && success) {
consecutiveSpan1++
}
}
//
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(() => {

View File

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