diff --git a/backend/gateway/dto/gallery_converter.go b/backend/gateway/dto/gallery_converter.go index f5c5c8b..ebc5037 100644 --- a/backend/gateway/dto/gallery_converter.go +++ b/backend/gateway/dto/gallery_converter.go @@ -171,6 +171,7 @@ func ConvertInspirationFlowData(pbData *pbGallery.InspirationFlowData) *GetInspi CoverURL: item.CoverUrl, LikeCount: item.LikeCount, OwnerNickname: item.OwnerNickname, + OwnerAvatar: item.OwnerAvatar, Span: item.Span, MaterialType: item.MaterialType, }) diff --git a/backend/gateway/dto/gallery_dto.go b/backend/gateway/dto/gallery_dto.go index fc1b270..b36614c 100644 --- a/backend/gateway/dto/gallery_dto.go +++ b/backend/gateway/dto/gallery_dto.go @@ -103,6 +103,7 @@ type InspirationFlowItemDTO struct { CoverURL string `json:"cover_url"` // 封面图URL LikeCount int32 `json:"like_count"` // 点赞数 OwnerNickname string `json:"owner_nickname"` // 展出者昵称 + OwnerAvatar string `json:"owner_avatar"` // 展出者头像 Span int32 `json:"span"` // 卡片大小: 0-30→1, 31-100→2, 101-200→3, 200+→4 MaterialType string `json:"material_type"` // 素材类型: hot(人气王者), potential(潜力之星), new(新鲜上架) } diff --git a/backend/pkg/database/redis.go b/backend/pkg/database/redis.go index 52a0ab4..28c75e1 100644 --- a/backend/pkg/database/redis.go +++ b/backend/pkg/database/redis.go @@ -162,6 +162,7 @@ type InspirationFlowCacheEntry struct { CoverURL string `json:"cover_url"` LikeCount int32 `json:"like_count"` OwnerNickname string `json:"owner_nickname"` + OwnerAvatar string `json:"owner_avatar"` Span int32 `json:"span"` MaterialType string `json:"material_type"` } diff --git a/backend/pkg/proto/gallery/gallery.pb.go b/backend/pkg/proto/gallery/gallery.pb.go index afdaa88..f945f9e 100644 --- a/backend/pkg/proto/gallery/gallery.pb.go +++ b/backend/pkg/proto/gallery/gallery.pb.go @@ -1536,6 +1536,7 @@ type InspirationFlowItem struct { CoverUrl string `protobuf:"bytes,3,opt,name=cover_url,json=coverUrl,proto3" json:"cover_url,omitempty"` // 封面图URL LikeCount int32 `protobuf:"varint,4,opt,name=like_count,json=likeCount,proto3" json:"like_count,omitempty"` // 点赞数 OwnerNickname string `protobuf:"bytes,5,opt,name=owner_nickname,json=ownerNickname,proto3" json:"owner_nickname,omitempty"` // 展出者昵称 + OwnerAvatar string `protobuf:"bytes,8,opt,name=owner_avatar,json=ownerAvatar,proto3" json:"owner_avatar,omitempty"` // 展出者头像 Span int32 `protobuf:"varint,6,opt,name=span,proto3" json:"span,omitempty"` // 卡片大小: 0-30→1, 31-100→2, 101-200→3, 200+→4 MaterialType string `protobuf:"bytes,7,opt,name=material_type,json=materialType,proto3" json:"material_type,omitempty"` // 素材类型: hot(人气王者), potential(潜力之星), new(新鲜上架) unknownFields protoimpl.UnknownFields @@ -1607,6 +1608,13 @@ func (x *InspirationFlowItem) GetOwnerNickname() string { return "" } +func (x *InspirationFlowItem) GetOwnerAvatar() string { + if x != nil { + return x.OwnerAvatar + } + return "" +} + func (x *InspirationFlowItem) GetSpan() int32 { if x != nil { return x.Span @@ -1855,14 +1863,15 @@ const file_gallery_proto_rawDesc = "" + "\x06cursor\x18\x02 \x01(\tR\x06cursor\x12\x19\n" + "\bhas_more\x18\x03 \x01(\bR\ahasMore\x12\x1d\n" + "\n" + - "session_id\x18\x04 \x01(\tR\tsessionId\"\xe0\x01\n" + + "session_id\x18\x04 \x01(\tR\tsessionId\"\x83\x02\n" + "\x13InspirationFlowItem\x12\x19\n" + "\basset_id\x18\x01 \x01(\x03R\aassetId\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + "\tcover_url\x18\x03 \x01(\tR\bcoverUrl\x12\x1d\n" + "\n" + "like_count\x18\x04 \x01(\x05R\tlikeCount\x12%\n" + - "\x0eowner_nickname\x18\x05 \x01(\tR\rownerNickname\x12\x12\n" + + "\x0eowner_nickname\x18\x05 \x01(\tR\rownerNickname\x12!\n" + + "\fowner_avatar\x18\b \x01(\tR\vownerAvatar\x12\x12\n" + "\x04span\x18\x06 \x01(\x05R\x04span\x12#\n" + "\rmaterial_type\x18\a \x01(\tR\fmaterialType\"i\n" + "\x1dGetUserExhibitedAssetsRequest\x12\x17\n" + diff --git a/backend/proto/gallery.proto b/backend/proto/gallery.proto index a27f81b..958b052 100644 --- a/backend/proto/gallery.proto +++ b/backend/proto/gallery.proto @@ -244,6 +244,7 @@ message InspirationFlowItem { string cover_url = 3; // 封面图URL int32 like_count = 4; // 点赞数 string owner_nickname = 5; // 展出者昵称 + string owner_avatar = 8; // 展出者头像 int32 span = 6; // 卡片大小: 0-30→1, 31-100→2, 101-200→3, 200+→4 string material_type = 7; // 素材类型: hot(人气王者), potential(潜力之星), new(新鲜上架) } diff --git a/backend/services/galleryService/repository/gallery_repository.go b/backend/services/galleryService/repository/gallery_repository.go index 7a23422..d38a309 100644 --- a/backend/services/galleryService/repository/gallery_repository.go +++ b/backend/services/galleryService/repository/gallery_repository.go @@ -90,6 +90,7 @@ type InspirationFlowItem struct { LikeCount int32 Level string // 藏品等级: N, R, SR, SSR, UR OwnerNickname string + OwnerAvatar string Span int32 // 卡片大小: N→1, R→2, SR→3, SSR→4, UR→5 MaterialType string // 素材类型: hot(人气王者), potential(潜力之星), new(新鲜上架) CreatedAt int64 // 创建时间(用于判断是否为潜力之星) @@ -546,7 +547,7 @@ 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, COALESCE(alr.current_level, 'N') as level, 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, fp.avatar_url as owner_avatar, 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"). @@ -558,7 +559,7 @@ 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, COALESCE(alr.current_level, 'N') as level, 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, fp.avatar_url as owner_avatar, 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"). diff --git a/backend/services/galleryService/service/gallery_service.go b/backend/services/galleryService/service/gallery_service.go index 5f39939..7b368ef 100644 --- a/backend/services/galleryService/service/gallery_service.go +++ b/backend/services/galleryService/service/gallery_service.go @@ -326,6 +326,20 @@ func (s *galleryService) GetInspirationFlow(userID, starID int64, cursor, direct // 向右滚动:从仓库随机查询新数据(排除已展示) if direction == "right" { + // 解析游标中的 offset + offset := 0 + if cursor != "" { + decoded, err := base64.StdEncoding.DecodeString(cursor) + if err == nil { + var cursorData map[string]interface{} + if json.Unmarshal(decoded, &cursorData) == nil { + if o, ok := cursorData["offset"].(float64); ok { + offset = int(o) + } + } + } + } + // Get excludeIDs from cache var excludeIDs []int64 if sessionID != "" { @@ -335,7 +349,7 @@ func (s *galleryService) GetInspirationFlow(userID, starID int64, cursor, direct } } - items, err := s.repo.GetRandomExhibitions(starID, materialType, excludeIDs, int(limit), 0) + items, err := s.repo.GetRandomExhibitions(starID, materialType, excludeIDs, int(limit), offset) if err != nil { logger.Logger.Warn("GetInspirationFlow failed", zap.Int64("star_id", starID), @@ -354,6 +368,7 @@ func (s *galleryService) GetInspirationFlow(userID, starID int64, cursor, direct CoverUrl: item.CoverURL, LikeCount: item.LikeCount, OwnerNickname: item.OwnerNickname, + OwnerAvatar: item.OwnerAvatar, Span: item.Span, MaterialType: item.MaterialType, }) @@ -366,6 +381,7 @@ func (s *galleryService) GetInspirationFlow(userID, starID int64, cursor, direct CoverURL: item.CoverURL, LikeCount: item.LikeCount, OwnerNickname: item.OwnerNickname, + OwnerAvatar: item.OwnerAvatar, Span: item.Span, MaterialType: item.MaterialType, } @@ -373,12 +389,13 @@ func (s *galleryService) GetInspirationFlow(userID, starID int64, cursor, direct } } - // 生成新游标 - newCursor := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"limit":%d}`, decodedLimit))) + // 生成新游标(包含 offset 以便下次分页) + newOffset := offset + len(items) + newCursor := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"limit":%d,"offset":%d}`, decodedLimit, newOffset))) // 检查是否还有更多数据 total, err := s.repo.CountValidExhibitions(starID, materialType) - hasMore := int64(len(items)) < total + hasMore := int64(len(items)) < total && newOffset < int(total) return &pb.InspirationFlowData{ Items: pbItems, @@ -433,6 +450,7 @@ func (s *galleryService) GetInspirationFlow(userID, starID int64, cursor, direct CoverUrl: item.CoverURL, LikeCount: item.LikeCount, OwnerNickname: item.OwnerNickname, + OwnerAvatar: item.OwnerAvatar, Span: item.Span, MaterialType: item.MaterialType, }) diff --git a/frontend/index.html b/frontend/index.html index 20d05ca..103cd12 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -20,4 +20,14 @@
- \ No newline at end of file + + + \ No newline at end of file diff --git a/frontend/pages/castlove/craft-select.vue b/frontend/pages/castlove/craft-select.vue index 75012fc..1989ee6 100644 --- a/frontend/pages/castlove/craft-select.vue +++ b/frontend/pages/castlove/craft-select.vue @@ -1,10 +1,17 @@ @@ -65,24 +72,91 @@ export default { }, data() { return { - selectedIndex: 2, - // 工艺名称 → 页面路由映射,方便扩展 + selectedCategoryIndex: 0, + selectedIndex: 0, + touchStartY: 0, + // 大分类名称 → 页面路由映射,方便扩展 cardRoutes: { '光栅卡': '/pages/castlove/lenticular/lenticular-create', '拍立得': '/pages/castlove/create', '镭射卡': '/pages/castlove/create', '撕拉片': '/pages/castlove/create', }, + // 大分类列表 + categoryList: [ + { name: '星卡' }, + { name: '吧唧' }, + { name: '海报' }, + ], + // 各分类下的工艺卡片 + cardListMap: { + '星卡': [ + { name: '光栅卡', image: '/static/castlove/guangshanka.png', comingSoon: false }, + { name: '拍立得', image: '/static/castlove/pailide.png', comingSoon: false }, + { name: '开发中', image: '/static/castlove/daikaifa.png', comingSoon: true }, + { name: '镭射卡', image: '/static/castlove/leisheka.png', comingSoon: false }, + { name: '撕拉片', image: '/static/castlove/silapian.png', comingSoon: false }, + ], + '吧唧': [ + // { name: '光栅卡', image: '/static/castlove/guangshanka.png', comingSoon: false }, + // { name: '拍立得', image: '/static/castlove/pailide.png', comingSoon: false }, + // { name: '镭射卡', image: '/static/castlove/leisheka.png', comingSoon: false }, + // { name: '撕拉片', image: '/static/castlove/silapian.png', comingSoon: false }, + // { name: '开发中', image: '/static/castlove/daikaifa.png', comingSoon: true } + ], + '海报': [ + // { name: '光栅卡', image: '/static/castlove/guangshanka.png', comingSoon: false }, + // { name: '拍立得', image: '/static/castlove/pailide.png', comingSoon: false }, + // { name: '镭射卡', image: '/static/castlove/leisheka.png', comingSoon: false }, + // { name: '撕拉片', image: '/static/castlove/silapian.png', comingSoon: false }, + // { name: '开发中', image: '/static/castlove/daikaifa.png', comingSoon: true } + ] + }, cardList: [ - { name: '镭射卡', image: '/static/castlove/leisheka.png', comingSoon: false }, - { name: '拍立得', image: '/static/castlove/pailide.png', comingSoon: false }, { name: '光栅卡', image: '/static/castlove/guangshanka.png', comingSoon: false }, + { name: '拍立得', image: '/static/castlove/pailide.png', comingSoon: false }, + { name: '镭射卡', image: '/static/castlove/leisheka.png', comingSoon: false }, { name: '撕拉片', image: '/static/castlove/silapian.png', comingSoon: false }, { name: '开发中', image: '/static/castlove/daikaifa.png', comingSoon: true } ] } }, + computed: { + currentCardList() { + const categoryName = this.categoryList[this.selectedCategoryIndex].name + return this.cardListMap[categoryName] || this.cardList + } + }, methods: { + // 选择大分类 + selectCategory(index) { + this.selectedCategoryIndex = index + this.selectedIndex = 0 // 重置子选项选中 + }, + // 触摸开始 + onTouchStart(e) { + this.touchStartY = e.touches[0].clientY + }, + // 触摸结束 - 滑动切换工艺卡片 + onTouchEnd(e) { + const touchEndY = e.changedTouches[0].clientY + const diff = this.touchStartY - touchEndY + const threshold = 50 // 滑动阈值 + + if (diff > threshold) { + // 向上滑动 → 下一个工艺卡片 + let newIndex = this.selectedIndex + 1 + if (newIndex < this.currentCardList.length) { + this.selectCard(newIndex) + } + } else if (diff < -threshold) { + // 向下滑动 → 上一个工艺卡片 + let newIndex = this.selectedIndex - 1 + if (newIndex >= 0) { + this.selectCard(newIndex) + } + } + }, // 获取卡片样式 - 循环滚动布局 // positions 定义了5个位置的固定样式(位置0最上,位置2中间,位置4最下) // 当选中某个卡片时,该卡片显示在位置2(中间),其他卡片循环填充 @@ -132,7 +206,7 @@ export default { * - 其余叠层 → 仅切换选中 */ onCardFrameTap(index) { - const card = this.cardList[index]; + const card = this.currentCardList[index]; if (!card) { return; } @@ -161,15 +235,15 @@ export default { uni.navigateBack() }, scrollUp() { - let newIndex = this.selectedIndex - 1; + let newIndex = this.selectedCategoryIndex - 1; if (newIndex >= 0) { - this.selectCard(newIndex); + this.selectCategory(newIndex); } }, scrollDown() { - let newIndex = this.selectedIndex + 1; - if (newIndex < this.cardList.length) { - this.selectCard(newIndex); + let newIndex = this.selectedCategoryIndex + 1; + if (newIndex < this.categoryList.length) { + this.selectCategory(newIndex); } }, handleSkip() { @@ -196,12 +270,20 @@ export default { \ No newline at end of file diff --git a/frontend/pages/square/components/WaterfallGrid.vue b/frontend/pages/square/components/WaterfallGrid.vue index d0d0d91..0a3aa98 100644 --- a/frontend/pages/square/components/WaterfallGrid.vue +++ b/frontend/pages/square/components/WaterfallGrid.vue @@ -4,7 +4,7 @@ @touchend="onTouchEnd" @touchcancel="onTouchEnd"> - + @@ -38,7 +38,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { getInspirationFlowApi, getBatchOssPresignedUrlsApi } from '@/utils/api.js' import { doubleTapLike } from '@/utils/likeHelper.js' -import { USE_MOCK_DATA, getMockDataByCategory, generateMockItems, calcSpan } from '../config/mockData.js' +import { calcSpan } from '../config/mockData.js' const props = defineProps({ screenWidth: { type: Number, default: 375 }, @@ -71,10 +71,12 @@ const likingMap = ref({}) // 记录正在播放点赞动画的卡片ID let currentScrollLeft = 0 let idCounter = 0 let isComponentMounted = false // 标记组件是否已卸载 -let mockDataOffset = 0 // 模拟数据循环偏移量 let appendFailed = false // 标记追加是否已失败 let isInitialLoading = true // 标记是否在初始加载中 let isIOS = false // 是否为 iOS 平台 +let cursor = '' // 游标分页(右侧新数据) +let leftCursor = '' // 游标分页(左侧历史数据) +let leftAppendFailed = false // 标记左侧追加是否已失败 // ========== RAF 兼容 ========== const rafFn = (cb) => { @@ -164,17 +166,16 @@ const startAutoScroll = () => { // 使用定时器定期更新 scrollLeft clearInterval(scrollUpdateTimer) scrollUpdateTimer = setInterval(() => { - if (!isComponentMounted || userInteracting || isLoadingMore) return + if (!isComponentMounted || userInteracting) return autoScrollPos += AUTO_SCROLL_SPEED_ANDROID - // 滚到头时重置到 0,实现无缝循环 if (autoScrollPos >= totalWidth.value) { autoScrollPos = 0 } scrollLeft.value = autoScrollPos - // 预加载(不依赖 useMockData,真实 API 也能追加) + // 预加载检查 const remainingScroll = totalWidth.value - autoScrollPos - props.screenWidth - if (remainingScroll < Math.max(totalWidth.value / 2, props.screenWidth)) { + if (remainingScroll < props.screenWidth) { if (!isLoadingMore && !appendFailed && !isInitialLoading) { appendMore() } @@ -292,11 +293,18 @@ const onScroll = (e) => { } const remainingScroll = totalWidth.value - currentScrollLeft - props.screenWidth - if (remainingScroll < Math.max(totalWidth.value / 2, props.screenWidth)) { - if (!isLoadingMore && !appendFailed && !isInitialLoading && props.useMockData) { + // 向左滚动到底触发追加新数据 + if (remainingScroll < props.screenWidth && currentScrollLeft > 0) { + if (!isLoadingMore && !appendFailed && !isInitialLoading) { scheduleAppend() } } + // 向右滚动触发追加历史数据 + if (currentScrollLeft > props.screenWidth && leftCursor) { + if (!isLoadingMore && !leftAppendFailed && !isInitialLoading) { + appendLeft() + } + } } // ========== 样式 ========== @@ -307,19 +315,21 @@ const scrollStyle = computed(() => ({ width: props.screenWidth + 'px', height: (props.screenHeight - props.bannerBottom) + 'px', zIndex: 2, - // overflow: 'visible', + overflow: 'hidden', })) // iOS CSS 动画内联样式 +// 无缝循环需要两份内容拼接:[原内容|原内容] +// 动画从 0 滚动到 -totalWidth,当原内容滚动到末端时,第二份正好接上 const innerStyle = computed(() => { if (!isIOS) { - return { width: totalWidth.value + 'px', height: '100%' } + return { width: (totalWidth.value * 2) + 'px', height: '100%' } } const scrollDist = totalWidth.value // 速度:px/ms,iOS 使用 AUTO_SCROLL_SPEED_IOS const duration = scrollDist / AUTO_SCROLL_SPEED_IOS return { - width: scrollDist + 'px', + width: (scrollDist * 2) + 'px', height: '100%', '--scroll-dist': -scrollDist + 'px', '--anim-duration': duration + 'ms', @@ -329,6 +339,13 @@ const innerStyle = computed(() => { } }) +// 获取循环后的卡片列表(用于渲染两份内容实现无缝循环) +const loopedCards = computed(() => { + if (!cards.value || cards.value.length === 0) return [] + // 返回两份内容,无缝循环 + return [...cards.value, ...cards.value.map(card => ({ ...card }))] +}) + // ========== 布局引擎 ========== // 核心思路:4×4 网格装箱,按块放置(每块16格),优先竖形状,放不下换横,最后填空白 // span 对应面积: @@ -413,6 +430,17 @@ class WaterfallLayout { } } + // 计算网格中可用格子数量 + _countAvailableCells(grid) { + let count = 0 + for (let r = 0; r < this.ROWS; r++) { + for (let c = 0; c < this.COLS; c++) { + if (grid[r][c] === 0) count++ + } + } + return count + } + // 找空白位置(列优先:先左右,再上下) _findSpace(grid, w, h) { for (let c = 0; c <= this.COLS - w; c++) { @@ -452,11 +480,21 @@ class WaterfallLayout { const baseX = blockIndex * (this.COLS * (this.cellW + this.gap) + this.gap) const baseY = 0 + // 计算可放置卡片数量的上限(考虑每5个会强制留空) + const maxCardsPerBlock = this.BLOCK_SIZE // 16格 + let consecutiveSpan1 = 0 for (const item of items) { const span = item.span != null ? item.span : this._calcSpan(item.likes || 0) + // 检查是否还有空间 + const availableCells = this._countAvailableCells(grid) + if (availableCells < 1) { + // 没有空间了,跳过这个卡片 + continue + } + // span1 连续放置 5 个后,先留 1-2 个空白格再继续 if (span === 1 && consecutiveSpan1 >= 5) { const blanks = Math.random() < 0.5 ? 1 : 2 @@ -512,6 +550,21 @@ class WaterfallLayout { } } + // 如果还是放不下,强制找到一个空位放置(避免卡片丢失) + if (!success) { + for (let r = 0; r < this.ROWS && !success; r++) { + for (let c = 0; c < this.COLS && !success; c++) { + if (grid[r][c] === 0) { + this._markGrid(grid, r, c, 1, 1) + const left = baseX + c * (this.cellW + this.gap) + const top = baseY + r * (this.cellH + this.gap) + placed.push({ ...item, left, top, w: this.cellW, h: this.cellH, radius: 8 }) + success = true + } + } + } + } + if (!success) { console.warn('[WaterfallLayout] 无法放置卡片 span=' + span) } @@ -549,7 +602,20 @@ class WaterfallLayout { 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) + + // 计算新块的宽度(基于该块中所有卡片的最右边位置) + let blockMaxX = 0 + for (const card of blockResult) { + const right = card.left + card.w + if (right > blockMaxX) { + blockMaxX = right + } + } + // 加上最后一块的 gap,得到该块的真实宽度 + const blockWidth = blockMaxX + this.gap + + // 累积总宽度 + this.totalWidth += blockWidth return result } @@ -658,63 +724,19 @@ const batchGetPresignedUrls = async (urls) => { const loadUsers = async () => { if (!isComponentMounted) return Promise.resolve() - // 切换分类时重置偏移量 - mockDataOffset = 0 + // 切换分类时重置 + cursor = '' isLoadingMore = true try { - let items - // 使用真实API - const res = await getInspirationFlowApi({ limit: 20, type: props.category }) + const res = await getInspirationFlowApi({ limit: 20, type: props.category, direction: 'right', cursor }) if (!isComponentMounted) return if (res.code === 200 && res.data?.items && res.data.items.length > 0) { - items = res.data.items - // 真实接口根据 has_more 决定是否继续加载,has_more 为 false 时追加模拟数据兜底 - if (!res.data.has_more) { - appendFailed = true - if (USE_MOCK_DATA) { - const mockData = getMockDataByCategory(props.category) - const allItems = mockData.items - const batchSize = 20 - const itemsToAdd = [] - for (let i = 0; i < batchSize; i++) { - const sourceIndex = (mockDataOffset + i) % allItems.length - const sourceItem = allItems[sourceIndex] - const newItem = { - ...sourceItem, - asset_id: sourceItem.asset_id * 100 + mockDataOffset + i, - likes: sourceItem.like_count, - } - itemsToAdd.push(newItem) - } - mockDataOffset = mockDataOffset + batchSize - items = [...items, ...itemsToAdd] - } - } - } else { - // 接口没数据时,使用模拟数据兜底 - if (USE_MOCK_DATA) { - const mockData = getMockDataByCategory(props.category) - const allItems = mockData.items - const batchSize = 20 - const itemsToAdd = [] - for (let i = 0; i < batchSize; i++) { - const sourceIndex = (mockDataOffset + i) % allItems.length - const sourceItem = allItems[sourceIndex] - const newItem = { - ...sourceItem, - asset_id: sourceItem.asset_id * 100 + mockDataOffset + i, - likes: sourceItem.like_count, - } - itemsToAdd.push(newItem) - } - mockDataOffset = mockDataOffset + batchSize - items = itemsToAdd - } - } + const items = res.data.items + // 保存游标用于下次分页请求 + cursor = res.data.cursor || '' - if (items && items.length > 0) { const withData = items.map((item) => { return { id: item.asset_id, @@ -730,6 +752,9 @@ const loadUsers = async () => { allUsers.value = withData cards.value = layout.compute(withData) totalWidth.value = layout.getTotalWidth() + } else { + // 接口没数据 + appendFailed = true } isLoadingMore = false } catch (e) { @@ -747,62 +772,70 @@ const appendMore = async () => { } isLoadingMore = true try { - let items - - // 使用真实API - const res = await getInspirationFlowApi({ limit: 20, type: props.category }) + const res = await getInspirationFlowApi({ limit: 20, type: props.category, direction: 'right', cursor }) if (!isComponentMounted) return if (res.code === 200 && res.data?.items && res.data.items.length > 0) { - items = res.data.items - // 真实接口根据 has_more 决定是否继续加载,has_more 为 false 时追加模拟数据兜底 - if (!res.data.has_more) { - appendFailed = true - if (USE_MOCK_DATA) { - const mockData = getMockDataByCategory(props.category) - const allItems = mockData.items - const batchSize = 20 - const itemsToAdd = [] - for (let i = 0; i < batchSize; i++) { - const sourceIndex = (mockDataOffset + i) % allItems.length - const sourceItem = allItems[sourceIndex] - const newItem = { - ...sourceItem, - asset_id: sourceItem.asset_id * 100 + mockDataOffset + i, - likes: sourceItem.like_count, - } - itemsToAdd.push(newItem) - } - mockDataOffset = mockDataOffset + batchSize - items = [...items, ...itemsToAdd] - } - } - } else { - // 接口没数据时,使用模拟数据兜底 - if (USE_MOCK_DATA) { - const mockData = getMockDataByCategory(props.category) - const allItems = mockData.items - const batchSize = 20 - const itemsToAdd = [] - for (let i = 0; i < batchSize; i++) { - const sourceIndex = (mockDataOffset + i) % allItems.length - const sourceItem = allItems[sourceIndex] - const newItem = { - ...sourceItem, - asset_id: sourceItem.asset_id * 100 + mockDataOffset + i, - likes: sourceItem.like_count, - } - itemsToAdd.push(newItem) - } - mockDataOffset = mockDataOffset + batchSize - items = itemsToAdd - } - } - - if (items && items.length > 0) { + const items = res.data.items + cursor = res.data.cursor || '' const withData = items.map((item) => { return { - id: item.asset_id, // 直接使用 asset_id(已经在上面保证唯一) + id: item.asset_id, + userId: item.asset_id, + nickname: item.owner_nickname || item.name, + coverUrl: item.cover_url || MOCK_IMAGES[idCounter % MOCK_IMAGES.length], + likes: item.likes || item.like_count || 0, + span: item.span ?? null, + } + }) + + appendFailed = false + const placed = layout.addCards(withData) + console.log('[appendMore success] totalWidth:', totalWidth.value, 'isLoadingMore:', isLoadingMore) + + cards.value = [...cards.value, ...placed] + allUsers.value = [...allUsers.value, ...withData] + + // totalWidth 已在 addCards 中更新 + + // 保持滚动位置连续性 + if (!isIOS) { + stopAutoScroll() + // 重启时从当前位置开始,但确保不会立即触发下一次加载 + // 向前移动一点距离,避免刚好在触发阈值上 + autoScrollPos = currentScrollLeft + 100 + if (autoScrollPos >= totalWidth.value) { + autoScrollPos = 0 + } + startAutoScroll() + } + } else { + // 没有可追加的数据,标记失败停止 + appendFailed = true + } + } catch (e) { + console.error('[WaterfallGrid] 追加用户失败', e?.message ?? e) + appendFailed = true + } finally { + isLoadingMore = false + } +} + +// 追加左侧历史数据(向右滚动查看) +const appendLeft = async () => { + if (!isComponentMounted) return + if (isLoadingMore) return + isLoadingMore = true + try { + const res = await getInspirationFlowApi({ limit: 20, type: props.category, direction: 'left', cursor: leftCursor }) + if (!isComponentMounted) return + if (res.code === 200 && res.data?.items && res.data.items.length > 0) { + const items = res.data.items + leftCursor = res.data.cursor || '' + + const withData = items.map((item) => { + return { + id: item.asset_id + '_left', userId: item.asset_id, nickname: item.owner_nickname || item.name, coverUrl: item.cover_url || MOCK_IMAGES[idCounter % MOCK_IMAGES.length], @@ -812,24 +845,21 @@ const appendMore = async () => { }) const placed = layout.addCards(withData) - - cards.value = [...cards.value, ...placed] - allUsers.value = [...allUsers.value, ...withData] + cards.value = [...placed, ...cards.value] + allUsers.value = [...withData, ...allUsers.value] totalWidth.value = layout.getTotalWidth() - // totalWidth 变化后重启 iOS CSS 动画,确保新宽度生效 - if (isIOS && !iosScrollPaused.value) { - stopIOSAutoScroll() - startIOSAutoScroll() - } + scrollLeft.value = currentScrollLeft + placed.reduce((sum, card) => sum + card.w + GAP, 0) + if (!isIOS) { + stopAutoScroll() + startAutoScroll() + } } else { - // 没有可追加的数据,标记失败停止 - appendFailed = true + leftCursor = '' } } catch (e) { - console.error('[WaterfallGrid] 追加用户失败', e?.message ?? e) - appendFailed = true + console.error('[WaterfallGrid] 追加左侧历史数据失败', e?.message ?? e) } finally { isLoadingMore = false } @@ -975,10 +1005,7 @@ const handleAppHide = () => { } // 暴露方法给父组件 -defineExpose({ - handleAppShow, - handleAppHide, -}) +defineExpose({ handleAppShow, handleAppHide }) // 监听 isActive 属性变化(父组件控制) watch(() => props.isActive, (active) => { @@ -1034,6 +1061,9 @@ watch(() => props.category, (newCategory) => { // 重置滚动位置 currentScrollLeft = 0 scrollLeft.value = 0 + cursor = '' + leftCursor = '' + leftAppendFailed = false // 重新创建布局(使用新的 span 阈值) const containerH = props.screenHeight - props.bannerBottom @@ -1041,7 +1071,6 @@ watch(() => props.category, (newCategory) => { cards.value = [] allUsers.value = [] totalWidth.value = 0 - mockDataOffset = 0 appendFailed = false isInitialLoading = false diff --git a/frontend/pages/square/square.vue b/frontend/pages/square/square.vue index 629d27f..eb1c803 100644 --- a/frontend/pages/square/square.vue +++ b/frontend/pages/square/square.vue @@ -1,58 +1,30 @@ @@ -77,8 +94,7 @@ import BottomNav from '../components/BottomNav.vue' import GuideOverlay from '@/components/GuideOverlay.vue' import RankingModal from '../components/RankingModal.vue' import BannerCarousel from './components/BannerCarousel.vue' -import WaterfallGrid from './components/WaterfallGrid.vue' -import ContentTabs from './components/ContentTabs.vue' +import CreationGrid from './components/CreationGrid.vue' import { clearSubStepProgress, shouldShowGuideStartModal } from '@/utils/guideConfig.js' import { useBanner } from './composables/useBanner.js' import { USE_MOCK_DATA } from './config/mockData.js' @@ -90,10 +106,20 @@ const currentStarId = ref(uni.getStorageSync('star_id') || null) // ========== UI State ========== const activeContentTab = ref('hot') -const waterfallKey = ref(0) // 用于重新加载 WaterfallGrid const navExpanded = ref(false) const showRankingModal = ref(false) const isActive = ref(true) +const isFixed = ref(false) +const creationGridRef = ref(null) + +// ========== 分类配置 ========== +const categories = ref([ + { label: '热门作品', value: 'hot' }, + { label: '最新作品', value: 'latest' }, + { label: '星卡', value: 'star_card' }, + { label: '吧唧', value: 'badge' }, + { label: '海报', value: 'poster' } +]) // ========== Watch activeContentTab ========== watch(activeContentTab, (newTab) => { @@ -101,8 +127,6 @@ watch(activeContentTab, (newTab) => { uni.navigateTo({ url: '/pages/profile/myWorks' }) return } - // 切换标签时重置 WaterfallGrid(iOS 需要重新挂载才能重置 CSS 动画) - waterfallKey.value++ }) // ========== Screen Info ========== @@ -118,17 +142,16 @@ const { // banner(216+360rpx) + tab栏(16+80rpx) + 间距(8rpx) ≈ 680rpx const bannerBottomPx = computed(() => Math.round(screenWidth.value / 750 * 715)) -const bgScrollLeft = ref(0) - // ========== Handlers ========== const handleCardClick = (card) => { - // WaterfallGrid 组件内部已处理单击跳转和双击点赞 + // CreationGrid 组件内部已处理单击跳转和双击点赞 } -const handleWaterfallScroll = (scrollLeft) => { - // 背景以 30% 速度跟随瀑布流滚动 - bgScrollLeft.value = scrollLeft * 0.3 +const handleScrollToLower = () => { + if (creationGridRef.value) { + creationGridRef.value.loadMore() + } } const handleActivityClick = (item) => { @@ -146,7 +169,6 @@ const handleRankingVisit = ({ userId, nickname }) => { const handleRankingModalClose = (visible) => { showRankingModal.value = visible - // 使用 componentMode 判断,因为 isActive 可能在 END_GUIDE 后已变为 false if (!visible && store.state.guide.componentMode) { uni.$emit('guide:closeComponent') } @@ -173,22 +195,23 @@ const handleTabChange = (newTab) => { } } +const handleCategoryChange = (value) => { + if (activeContentTab.value === value) return + activeContentTab.value = value +} + // ========== Tile Change Callback ========== const handleTileChange = () => {} // ========== Reset Square ========== const resetSquare = async () => {} -// ========== Watch currentUserNickname ========== -// (no-op, kept for future use) - // ========== Lifecycle ========== onMounted(() => { const info = uni.getSystemInfoSync() screenWidth.value = info.windowWidth screenHeight.value = info.windowHeight - // 数据加载在后台进行,不阻塞渲染 resetSquare() loadBannerActivities() }) @@ -196,12 +219,6 @@ onMounted(() => { onShow(() => { isActive.value = true activeContentTab.value = 'hot' - // 检查是否需要显示引导,如果需要则跳转到引导页面 - // if (shouldShowGuideStartModal()) { - // uni.navigateTo({ - // url: '/pages/tasks/guide' - // }) - // } }) onHide(() => { @@ -209,7 +226,6 @@ onHide(() => { }) onLoad((options) => { - // 调试模式:读取 guide_debug 参数并设置存储 if (options && 'guide_debug' in options) { const debugValue = options.guide_debug const isDebug = debugValue === '1' || debugValue === 'true' @@ -224,10 +240,8 @@ onLoad((options) => { } } - // 处理引导跳转参数:如果传递了 guide_key,则继续该引导 if (options && options.guide_key) { console.log('[Guide] 收到引导跳转参数, guide_key:', options.guide_key, 'guide_step:', options.guide_step) - // 使用 resumeGuide 继续引导(不会检查 shouldShowGuide) store.dispatch('guide/resumeGuide', options.guide_key).then(res => { console.log('[Guide] resumeGuide 结果:', res) }).catch(err => { @@ -257,33 +271,9 @@ onUnmounted(() => { overflow: hidden; } -/* .fall-bg{ - background: rgba(255, 180, 180, 0.25); - border-radius: 48rpx; -} */ - -.banner-tabs-wrapper { - position: fixed; - top: 248rpx; - left: 0; - right: 0; - z-index: 100; - display: flex; - flex-direction: column; - min-height: 448rpx; - justify-content: space-between; - /* background: rgb(249 159 192 / 45%);; */ - /* background: rgba(212, 127, 127, 0.8); */ - /* border-radius: 48rpx; */ - overflow: visible; -} - -/* .tabs{ - margin-bottom: 8rpx; -} */ - +/* 背景图片 */ .bg-wrapper { - position: absolute; + position: fixed; top: 0; left: 0; width: 100%; @@ -291,8 +281,69 @@ onUnmounted(() => { z-index: 0; } -/* 已使用 CSS background-image 方式实现循环背景,无需额外样式 */ +/* 内容区域 */ +.content-wrapper { + position: relative; + z-index: 1; + width: 100%; + height: calc(100vh - 64rpx); + /* margin-top: 160rpx; */ + padding: 256rpx 24rpx 0; + box-sizing: border-box; +} +/* 区域一:轮播图 */ +.banner-section { + width: 100%; + /* margin-bottom: 32rpx; */ +} + +/* 区域二:分类标签 */ +.category-section { + margin-bottom: 24rpx; + transition: all 0.3s ease; + will-change: transform; +} + +.category-section.fixed { + position: fixed; + top: 96rpx; + left: 24rpx; + right: 24rpx; + z-index: 100; + padding: 16rpx 0; +} + +.category-scroll { + white-space: nowrap; +} + +.category-item { + display: inline-block; + padding: 16rpx 32rpx; + margin-right: 16rpx; + background: rgba(255, 255, 255, 0.2); + border-radius: 40rpx; + backdrop-filter: blur(10rpx); + transition: all 0.3s; +} + +.category-item.active { + background: linear-gradient(135deg, #F0E4B1, #F08399); + box-shadow: 0 4rpx 12rpx rgba(255, 107, 157, 0.4); +} + +.category-text { + font-size: 26rpx; + color: #fff; + font-weight: 500; +} + +.category-item.active .category-text { + font-weight: 600; +} + +/* 蒙层 */ .nav-mask { position: fixed; top: 0;