feat:修改广场页面,铸造页面
This commit is contained in:
parent
a39bb7b60f
commit
b8e462f9b5
@ -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,
|
||||
})
|
||||
|
||||
@ -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(新鲜上架)
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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" +
|
||||
|
||||
@ -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(新鲜上架)
|
||||
}
|
||||
|
||||
@ -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").
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -21,3 +21,13 @@
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style>
|
||||
html,body,#app,page{
|
||||
height: 100%;
|
||||
overscroll-behavior: none;/* 阻止系统橡皮筋 */
|
||||
-webkit-overflow-scrolling: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
</style>
|
||||
@ -1,10 +1,17 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<image
|
||||
class="bg-wrapper"
|
||||
src="/static/castlove/beijingban.png"
|
||||
mode="aspectFill"
|
||||
></image>
|
||||
<!-- 主体内容 -->
|
||||
<view class="main-container">
|
||||
<!-- 左侧图片卡片区域 - 半圆弧形布局 -->
|
||||
<view class="cards-container">
|
||||
<view v-for="(card, index) in cardList" :key="index" class="card-item"
|
||||
<view class="cards-container"
|
||||
@touchstart="onTouchStart"
|
||||
@touchend="onTouchEnd">
|
||||
<view v-for="(card, index) in currentCardList" :key="index" class="card-item"
|
||||
:class="{ 'card-selected': selectedIndex === index }" :style="getCardStyle(index)">
|
||||
<view class="card-frame"
|
||||
:class="{ 'no-border': card.comingSoon, 'card-frame--tappable': !card.comingSoon }"
|
||||
@ -16,7 +23,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 右侧文字列表 -->
|
||||
<!-- 右侧文字列表 - 大分类 -->
|
||||
<view class="text-panel">
|
||||
<!-- 向上按钮 -->
|
||||
<view class="arrow-btn arrow-up" @click="scrollUp">
|
||||
@ -26,10 +33,10 @@
|
||||
|
||||
<!-- 文字列表 -->
|
||||
<view class="text-list">
|
||||
<view v-for="(card, index) in cardList" :key="index" class="text-item"
|
||||
:class="{ active: selectedIndex === index, 'font-large': index === 2, 'font-mid': index === 1 || index === 3, 'font-small': index === 0 || index === 4 }"
|
||||
@click="selectCard(index)">
|
||||
<text>{{ card.name }}</text>
|
||||
<view v-for="(category, index) in categoryList" :key="index" class="text-item"
|
||||
:class="{ active: selectedCategoryIndex === index, 'font-large': index === 1, 'font-mid': index === 0 || index === 2 }"
|
||||
@click="selectCategory(index)">
|
||||
<text>{{ category.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -41,10 +48,10 @@
|
||||
</view>
|
||||
|
||||
<!-- 自定义底部导航 -->
|
||||
<view class="nav-bar">
|
||||
<!-- <view class="nav-bar">
|
||||
<button class="btn-secondary" @click="handleBack">返回</button>
|
||||
<button class="btn-skip" @click="handleSkip">确认</button>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
</view>
|
||||
</template>
|
||||
@ -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 {
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
height: 100vh;
|
||||
background: url('/static/castlove/beijingban.png') no-repeat center center;
|
||||
background-size: cover;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 背景图片 */
|
||||
.bg-wrapper {
|
||||
position: fixed;
|
||||
top: -16rpx;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 110%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import BottomNav from "../components/BottomNav.vue";
|
||||
import CastloveContent from "../components/CastloveContent.vue";
|
||||
import CastloveContent from "./craft-select.vue";
|
||||
|
||||
const navExpanded = ref(false);
|
||||
|
||||
|
||||
@ -383,13 +383,13 @@ const handleAvatarClick = () => {
|
||||
if (pages.length > 0) {
|
||||
const currentPage = pages[pages.length - 1];
|
||||
// 检查当前页面是否是个人信息页面
|
||||
if (currentPage.route === 'pages/profile/profile') {
|
||||
if (currentPage.route === 'pages/profile/myWorks') {
|
||||
// 已经在个人信息页面,不执行跳转
|
||||
return;
|
||||
}
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: '/pages/profile/profile'
|
||||
url: '/pages/profile/myWorks'
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -10,9 +10,9 @@
|
||||
</view>
|
||||
<!-- <text class="nav-title">我的作品</text> -->
|
||||
<view class="nav-placeholder"></view>
|
||||
<!-- <view class="nav-settings" @tap="goToSettings">
|
||||
<view class="nav-settings" @tap="goToSettings">
|
||||
<image class="nav-settings-icon" src="/static/icon/settings.png" mode="aspectFit"></image>
|
||||
</view> -->
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="scroll-content">
|
||||
|
||||
@ -1134,13 +1134,13 @@ const uploadAvatarToOss = async (filePath) => {
|
||||
try {
|
||||
uploadingAvatar.value = true;
|
||||
uni.showLoading({ title: '上传中...', mask: true });
|
||||
|
||||
console.log(filePath)
|
||||
// 1. 获取OSS签名
|
||||
const signRes = await getOssSignatureApi('avatar');
|
||||
if (signRes.code !== 200) {
|
||||
throw new Error(signRes.message || '获取签名失败');
|
||||
}
|
||||
|
||||
console.log(signRes)
|
||||
// 2. 构建FormData并上传到OSS
|
||||
// 注意:文件名固定为avatar.png
|
||||
uni.uploadFile({
|
||||
@ -1167,13 +1167,14 @@ const uploadAvatarToOss = async (filePath) => {
|
||||
if (userAvatarUrl.value) {
|
||||
clearAvatarCache(userAvatarUrl.value);
|
||||
}
|
||||
|
||||
console.log(avatarUrl)
|
||||
// 5. 调用后端更新头像接口
|
||||
const updateRes = await updateAvatarApi(avatarUrl);
|
||||
|
||||
console.log('updateAvatarApi 返回:', updateRes); // 添加这行
|
||||
if (updateRes.code === 200) {
|
||||
// 6. 处理头像更新成功后的操作
|
||||
await handleAvatarUpdateSuccess(avatarUrl);
|
||||
console.log('updateAvatarApi 返回:', avatarUrl);
|
||||
} else {
|
||||
throw new Error(updateRes.message || '更新头像失败');
|
||||
}
|
||||
|
||||
244
frontend/pages/square/components/CreationGrid.vue
Normal file
244
frontend/pages/square/components/CreationGrid.vue
Normal file
@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<view class="creation-grid">
|
||||
<view v-for="(item, index) in creationList" :key="item.id" class="creation-card" @click="handleCardClick(item)">
|
||||
<image class="creation-image" :src="item.cover_image" mode="aspectFill"></image>
|
||||
<view class="creation-info">
|
||||
<view class="creation-id">
|
||||
<text class="id-text">#{{ item.certificate_id }}</text>
|
||||
</view>
|
||||
<view class="creation-meta">
|
||||
<view class="creator-info">
|
||||
<image class="creator-avatar" :src="item.creator_avatar" mode="aspectFill"></image>
|
||||
<text class="creator-name">{{ item.creator_name }}</text>
|
||||
</view>
|
||||
<view class="like-info">
|
||||
<image class="like-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
|
||||
<text class="like-count">{{ formatCount(item.like_count) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isLoading" class="loading-more">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
<view v-if="noMore && creationList.length > 0" class="no-more">
|
||||
<text class="no-more-text">没有更多了</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { getInspirationFlowApi } from '@/utils/api.js'
|
||||
|
||||
const props = defineProps({
|
||||
screenWidth: { type: Number, default: 375 },
|
||||
screenHeight: { type: Number, default: 812 },
|
||||
bannerBottom: { type: Number, default: 200 },
|
||||
useMockData: { type: Boolean, default: false },
|
||||
category: { type: String, default: '' },
|
||||
isActive: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['cardClick', 'scroll'])
|
||||
|
||||
const creationList = ref([])
|
||||
const cursor = ref('')
|
||||
const isLoading = ref(false)
|
||||
const noMore = ref(false)
|
||||
let isComponentMounted = false
|
||||
|
||||
const formatCount = (count) => {
|
||||
if (!count) return '0'
|
||||
if (count >= 10000) return (count / 10000).toFixed(1) + 'w'
|
||||
if (count >= 1000) return (count / 1000).toFixed(1) + 'k'
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
const handleCardClick = (item) => {
|
||||
emit('cardClick', item)
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
if (!isComponentMounted) return Promise.resolve()
|
||||
|
||||
cursor.value = ''
|
||||
isLoading.value = true
|
||||
noMore.value = false
|
||||
|
||||
try {
|
||||
const res = await getInspirationFlowApi({ limit: 20, type: props.category, cursor: '' })
|
||||
if (!isComponentMounted) return
|
||||
|
||||
if (res.code === 200 && res.data?.items && res.data.items.length > 0) {
|
||||
const items = res.data.items
|
||||
cursor.value = res.data.cursor || ''
|
||||
|
||||
creationList.value = items.map((item) => {
|
||||
return {
|
||||
id: item.asset_id,
|
||||
certificate_id: item.asset_id,
|
||||
cover_image: item.cover_url || '',
|
||||
creator_avatar: item.owner_avatar || '',
|
||||
creator_name: item.owner_nickname || item.name || '',
|
||||
like_count: item.likes || item.like_count || 0,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
noMore.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[CreationGrid] 加载数据失败', e?.message ?? e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!isComponentMounted || isLoading.value || noMore.value) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await getInspirationFlowApi({ limit: 20, type: props.category, cursor: cursor.value })
|
||||
if (!isComponentMounted) return
|
||||
|
||||
if (res.code === 200 && res.data?.items && res.data.items.length > 0) {
|
||||
const items = res.data.items
|
||||
cursor.value = res.data.cursor || ''
|
||||
|
||||
const newItems = items.map((item) => {
|
||||
return {
|
||||
id: item.asset_id,
|
||||
certificate_id: item.asset_id,
|
||||
cover_image: item.cover_url || '',
|
||||
creator_avatar: item.owner_avatar || '',
|
||||
creator_name: item.owner_nickname || item.name || '',
|
||||
like_count: item.likes || item.like_count || 0,
|
||||
}
|
||||
})
|
||||
|
||||
creationList.value = [...creationList.value, ...newItems]
|
||||
} else {
|
||||
noMore.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[CreationGrid] 加载更多失败', e?.message ?? e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.category, () => {
|
||||
loadUsers()
|
||||
})
|
||||
|
||||
watch(() => props.isActive, (active) => {
|
||||
if (active && creationList.value.length === 0) {
|
||||
loadUsers()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
isComponentMounted = true
|
||||
loadUsers()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
isComponentMounted = false
|
||||
})
|
||||
|
||||
defineExpose({ loadMore })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.creation-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx;
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
.creation-card {
|
||||
width: 48%;
|
||||
margin-bottom: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10rpx);
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.creation-image {
|
||||
width: 100%;
|
||||
height: 400rpx;
|
||||
}
|
||||
|
||||
.creation-info {
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.creation-id {
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.id-text {
|
||||
font-size: 22rpx;
|
||||
color: #FFB800;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.creation-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.creator-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.creator-avatar {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.creator-name {
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.like-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.like-icon {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
margin-right: 4rpx;
|
||||
}
|
||||
|
||||
.like-count {
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.loading-more,
|
||||
.no-more {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 32rpx 0;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.no-more-text {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
</style>
|
||||
@ -4,7 +4,7 @@
|
||||
@touchend="onTouchEnd" @touchcancel="onTouchEnd">
|
||||
<view ref="waterfallInnerRef" :class="{ 'waterfall-inner': true, 'ios-css-animate': !iosScrollPaused }"
|
||||
:style="innerStyle">
|
||||
<block v-for="card in cards" :key="card.id">
|
||||
<block v-for="(card, index) in loopedCards" :key="card.id + '_' + index">
|
||||
<view v-if="card && !card._blank" class="wf-card" :style="cardStyle(card)"
|
||||
@click="handleCardClick(card)">
|
||||
<!-- 渐变边框光效 -->
|
||||
@ -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
|
||||
|
||||
|
||||
@ -1,29 +1,11 @@
|
||||
<template>
|
||||
<view class="square-container">
|
||||
<!-- 可横向滑动的背景 - 使用背景图循环实现 -->
|
||||
<view
|
||||
<!-- 背景图片 -->
|
||||
<image
|
||||
class="bg-wrapper"
|
||||
:style="{
|
||||
backgroundImage: `url(/static/square/squearbj1.png)`,
|
||||
backgroundRepeat: 'repeat-x',
|
||||
backgroundPosition: `${-bgScrollLeft}px 0`,
|
||||
backgroundSize: `auto 100%`,
|
||||
}"
|
||||
></view>
|
||||
|
||||
<!-- 横向瀑布流卡片层(内部自带横向滚动) -->
|
||||
<WaterfallGrid
|
||||
:key="waterfallKey"
|
||||
:screenWidth="screenWidth"
|
||||
:screenHeight="screenHeight"
|
||||
:bannerBottom="bannerBottomPx"
|
||||
:useMockData="USE_MOCK_DATA"
|
||||
:category="activeContentTab"
|
||||
:isActive="isActive"
|
||||
@cardClick="handleCardClick"
|
||||
@scroll="handleWaterfallScroll"
|
||||
class="fall-bg"
|
||||
/>
|
||||
src="/static/square/squearbj1.png"
|
||||
mode="aspectFill"
|
||||
></image>
|
||||
|
||||
<!-- Header组件 -->
|
||||
<Header
|
||||
@ -33,16 +15,6 @@
|
||||
backIconColor="#e6e6e6"
|
||||
/>
|
||||
|
||||
<!-- 轮播图 + 内容分类 Tab -->
|
||||
<view class="banner-tabs-wrapper">
|
||||
<BannerCarousel
|
||||
:bannerActivities="bannerActivities"
|
||||
@activityClick="handleActivityClick"
|
||||
@top3Click="showRankingModal = true"
|
||||
/>
|
||||
<ContentTabs class="tabs" v-model="activeContentTab" />
|
||||
</view>
|
||||
|
||||
<!-- 蒙层 - 导航栏展开时显示 -->
|
||||
<view v-if="navExpanded" class="nav-mask" @click="navExpanded = false"></view>
|
||||
|
||||
@ -65,6 +37,51 @@
|
||||
|
||||
<!-- 全局引导遮罩 -->
|
||||
<GuideOverlay />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<scroll-view
|
||||
class="content-wrapper"
|
||||
scroll-y
|
||||
:show-scrollbar="false"
|
||||
:bounce="false"
|
||||
@scrolltolower="handleScrollToLower"
|
||||
>
|
||||
<!-- 区域一:顶部运营轮播图 -->
|
||||
<view class="banner-section">
|
||||
<BannerCarousel
|
||||
:bannerActivities="bannerActivities"
|
||||
@activityClick="handleActivityClick"
|
||||
@top3Click="showRankingModal = true"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 区域二:分类标签区 -->
|
||||
<view id="category-section" class="category-section" :class="{ fixed: isFixed }">
|
||||
<scroll-view class="category-scroll" scroll-x :show-scrollbar="false">
|
||||
<view
|
||||
v-for="(category, index) in categories"
|
||||
:key="index"
|
||||
class="category-item"
|
||||
:class="{ active: activeContentTab === category.value }"
|
||||
@click="handleCategoryChange(category.value)"
|
||||
>
|
||||
<text class="category-text">{{ category.label }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 区域三:创作网格列表 -->
|
||||
<CreationGrid
|
||||
:screenWidth="screenWidth"
|
||||
:screenHeight="screenHeight"
|
||||
:bannerBottom="bannerBottomPx"
|
||||
:useMockData="USE_MOCK_DATA"
|
||||
:category="activeContentTab"
|
||||
:isActive="isActive"
|
||||
@cardClick="handleCardClick"
|
||||
ref="creationGridRef"
|
||||
/>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user