feat:修改广场页面,铸造页面

This commit is contained in:
zerosaturation 2026-05-26 19:26:27 +08:00
parent a39bb7b60f
commit b8e462f9b5
16 changed files with 714 additions and 265 deletions

View File

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

View File

@ -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(新鲜上架)
}

View File

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

View File

@ -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" +

View File

@ -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-301, 31-1002, 101-2003, 200+4
string material_type = 7; // : hot(), potential(), new()
}

View File

@ -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").

View File

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

View File

@ -20,4 +20,14 @@
<div id="app"><!--app-html--></div>
<script type="module" src="/main.js"></script>
</body>
</html>
</html>
<style>
html,body,#app,page{
height: 100%;
overscroll-behavior: none;/* 阻止系统橡皮筋 */
-webkit-overflow-scrolling: auto;
overflow: hidden;
}
</style>

View File

@ -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 5024
// 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;

View File

@ -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);

View File

@ -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'
});
};

View File

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

View File

@ -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. FormDataOSS
// :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 || '更新头像失败');
}

View 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>

View File

@ -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/msiOS 使 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

View File

@ -1,58 +1,30 @@
<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
:showGuideIcon="true"
:showTaskIcon="true"
:showStarActivityIcon="true"
backIconColor="#e6e6e6"
<Header
:showGuideIcon="true"
:showTaskIcon="true"
:showStarActivityIcon="true"
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>
<!-- 排行榜弹窗 -->
<RankingModal
:visible="showRankingModal"
:parent-active="true"
<RankingModal
:visible="showRankingModal"
:parent-active="true"
:star-id="currentStarId"
@update:visible="handleRankingModalClose"
@visit="handleRankingVisit"
@update:visible="handleRankingModalClose"
@visit="handleRankingVisit"
/>
<!-- 底部导航栏 -->
@ -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
}
// WaterfallGridiOS 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;