Compare commits
2 Commits
02233db0ec
...
3e6ff3c898
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e6ff3c898 | ||
|
|
5f4a443a93 |
@ -615,6 +615,8 @@ func (ctrl *GalleryController) GetMyExhibitedAssets(c *gin.Context) {
|
|||||||
ExhibitedAt: item.ExhibitedAt,
|
ExhibitedAt: item.ExhibitedAt,
|
||||||
ExpireAt: item.ExpireAt,
|
ExpireAt: item.ExpireAt,
|
||||||
Earnings: item.Earnings,
|
Earnings: item.Earnings,
|
||||||
|
SlotIndex: item.SlotIndex,
|
||||||
|
IsLenticular: item.IsLenticular,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -803,6 +805,8 @@ func (ctrl *GalleryController) GetUserExhibitedAssets(c *gin.Context) {
|
|||||||
ExhibitedAt: item.ExhibitedAt,
|
ExhibitedAt: item.ExhibitedAt,
|
||||||
ExpireAt: item.ExpireAt,
|
ExpireAt: item.ExpireAt,
|
||||||
Earnings: item.Earnings,
|
Earnings: item.Earnings,
|
||||||
|
SlotIndex: item.SlotIndex,
|
||||||
|
IsLenticular: item.IsLenticular,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -64,6 +64,7 @@ func ConvertAssetMaterialRelation(pbRel *pbAsset.AssetMaterialRelation) AssetMat
|
|||||||
MaterialType: pbRel.MaterialType,
|
MaterialType: pbRel.MaterialType,
|
||||||
LayerOrder: pbRel.LayerOrder,
|
LayerOrder: pbRel.LayerOrder,
|
||||||
MaterialURLSigned: pbRel.MaterialUrlSigned,
|
MaterialURLSigned: pbRel.MaterialUrlSigned,
|
||||||
|
OssKey: pbRel.OssKey,
|
||||||
}
|
}
|
||||||
if pbRel.PosX != 0 || pbRel.PosY != 0 {
|
if pbRel.PosX != 0 || pbRel.PosY != 0 {
|
||||||
px := pbRel.PosX
|
px := pbRel.PosX
|
||||||
|
|||||||
@ -203,6 +203,7 @@ type AssetMaterialRelationDTO struct {
|
|||||||
ScaleY *float64 `json:"scale_y"`
|
ScaleY *float64 `json:"scale_y"`
|
||||||
Width *int `json:"width"`
|
Width *int `json:"width"`
|
||||||
Height *int `json:"height"`
|
Height *int `json:"height"`
|
||||||
|
OssKey string `json:"oss_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadMaterialRequestDTO 上传素材请求
|
// UploadMaterialRequestDTO 上传素材请求
|
||||||
|
|||||||
@ -80,6 +80,8 @@ type ExhibitedAssetItemDTO struct {
|
|||||||
ExhibitedAt int64 `json:"exhibited_at"` // 展出开始时间(毫秒时间戳)
|
ExhibitedAt int64 `json:"exhibited_at"` // 展出开始时间(毫秒时间戳)
|
||||||
ExpireAt int64 `json:"expire_at"` // 展出过期时间(毫秒时间戳)
|
ExpireAt int64 `json:"expire_at"` // 展出过期时间(毫秒时间戳)
|
||||||
Earnings int64 `json:"earnings"` // 当前可领取收益
|
Earnings int64 `json:"earnings"` // 当前可领取收益
|
||||||
|
SlotIndex int32 `json:"slot_index"` // 展位序号
|
||||||
|
IsLenticular bool `json:"is_lenticular"` // 是否为光栅卡
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMyExhibitedAssetsResponseDTO 获取我展出的作品列表响应
|
// GetMyExhibitedAssetsResponseDTO 获取我展出的作品列表响应
|
||||||
|
|||||||
@ -2826,6 +2826,7 @@ type AssetMaterialRelation struct {
|
|||||||
Rotation float64 `protobuf:"fixed64,10,opt,name=rotation,proto3" json:"rotation,omitempty"`
|
Rotation float64 `protobuf:"fixed64,10,opt,name=rotation,proto3" json:"rotation,omitempty"`
|
||||||
ScaleX float64 `protobuf:"fixed64,11,opt,name=scale_x,json=scaleX,proto3" json:"scale_x,omitempty"`
|
ScaleX float64 `protobuf:"fixed64,11,opt,name=scale_x,json=scaleX,proto3" json:"scale_x,omitempty"`
|
||||||
ScaleY float64 `protobuf:"fixed64,12,opt,name=scale_y,json=scaleY,proto3" json:"scale_y,omitempty"`
|
ScaleY float64 `protobuf:"fixed64,12,opt,name=scale_y,json=scaleY,proto3" json:"scale_y,omitempty"`
|
||||||
|
OssKey string `protobuf:"bytes,13,opt,name=oss_key,json=ossKey,proto3" json:"oss_key,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -2944,6 +2945,13 @@ func (x *AssetMaterialRelation) GetScaleY() float64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *AssetMaterialRelation) GetOssKey() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.OssKey
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// 上传素材请求
|
// 上传素材请求
|
||||||
type UploadMaterialRequest struct {
|
type UploadMaterialRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -3859,7 +3867,7 @@ const file_asset_proto_rawDesc = "" +
|
|||||||
"\astar_id\x18\n" +
|
"\astar_id\x18\n" +
|
||||||
" \x01(\x03R\x06starId\x12\x1d\n" +
|
" \x01(\x03R\x06starId\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"created_at\x18\v \x01(\x03R\tcreatedAt\"\xfc\x02\n" +
|
"created_at\x18\v \x01(\x03R\tcreatedAt\"\x95\x03\n" +
|
||||||
"\x15AssetMaterialRelation\x12\x1f\n" +
|
"\x15AssetMaterialRelation\x12\x1f\n" +
|
||||||
"\vrelation_id\x18\x01 \x01(\x03R\n" +
|
"\vrelation_id\x18\x01 \x01(\x03R\n" +
|
||||||
"relationId\x12\x19\n" +
|
"relationId\x12\x19\n" +
|
||||||
@ -3876,7 +3884,8 @@ const file_asset_proto_rawDesc = "" +
|
|||||||
"\brotation\x18\n" +
|
"\brotation\x18\n" +
|
||||||
" \x01(\x01R\brotation\x12\x17\n" +
|
" \x01(\x01R\brotation\x12\x17\n" +
|
||||||
"\ascale_x\x18\v \x01(\x01R\x06scaleX\x12\x17\n" +
|
"\ascale_x\x18\v \x01(\x01R\x06scaleX\x12\x17\n" +
|
||||||
"\ascale_y\x18\f \x01(\x01R\x06scaleY\"\xf6\x01\n" +
|
"\ascale_y\x18\f \x01(\x01R\x06scaleY\x12\x17\n" +
|
||||||
|
"\aoss_key\x18\r \x01(\tR\x06ossKey\"\xf6\x01\n" +
|
||||||
"\x15UploadMaterialRequest\x12\x17\n" +
|
"\x15UploadMaterialRequest\x12\x17\n" +
|
||||||
"\aoss_key\x18\x01 \x01(\tR\x06ossKey\x12#\n" +
|
"\aoss_key\x18\x01 \x01(\tR\x06ossKey\x12#\n" +
|
||||||
"\roriginal_name\x18\x02 \x01(\tR\foriginalName\x12\x1b\n" +
|
"\roriginal_name\x18\x02 \x01(\tR\foriginalName\x12\x1b\n" +
|
||||||
|
|||||||
@ -1222,6 +1222,8 @@ type ExhibitedAssetItem struct {
|
|||||||
ExhibitedAt int64 `protobuf:"varint,5,opt,name=exhibited_at,json=exhibitedAt,proto3" json:"exhibited_at,omitempty"` // 展出开始时间(毫秒时间戳)
|
ExhibitedAt int64 `protobuf:"varint,5,opt,name=exhibited_at,json=exhibitedAt,proto3" json:"exhibited_at,omitempty"` // 展出开始时间(毫秒时间戳)
|
||||||
ExpireAt int64 `protobuf:"varint,6,opt,name=expire_at,json=expireAt,proto3" json:"expire_at,omitempty"` // 展出过期时间(毫秒时间戳)
|
ExpireAt int64 `protobuf:"varint,6,opt,name=expire_at,json=expireAt,proto3" json:"expire_at,omitempty"` // 展出过期时间(毫秒时间戳)
|
||||||
Earnings int64 `protobuf:"varint,7,opt,name=earnings,proto3" json:"earnings,omitempty"` // 当前可领取收益
|
Earnings int64 `protobuf:"varint,7,opt,name=earnings,proto3" json:"earnings,omitempty"` // 当前可领取收益
|
||||||
|
SlotIndex int32 `protobuf:"varint,8,opt,name=slot_index,json=slotIndex,proto3" json:"slot_index,omitempty"` // 展位序号
|
||||||
|
IsLenticular bool `protobuf:"varint,9,opt,name=is_lenticular,json=isLenticular,proto3" json:"is_lenticular,omitempty"` // 是否为光栅卡(根据 tags 判断)
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -1305,6 +1307,20 @@ func (x *ExhibitedAssetItem) GetEarnings() int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ExhibitedAssetItem) GetSlotIndex() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.SlotIndex
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExhibitedAssetItem) GetIsLenticular() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.IsLenticular
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 获取灵感瀑布藏品列表请求
|
// 获取灵感瀑布藏品列表请求
|
||||||
type GetInspirationFlowRequest struct {
|
type GetInspirationFlowRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -1801,7 +1817,7 @@ const file_gallery_proto_rawDesc = "" +
|
|||||||
"\x04page\x18\x02 \x01(\x05R\x04page\x12\x1b\n" +
|
"\x04page\x18\x02 \x01(\x05R\x04page\x12\x1b\n" +
|
||||||
"\tpage_size\x18\x03 \x01(\x05R\bpageSize\x12\x14\n" +
|
"\tpage_size\x18\x03 \x01(\x05R\bpageSize\x12\x14\n" +
|
||||||
"\x05total\x18\x04 \x01(\x03R\x05total\x12\x19\n" +
|
"\x05total\x18\x04 \x01(\x03R\x05total\x12\x19\n" +
|
||||||
"\bhas_more\x18\x05 \x01(\bR\ahasMore\"\xdb\x01\n" +
|
"\bhas_more\x18\x05 \x01(\bR\ahasMore\"\x9f\x02\n" +
|
||||||
"\x12ExhibitedAssetItem\x12\x19\n" +
|
"\x12ExhibitedAssetItem\x12\x19\n" +
|
||||||
"\basset_id\x18\x01 \x01(\x03R\aassetId\x12\x12\n" +
|
"\basset_id\x18\x01 \x01(\x03R\aassetId\x12\x12\n" +
|
||||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" +
|
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" +
|
||||||
@ -1810,7 +1826,10 @@ const file_gallery_proto_rawDesc = "" +
|
|||||||
"like_count\x18\x04 \x01(\x05R\tlikeCount\x12!\n" +
|
"like_count\x18\x04 \x01(\x05R\tlikeCount\x12!\n" +
|
||||||
"\fexhibited_at\x18\x05 \x01(\x03R\vexhibitedAt\x12\x1b\n" +
|
"\fexhibited_at\x18\x05 \x01(\x03R\vexhibitedAt\x12\x1b\n" +
|
||||||
"\texpire_at\x18\x06 \x01(\x03R\bexpireAt\x12\x1a\n" +
|
"\texpire_at\x18\x06 \x01(\x03R\bexpireAt\x12\x1a\n" +
|
||||||
"\bearnings\x18\a \x01(\x03R\bearnings\"\x9a\x01\n" +
|
"\bearnings\x18\a \x01(\x03R\bearnings\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"slot_index\x18\b \x01(\x05R\tslotIndex\x12#\n" +
|
||||||
|
"\ris_lenticular\x18\t \x01(\bR\fisLenticular\"\x9a\x01\n" +
|
||||||
"\x19GetInspirationFlowRequest\x12\x16\n" +
|
"\x19GetInspirationFlowRequest\x12\x16\n" +
|
||||||
"\x06cursor\x18\x01 \x01(\tR\x06cursor\x12\x1c\n" +
|
"\x06cursor\x18\x01 \x01(\tR\x06cursor\x12\x1c\n" +
|
||||||
"\tdirection\x18\x02 \x01(\tR\tdirection\x12\x14\n" +
|
"\tdirection\x18\x02 \x01(\tR\tdirection\x12\x14\n" +
|
||||||
|
|||||||
@ -360,6 +360,7 @@ message AssetMaterialRelation {
|
|||||||
double rotation = 10;
|
double rotation = 10;
|
||||||
double scale_x = 11;
|
double scale_x = 11;
|
||||||
double scale_y = 12;
|
double scale_y = 12;
|
||||||
|
string oss_key = 13;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传素材请求
|
// 上传素材请求
|
||||||
|
|||||||
@ -207,6 +207,8 @@ message ExhibitedAssetItem {
|
|||||||
int64 exhibited_at = 5; // 展出开始时间(毫秒时间戳)
|
int64 exhibited_at = 5; // 展出开始时间(毫秒时间戳)
|
||||||
int64 expire_at = 6; // 展出过期时间(毫秒时间戳)
|
int64 expire_at = 6; // 展出过期时间(毫秒时间戳)
|
||||||
int64 earnings = 7; // 当前可领取收益
|
int64 earnings = 7; // 当前可领取收益
|
||||||
|
int32 slot_index = 8; // 展位序号
|
||||||
|
bool is_lenticular = 9; // 是否为光栅卡(根据 tags 判断)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 灵感瀑布相关消息 ====================
|
// ==================== 灵感瀑布相关消息 ====================
|
||||||
|
|||||||
@ -99,6 +99,8 @@ type ExhibitedAssetInfo struct {
|
|||||||
ExhibitedAt int64
|
ExhibitedAt int64
|
||||||
ExpireAt int64
|
ExpireAt int64
|
||||||
Earnings int64
|
Earnings int64
|
||||||
|
SlotIndex int32
|
||||||
|
IsLenticular bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// galleryRepository Repository实现
|
// galleryRepository Repository实现
|
||||||
@ -417,7 +419,8 @@ func (r *galleryRepository) GetMyExhibitedAssets(userID, starID int64, page, pag
|
|||||||
err = r.db.Model(&models.Exhibition{}).
|
err = r.db.Model(&models.Exhibition{}).
|
||||||
Raw(`
|
Raw(`
|
||||||
SELECT exhibitions.asset_id, a.name, a.cover_url, a.like_count,
|
SELECT exhibitions.asset_id, a.name, a.cover_url, a.like_count,
|
||||||
exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index
|
exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index,
|
||||||
|
(a.tags @> '["craft:lenticular"]') as is_lenticular
|
||||||
FROM exhibitions
|
FROM exhibitions
|
||||||
JOIN assets a ON a.id = exhibitions.asset_id
|
JOIN assets a ON a.id = exhibitions.asset_id
|
||||||
JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id
|
JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id
|
||||||
@ -461,7 +464,8 @@ func (r *galleryRepository) GetUserExhibitedAssets(userID, starID int64, page, p
|
|||||||
err = r.db.Model(&models.Exhibition{}).
|
err = r.db.Model(&models.Exhibition{}).
|
||||||
Raw(`
|
Raw(`
|
||||||
SELECT exhibitions.asset_id, a.name, a.cover_url, a.like_count,
|
SELECT exhibitions.asset_id, a.name, a.cover_url, a.like_count,
|
||||||
exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index
|
exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index,
|
||||||
|
(a.tags @> '["craft:lenticular"]') as is_lenticular
|
||||||
FROM exhibitions
|
FROM exhibitions
|
||||||
JOIN assets a ON a.id = exhibitions.asset_id
|
JOIN assets a ON a.id = exhibitions.asset_id
|
||||||
JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id
|
JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id
|
||||||
|
|||||||
@ -216,6 +216,8 @@ func (s *exhibitionService) GetMyExhibitedAssets(ctx context.Context, userID, st
|
|||||||
ExhibitedAt: item.ExhibitedAt,
|
ExhibitedAt: item.ExhibitedAt,
|
||||||
ExpireAt: item.ExpireAt,
|
ExpireAt: item.ExpireAt,
|
||||||
Earnings: item.Earnings,
|
Earnings: item.Earnings,
|
||||||
|
SlotIndex: item.SlotIndex,
|
||||||
|
IsLenticular: item.IsLenticular,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -341,8 +341,8 @@ func (s *starbookService) buildAssetItemsFromRegistries(registries []*models.Ass
|
|||||||
if name, ok := assetNameMap[reg.AssetID]; ok {
|
if name, ok := assetNameMap[reg.AssetID]; ok {
|
||||||
item.Name = name
|
item.Name = name
|
||||||
}
|
}
|
||||||
if coverURL, ok := assetCoverMap[reg.AssetID]; ok {
|
if coverURL, ok := assetCoverMap[reg.AssetID]; ok && coverURL != "" {
|
||||||
item.CoverUrlSigned = coverURL // 直接返回原始 URL
|
item.CoverUrlSigned = coverURL
|
||||||
}
|
}
|
||||||
if cat, ok := categoryMap[reg.AssetID]; ok {
|
if cat, ok := categoryMap[reg.AssetID]; ok {
|
||||||
item.Category = cat
|
item.Category = cat
|
||||||
@ -433,11 +433,19 @@ func (s *starbookService) GetStarbookItems(req *pb.GetStarbookItemsRequest, owne
|
|||||||
// 构建 items
|
// 构建 items
|
||||||
items := s.buildAssetItemsFromRegistries(registries, assetType)
|
items := s.buildAssetItemsFromRegistries(registries, assetType)
|
||||||
|
|
||||||
|
// 过滤掉没有图片的藏品
|
||||||
|
filteredItems := make([]*pb.AssetItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if item.CoverUrlSigned != "" {
|
||||||
|
filteredItems = append(filteredItems, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hasMore := int64(page*pageSize) < totalCount
|
hasMore := int64(page*pageSize) < totalCount
|
||||||
|
|
||||||
return &pb.GetStarbookItemsResponse{
|
return &pb.GetStarbookItemsResponse{
|
||||||
Data: &pb.AssetListData{
|
Data: &pb.AssetListData{
|
||||||
Items: items,
|
Items: filteredItems,
|
||||||
Total: totalCount,
|
Total: totalCount,
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
|
|||||||
@ -427,9 +427,9 @@ func (r *fanProfileRepository) UpdateCrystalBalance(userID, starID int64, delta
|
|||||||
balanceBefore := profile.CrystalBalance
|
balanceBefore := profile.CrystalBalance
|
||||||
newBalance = balanceBefore + delta
|
newBalance = balanceBefore + delta
|
||||||
|
|
||||||
// 确保不会小于 0
|
// 扣除时检查余额是否充足
|
||||||
if newBalance < 0 {
|
if delta < 0 && balanceBefore < -delta {
|
||||||
newBalance = 0
|
return appErrors.ErrInsufficientCrystal
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 写入水晶流水(复式记账,包含余额快照)
|
// 3. 写入水晶流水(复式记账,包含余额快照)
|
||||||
|
|||||||
@ -24,3 +24,7 @@ OSS_TOKEN_EXPIRE_TIME=3600
|
|||||||
# ==================== MiniMax API Configuration ====================
|
# ==================== MiniMax API Configuration ====================
|
||||||
MINIMAX_API_KEY=sk-cp-Fffv8Bg8zeFD929_KUAZq9EKet64Nkxgu7t1ibZEngqmyPKaOOa7U8U_gtg3VICfUQyGPn8c5XR4hxmWzjKC4wO6DxKh5ipN36Yv5jsFzZWMEPh6NKV2qAE
|
MINIMAX_API_KEY=sk-cp-Fffv8Bg8zeFD929_KUAZq9EKet64Nkxgu7t1ibZEngqmyPKaOOa7U8U_gtg3VICfUQyGPn8c5XR4hxmWzjKC4wO6DxKh5ipN36Yv5jsFzZWMEPh6NKV2qAE
|
||||||
MINIMAX_API_URL=https://api.minimaxi.com/v1/image_generation
|
MINIMAX_API_URL=https://api.minimaxi.com/v1/image_generation
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_HOST=topfans-redis
|
||||||
|
REDIS_PASSWORD=123456
|
||||||
|
|||||||
@ -216,6 +216,7 @@ services:
|
|||||||
DB_NAME: topfans
|
DB_NAME: topfans
|
||||||
USER_SERVICE_URL: tri://userservice:20000
|
USER_SERVICE_URL: tri://userservice:20000
|
||||||
ASSET_SERVICE_URL: tri://assetservice:20003
|
ASSET_SERVICE_URL: tri://assetservice:20003
|
||||||
|
TASK_SERVICE_URL: tri://taskservice:20006
|
||||||
depends_on:
|
depends_on:
|
||||||
userservice:
|
userservice:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
@ -290,6 +291,7 @@ services:
|
|||||||
DB_PASSWORD: ${DB_PASSWORD:-postgres123}
|
DB_PASSWORD: ${DB_PASSWORD:-postgres123}
|
||||||
DB_NAME: topfans
|
DB_NAME: topfans
|
||||||
USER_SERVICE_URL: tri://userservice:20000
|
USER_SERVICE_URL: tri://userservice:20000
|
||||||
|
GALLERY_SERVICE_URL: tri://galleryservice:20001
|
||||||
depends_on:
|
depends_on:
|
||||||
userservice:
|
userservice:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
@ -369,6 +371,7 @@ services:
|
|||||||
DUBBO_ACTIVITY_SERVICE_URL: tri://activityservice:20004
|
DUBBO_ACTIVITY_SERVICE_URL: tri://activityservice:20004
|
||||||
DUBBO_TASK_SERVICE_URL: tri://taskservice:20006
|
DUBBO_TASK_SERVICE_URL: tri://taskservice:20006
|
||||||
DUBBO_STARBOOK_SERVICE_URL: tri://starbookservice:20007
|
DUBBO_STARBOOK_SERVICE_URL: tri://starbookservice:20007
|
||||||
|
REDIS_HOST: topfans-redis
|
||||||
depends_on:
|
depends_on:
|
||||||
userservice:
|
userservice:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|||||||
365
docs/slot-refactor.md
Normal file
365
docs/slot-refactor.md
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
# 展位重构设计文档
|
||||||
|
|
||||||
|
## 背景与目标
|
||||||
|
|
||||||
|
### 当前问题
|
||||||
|
- 数据库每用户有 **6 个槽位**(slot_index 1-6)
|
||||||
|
- 前端 UI 只设计 **2 个卡片**显示
|
||||||
|
- 现有匹配逻辑有 bug:空位显示与实际 slot_index 不对应
|
||||||
|
|
||||||
|
### 核心问题示例
|
||||||
|
```
|
||||||
|
数据库槽位状态(用户A):
|
||||||
|
slot_index=1: 空
|
||||||
|
slot_index=2: 空
|
||||||
|
slot_index=3: 展出中 (asset X)
|
||||||
|
slot_index=4: 展出中 (asset Y)
|
||||||
|
slot_index=5: 空
|
||||||
|
slot_index=6: 展出中 (asset Z)
|
||||||
|
|
||||||
|
前端显示:
|
||||||
|
- 展出中卡片: asset X (slot_index=3), asset Y (slot_index=4)
|
||||||
|
- 空位: 1个(因为 exhibitionWorks.length=2 < 2?不对,这里逻辑有问题)
|
||||||
|
|
||||||
|
问题:前端不知道 slot_index=1,2,5 哪个是空的
|
||||||
|
```
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
1. 每用户固定 **2 个展位**(slot_index=1, 2)
|
||||||
|
2. 前端只显示 **2 个卡片**,与后端 2 个槽位一一对应
|
||||||
|
3. **每个用户独立**,只能操作自己的展位
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题分析
|
||||||
|
|
||||||
|
### 当前前端空位逻辑(有问题)
|
||||||
|
```vue
|
||||||
|
<!-- exhibitionWorks.length === 2 时不显示空位 -->
|
||||||
|
<!-- exhibitionWorks.length === 1 时显示 1 个空位 -->
|
||||||
|
<!-- exhibitionWorks.length === 0 时显示 2 个空位 -->
|
||||||
|
|
||||||
|
<view v-if="exhibitionWorks.length < 2">
|
||||||
|
<view v-if="exhibitionWorks.length === 0" @tap="openAssetSelector(0)"> <!-- 左空位 -->
|
||||||
|
<view v-if="exhibitionWorks.length < 2" @tap="openAssetSelector(1)"> <!-- 右空位 -->
|
||||||
|
</view>
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题**:`openAssetSelector(position)` 传入的是 UI 位置(0或1),但不知道对应哪个 `slot_index`
|
||||||
|
|
||||||
|
### 现有 handleAssetSelect 逻辑
|
||||||
|
```javascript
|
||||||
|
// 原来:用 slot_index = position + 1 匹配
|
||||||
|
const desiredSlotIndex = currentSlotIndex.value + 1; // position=0 → slot_index=1
|
||||||
|
let targetSlot = slots.find(s => s.slot_index === desiredSlotIndex);
|
||||||
|
|
||||||
|
// 回退:如果 slot_index=1 被占用,用 operatableSlots[0]
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题**:如果 slot_index=1 已被占用但 slot_index=2 是空的,会匹配错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
### 方案:按 slot_index 计算空位
|
||||||
|
|
||||||
|
**核心思路**:
|
||||||
|
1. 前端获取 `mySlots`(前 2 个,按 slot_index 排序)
|
||||||
|
2. 计算 `emptySlotIndices = [1, 2] - 已展出作品的 slot_index 集合`
|
||||||
|
3. 点击空位时,传入 `slot_index`(不是 position)
|
||||||
|
|
||||||
|
### 前端改动
|
||||||
|
|
||||||
|
#### 1. 空位显示逻辑
|
||||||
|
```vue
|
||||||
|
<!-- 计算哪些 slot_index 是空的 -->
|
||||||
|
<view v-if="emptySlotIndices.length > 0" class="empty-exhibition">
|
||||||
|
<view
|
||||||
|
v-for="(slotIdx, uiIndex) in emptySlotIndices"
|
||||||
|
:key="'empty-' + uiIndex"
|
||||||
|
:class="uiIndex === 0 ? 'empty-card-left' : 'empty-card-right'"
|
||||||
|
@tap="openAssetSelector(slotIdx)">
|
||||||
|
<!-- 空位卡片 -->
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 添加计算属性
|
||||||
|
```javascript
|
||||||
|
// 加载展馆信息(用于确定空位)
|
||||||
|
const mySlots = ref([]);
|
||||||
|
|
||||||
|
const loadGalleryInfo = async () => {
|
||||||
|
const galleriesRes = await getMyGalleriesApi();
|
||||||
|
// 只取前2个可操作槽位,按 slot_index 排序
|
||||||
|
mySlots.value = galleriesRes.data?.slots
|
||||||
|
.filter(s => s.can_operate)
|
||||||
|
.sort((a, b) => (a.slot_index ?? 0) - (b.slot_index ?? 0))
|
||||||
|
.slice(0, 2) || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算空位:哪些 slot_index 没有展出中
|
||||||
|
const emptySlotIndices = computed(() => {
|
||||||
|
const occupiedSlots = exhibitionWorks.value.map(w => w.slot_index);
|
||||||
|
return mySlots.value
|
||||||
|
.filter(s => !occupiedSlots.includes(s.slot_index))
|
||||||
|
.map(s => s.slot_index);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 修改 openAssetSelector 参数
|
||||||
|
```javascript
|
||||||
|
// openAssetSelector 现在接收 slot_index(1 或 2)
|
||||||
|
const openAssetSelector = (slotIndex = 0) => {
|
||||||
|
currentSlotIndex.value = slotIndex; // 现在是 slot_index 值
|
||||||
|
showAssetSelector.value = true;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 修改 handleAssetSelect 匹配逻辑
|
||||||
|
```javascript
|
||||||
|
// 原来:用 position + 1 匹配,可能错误
|
||||||
|
// 现在:用 currentSlotIndex(就是 slot_index)直接匹配
|
||||||
|
|
||||||
|
const handleAssetSelect = async ({ asset, isReplace, oldAsset }) => {
|
||||||
|
// mySlots[0].slot_index = 1, mySlots[1].slot_index = 2
|
||||||
|
// currentSlotIndex.value 就是要放置的 slot_index
|
||||||
|
|
||||||
|
let targetSlotId = null;
|
||||||
|
|
||||||
|
if (isReplace && oldAsset) {
|
||||||
|
// 替换模式:根据旧藏品找到 slot_id
|
||||||
|
targetSlotId = slots.find(s => s.asset_id === oldAsset.asset_id)?.slot_id;
|
||||||
|
} else {
|
||||||
|
// 新放置模式:用 currentSlotIndex(= slot_index)直接找
|
||||||
|
targetSlotId = mySlots.value.find(s => s.slot_index === currentSlotIndex.value)?.slot_id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后端改动
|
||||||
|
|
||||||
|
### 1. 配置 (gallery_config.go)
|
||||||
|
|
||||||
|
```go
|
||||||
|
GalleryRules = &GalleryRulesConfig{
|
||||||
|
InitialSlotCount: 2, // 6 → 2
|
||||||
|
MaxSlotCount: 2, // 10 → 2
|
||||||
|
UnlockLevelBySlot: map[int]int{}, // 清空
|
||||||
|
UnlockCrystalBySlot: map[int]int{}, // 清空
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. PlaceAsset 简化 (exhibition_service.go)
|
||||||
|
|
||||||
|
**位置**: `backend/services/galleryService/service/exhibition_service.go:46-129`
|
||||||
|
|
||||||
|
**改动**:移除"放到他人展馆"的逻辑,只允许放到自己的展位
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 原来 (lines 87-97)
|
||||||
|
isOwner := slot.UserID == userID
|
||||||
|
if !isOwner {
|
||||||
|
// 如果不是自己的展位,必须是 public 且在同一个明星下
|
||||||
|
if slot.Visibility != "public" { ... }
|
||||||
|
if slot.StarID != starID { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改为
|
||||||
|
if slot.UserID != userID {
|
||||||
|
return nil, errors.New("只能在自己的展位放置藏品")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. calculateOperation 简化 (gallery_service.go)
|
||||||
|
|
||||||
|
**位置**: `backend/services/galleryService/service/gallery_service.go:221-280`
|
||||||
|
|
||||||
|
**问题**:当前逻辑允许在他人 public 空展位放置,与新设计冲突
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 原来(有问题的逻辑)
|
||||||
|
if slot.Visibility == "public" {
|
||||||
|
if exhibition == nil {
|
||||||
|
if !isOwner {
|
||||||
|
return true, "place" // ← 允许放他人 public 展位
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改为(新设计)
|
||||||
|
func (s *galleryService) calculateOperation(slot *models.BoothSlot, exhibition *models.Exhibition, viewerUID int64, isOwner bool) (bool, string) {
|
||||||
|
// 未解锁的展位不能操作
|
||||||
|
if !slot.IsEnabled {
|
||||||
|
return false, "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自己是展位所有者
|
||||||
|
if isOwner {
|
||||||
|
if exhibition == nil {
|
||||||
|
return true, "place" // 自己的空展位,可以放置
|
||||||
|
}
|
||||||
|
return true, "remove" // 有藏品,可以移除
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不是展位所有者,不能操作
|
||||||
|
return false, "none"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. GetSlotsByUser 添加 LIMIT (gallery_repository.go)
|
||||||
|
|
||||||
|
**位置**: `backend/services/galleryService/repository/gallery_repository.go:117-124`
|
||||||
|
|
||||||
|
**问题**:查询可能返回超过 2 个槽位
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 原来
|
||||||
|
func (r *galleryRepository) GetSlotsByUser(userID, starID int64) ([]*models.BoothSlot, error) {
|
||||||
|
err := r.db.Where("user_id = ? AND star_id = ?", userID, starID).
|
||||||
|
Order("slot_index ASC").
|
||||||
|
Find(&slots).Error // 没有 LIMIT
|
||||||
|
return slots, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改为(添加 LIMIT 2)
|
||||||
|
func (r *galleryRepository) GetSlotsByUser(userID, starID int64) ([]*models.BoothSlot, error) {
|
||||||
|
var slots []*models.BoothSlot
|
||||||
|
err := r.db.Where("user_id = ? AND star_id = ?", userID, starID).
|
||||||
|
Order("slot_index ASC").
|
||||||
|
Limit(2). // ← 新增
|
||||||
|
Find(&slots).Error
|
||||||
|
return slots, err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. GetUserGallery 浏览他人展馆 (gallery_service.go)
|
||||||
|
|
||||||
|
**位置**: `backend/services/galleryService/service/gallery_service.go:94-140`
|
||||||
|
|
||||||
|
**行为变化**:
|
||||||
|
- 他人展馆 `can_operate: false`(不能操作,只能浏览)
|
||||||
|
- 前端不应显示"放置"按钮
|
||||||
|
|
||||||
|
### 3. 已有数据处理
|
||||||
|
|
||||||
|
| 情况 | 处理方式 |
|
||||||
|
|------|----------|
|
||||||
|
| 已有 6 槽用户 | 需要数据库迁移清理 |
|
||||||
|
| 数据库 migration | **需要**(见下方 SQL) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据库迁移
|
||||||
|
|
||||||
|
### 迁移原因
|
||||||
|
- 已有用户每人有 6 个槽位(slot_index 1-6)
|
||||||
|
- 新设计只需 2 个槽位(slot_index 1-2)
|
||||||
|
- 需要清理多余数据,避免混淆
|
||||||
|
|
||||||
|
### 迁移 SQL
|
||||||
|
```sql
|
||||||
|
-- 1. 先删除 slot_index > 2 的展览记录(引用完整性)
|
||||||
|
DELETE FROM exhibitions
|
||||||
|
WHERE slot_id IN (
|
||||||
|
SELECT slot_id FROM booth_slots
|
||||||
|
WHERE slot_index > 2
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. 删除 slot_index > 2 的槽位记录
|
||||||
|
DELETE FROM booth_slots
|
||||||
|
WHERE slot_index > 2;
|
||||||
|
|
||||||
|
-- 3. 验证清理结果
|
||||||
|
SELECT user_id, star_id, COUNT(*) as slot_count
|
||||||
|
FROM booth_slots
|
||||||
|
GROUP BY user_id, star_id
|
||||||
|
HAVING COUNT(*) > 2;
|
||||||
|
-- 应该返回空结果
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实施步骤
|
||||||
|
|
||||||
|
### 数据库
|
||||||
|
1. [ ] **备份数据**(重要!)
|
||||||
|
2. [ ] 执行清理 SQL(删除 slot_index > 2 的数据)
|
||||||
|
|
||||||
|
### 后端(5 处改动)
|
||||||
|
3. [ ] 修改 `gallery_config.go` 配置值
|
||||||
|
- `InitialSlotCount: 2`
|
||||||
|
- `MaxSlotCount: 2`
|
||||||
|
- 清空 `UnlockLevelBySlot` 和 `UnlockCrystalBySlot`
|
||||||
|
4. [ ] 修改 `PlaceAsset` 逻辑(移除他人展位)
|
||||||
|
5. [ ] 修改 `calculateOperation` 逻辑(移除 public 放置权限)
|
||||||
|
6. [ ] 修改 `GetSlotsByUser` 添加 `LIMIT 2`
|
||||||
|
7. [ ] 重启后端服务
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
8. [ ] 添加 `mySlots` ref 存储槽位信息
|
||||||
|
9. [ ] 添加 `loadGalleryInfo()` 获取展馆数据
|
||||||
|
10. [ ] 添加 `emptySlotIndices` 计算属性
|
||||||
|
11. [ ] 修改空位模板使用 `emptySlotIndices`
|
||||||
|
12. [ ] 修改 `handleAssetSelect` 直接用 `slot_index` 匹配
|
||||||
|
13. [ ] 测试:左右空位点击后正确放置
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
14. [ ] 多账号测试(各自独立)
|
||||||
|
15. [ ] 空位放置测试
|
||||||
|
16. [ ] 替换测试
|
||||||
|
17. [ ] 浏览他人展馆(只读,不能放置)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险评估
|
||||||
|
|
||||||
|
| 风险 | 级别 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 数据库迁移 | **高** | 删除数据需谨慎,必须先备份 |
|
||||||
|
| `calculateOperation` 修改 | **高** | 影响展位操作权限逻辑 |
|
||||||
|
| `GetSlotsByUser` 添加 LIMIT | **低** | 仅影响返回值数量,调用方逻辑不受影响 |
|
||||||
|
| 前端改动较多 | 中 | 涉及空位逻辑重构 |
|
||||||
|
| PlaceAsset 简化 | 低 | 移除多余逻辑 |
|
||||||
|
| 向后兼容 | 无 | 新用户和老用户都是 2 槽 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 影响分析
|
||||||
|
|
||||||
|
### 会影响的现有功能
|
||||||
|
|
||||||
|
| 功能 | 影响 | 需测试 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `calculateOperation` | 改变他人 public 空展位放置权限 | **是** |
|
||||||
|
| `GetSlotsByUser` 返回数量 | 可能影响其他使用该函数的地方 | **是** |
|
||||||
|
| 浏览他人展馆 | `can_operate` 始终为 false | 否(预期行为) |
|
||||||
|
|
||||||
|
### 需要检查的其他调用方
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 GetSlotsByUser 的其他调用
|
||||||
|
grep -rn "GetSlotsByUser" backend/
|
||||||
|
|
||||||
|
# 检查 calculateOperation 的其他调用(无其他调用方,仅内部使用)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 影响结论
|
||||||
|
|
||||||
|
| 函数 | 调用方 | 影响分析 |
|
||||||
|
|------|--------|----------|
|
||||||
|
| `GetSlotsByUser` | `GetMyGallery`, `GetUserGallery` | 添加 LIMIT 2 后,查询只返回前 2 个槽位(符合预期) |
|
||||||
|
| `calculateOperation` | 仅 `buildSlotInfos`(内部) | 修改后,他人 public 空展位不能放置(符合预期) |
|
||||||
|
| `PlaceAsset` | Gateway 暴露 | 修改后,只能放自己展位(符合预期) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证方法
|
||||||
|
|
||||||
|
1. **多账号测试**:A 账号和 B 账号登录,分别操作自己的展位,不混淆
|
||||||
|
2. **空位放置测试**:slot_index=1 空的、slot_index=2 占用时,点击左空位放到 slot_index=1
|
||||||
|
3. **替换测试**:slot_index=1 展出 A,点击 slot_index=1 的卡替换成 B
|
||||||
|
4. **解锁测试**:调用 UnlockSlot 应返回"已达到最大展位数"
|
||||||
@ -39,11 +39,11 @@
|
|||||||
<view class="vignette" />
|
<view class="vignette" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="showHint && tiltHintText" class="tilt-hint">
|
<!-- <view v-if="showHint && tiltHintText" class="tilt-hint">
|
||||||
<text class="tilt-hint-icon">↻</text>
|
<text class="tilt-hint-icon">↻</text>
|
||||||
<text class="tilt-hint-text">{{ tiltHintText }}</text>
|
<text class="tilt-hint-text">{{ tiltHintText }}</text>
|
||||||
<text v-if="approximatePreview" class="tilt-hint-sub">叠化近似预览(倾斜或拖动体验光栅效果)</text>
|
<text v-if="approximatePreview" class="tilt-hint-sub">叠化近似预览(倾斜或拖动体验光栅效果)</text>
|
||||||
</view>
|
</view> -->
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -707,6 +707,12 @@ const selectAsset = async () => {
|
|||||||
const handleConfirmMint = async () => {
|
const handleConfirmMint = async () => {
|
||||||
showConfirmModal.value = false;
|
showConfirmModal.value = false;
|
||||||
|
|
||||||
|
// 检查余额是否充足
|
||||||
|
if (confirmCostInfo.value.currentBalance < confirmCostInfo.value.costCrystal) {
|
||||||
|
uni.showToast({ title: '水晶余额不足,无法铸造', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const imagePath =
|
const imagePath =
|
||||||
isLenticularDisplay.value
|
isLenticularDisplay.value
|
||||||
? lenticularLayers.value.find((l) => l.id === 'mid')?.src || ''
|
? lenticularLayers.value.find((l) => l.id === 'mid')?.src || ''
|
||||||
@ -717,9 +723,9 @@ const handleConfirmMint = async () => {
|
|||||||
|
|
||||||
uni.showLoading({ title: '铸造中…', mask: true });
|
uni.showLoading({ title: '铸造中…', mask: true });
|
||||||
try {
|
try {
|
||||||
// 更新本地存储的余额
|
|
||||||
updateLocalBalance(confirmCostInfo.value.balanceAfter);
|
|
||||||
await submitCraftMintFromPath({ imagePath, bgImagePath, formData: craftFormData.value });
|
await submitCraftMintFromPath({ imagePath, bgImagePath, formData: craftFormData.value });
|
||||||
|
// 只有铸造成功后才扣除本地余额
|
||||||
|
updateLocalBalance(confirmCostInfo.value.balanceAfter);
|
||||||
uni.hideLoading();
|
uni.hideLoading();
|
||||||
uni.navigateTo({ url: '/pages/castlove/success' });
|
uni.navigateTo({ url: '/pages/castlove/success' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -57,7 +57,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 顶粉水晶余额 -->
|
<!-- 顶粉水晶余额 -->
|
||||||
<view class="crystal-balance-new">
|
<view class="crystal-balance-new" @click="handleAvatarClick">
|
||||||
<!-- 1. 左侧钻石图标:层级最高,盖住右侧背景 -->
|
<!-- 1. 左侧钻石图标:层级最高,盖住右侧背景 -->
|
||||||
<view class="crystal-icon-box">
|
<view class="crystal-icon-box">
|
||||||
<image class="crystal-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
|
<image class="crystal-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
|
||||||
|
|||||||
@ -28,7 +28,17 @@
|
|||||||
<view v-for="(item, index) in exhibitionWorks" :key="item.id" class="exhibition-card"
|
<view v-for="(item, index) in exhibitionWorks" :key="item.id" class="exhibition-card"
|
||||||
:class="index % 2 === 0 ? 'card-tilt-left' : 'card-tilt-right'"
|
:class="index % 2 === 0 ? 'card-tilt-left' : 'card-tilt-right'"
|
||||||
@tap="handleExhibitionCardTap(item, index)">
|
@tap="handleExhibitionCardTap(item, index)">
|
||||||
<image class="card-image" :src="item.cover_url || '/static/nft/placeholder.png'"
|
<LenticularCard
|
||||||
|
v-if="item.is_lenticular"
|
||||||
|
class="card-lenticular"
|
||||||
|
:layers="getLenticularLayers(item.id)"
|
||||||
|
:transforms="getLenticularTransforms(item.id)"
|
||||||
|
:gyro-source="gyroSourceLabel"
|
||||||
|
:skip-built-in-touch="false"
|
||||||
|
:shimmer-mid-opacity="0.16"
|
||||||
|
@simulate="(x, y) => onLenticularSimulate(item.id, x, y)"
|
||||||
|
/>
|
||||||
|
<image v-else class="card-image" :src="item.cover_url || '/static/nft/placeholder.png'"
|
||||||
mode="aspectFill"></image>
|
mode="aspectFill"></image>
|
||||||
<!-- 领取收益按钮 -->
|
<!-- 领取收益按钮 -->
|
||||||
<view class="claim-reward-btn" v-if="isRewardClaimable(item.id)">
|
<view class="claim-reward-btn" v-if="isRewardClaimable(item.id)">
|
||||||
@ -167,12 +177,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||||
import { getMyExhibitedAssetsApi, getMyLikedAssetsApi, getMyTodayLikedAssetsApi, getMyWeekLikedAssetsApi, getMyGalleriesApi, placeAssetToGalleryApi } from '@/utils/api.js';
|
import { getMyExhibitedAssetsApi, getMyLikedAssetsApi, getMyTodayLikedAssetsApi, getMyWeekLikedAssetsApi, getMyGalleriesApi, placeAssetToGalleryApi, getAssetMaterialsApi } from '@/utils/api.js';
|
||||||
import { getExhibitionRevenue, claimExhibitionRevenue } from '@/utils/task-api.js';
|
import { getExhibitionRevenue, claimExhibitionRevenue } from '@/utils/task-api.js';
|
||||||
import AssetSelector from '../components/AssetSelector.vue';
|
import AssetSelector from '../components/AssetSelector.vue';
|
||||||
import { onShow } from '@dcloudio/uni-app';
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
import { doubleTapLike } from '@/utils/likeHelper.js';
|
import { doubleTapLike } from '@/utils/likeHelper.js';
|
||||||
|
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
|
||||||
|
import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftTiltPreview.js';
|
||||||
|
import { buildLenticularLayers } from '@/utils/castloveMintForm.js';
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
// 获取页面栈
|
// 获取页面栈
|
||||||
@ -496,6 +509,157 @@ const likedWorks = ref([]);
|
|||||||
// 点赞标签状态: current-当前, today-今日, week-本周
|
// 点赞标签状态: current-当前, today-今日, week-本周
|
||||||
const likedTab = ref('current');
|
const likedTab = ref('current');
|
||||||
|
|
||||||
|
// 光栅卡预览相关
|
||||||
|
// 每张光栅卡独立的 transforms,通过 asset id 索引
|
||||||
|
const lenticularTransformsMap = ref({});
|
||||||
|
const lenticularLayersByAsset = ref({});
|
||||||
|
const activeLenticularId = ref(null);
|
||||||
|
const gyroSourceLabel = ref('device');
|
||||||
|
|
||||||
|
// 创建单一引擎供模拟倾斜使用
|
||||||
|
const lenticularPhysics = ref(null);
|
||||||
|
const lenticularEngine = ref(null);
|
||||||
|
let lenticularRafId = null;
|
||||||
|
|
||||||
|
// 使用 useLenticularPreview 来管理光栅效果
|
||||||
|
const lenticularLayersRef = ref([]);
|
||||||
|
|
||||||
|
function getLenticularLayers(assetId) {
|
||||||
|
return lenticularLayersByAsset.value[assetId] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLenticularTransforms(assetId) {
|
||||||
|
return lenticularTransformsMap.value[assetId] || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLenticularLayersForAsset(assetId) {
|
||||||
|
// 需要获取 bg + subject 素材构建 layers
|
||||||
|
// 目前使用 buildLenticularLayers(coverUrl) 作为占位
|
||||||
|
const item = exhibitionWorks.value.find(w => w.id === assetId);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
// 异步获取素材列表,参考 asset-detail.vue 的实现
|
||||||
|
try {
|
||||||
|
const materialsRes = await getAssetMaterialsApi(assetId);
|
||||||
|
if (materialsRes.code === 200 && materialsRes.data) {
|
||||||
|
const materialsList = Array.isArray(materialsRes.data) ? materialsRes.data : materialsRes.data.materials || [];
|
||||||
|
let subjectUrl = item.cover_url;
|
||||||
|
let bgUrl = '';
|
||||||
|
for (const mat of materialsList) {
|
||||||
|
if (mat.material_type === 'main' && mat.material_url_signed) {
|
||||||
|
subjectUrl = mat.material_url_signed;
|
||||||
|
}
|
||||||
|
if (mat.material_type === 'bg' && mat.material_url_signed) {
|
||||||
|
bgUrl = mat.material_url_signed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 使用 buildLenticularLayersTwo 或 buildLenticularLayers
|
||||||
|
if (bgUrl) {
|
||||||
|
const { buildLenticularLayersTwo } = await import('@/utils/castloveMintForm.js');
|
||||||
|
lenticularLayersByAsset.value[assetId] = buildLenticularLayersTwo(bgUrl, subjectUrl);
|
||||||
|
} else {
|
||||||
|
lenticularLayersByAsset.value[assetId] = buildLenticularLayers(subjectUrl);
|
||||||
|
}
|
||||||
|
// 初始化 transforms
|
||||||
|
initTransformsForAsset(assetId);
|
||||||
|
} else {
|
||||||
|
lenticularLayersByAsset.value[assetId] = buildLenticularLayers(item.cover_url);
|
||||||
|
initTransformsForAsset(assetId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[myWorks] 获取素材列表失败:', e);
|
||||||
|
lenticularLayersByAsset.value[assetId] = buildLenticularLayers(item.cover_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理光栅卡触摸模拟
|
||||||
|
function onLenticularSimulate(assetId, x, y) {
|
||||||
|
simulateLenticularTilt(assetId, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理光栅卡触摸模拟
|
||||||
|
function simulateLenticularTilt(assetId, x, y) {
|
||||||
|
if (!lenticularEngine.value) return;
|
||||||
|
const layers = lenticularLayersByAsset.value[assetId];
|
||||||
|
if (!layers || layers.length === 0) return;
|
||||||
|
|
||||||
|
// 更新引擎的层
|
||||||
|
lenticularEngine.value.setLayers(layers);
|
||||||
|
|
||||||
|
// 计算渲染状态
|
||||||
|
const renderState = lenticularEngine.value.feedSimulatedTilt(x, y ? y : 0);
|
||||||
|
|
||||||
|
const transforms = {};
|
||||||
|
for (const l of layers) {
|
||||||
|
const rs = renderState.layerOffsets.get(l.id);
|
||||||
|
const op = renderState.layerOpacities.get(l.id);
|
||||||
|
transforms[l.id] = {
|
||||||
|
x: rs ? rs.x : 0,
|
||||||
|
y: rs ? rs.y : 0,
|
||||||
|
opacity: op != null ? op : l.opacity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 创建新对象以触发 Vue 响应式更新
|
||||||
|
lenticularTransformsMap.value = { ...lenticularTransformsMap.value, [assetId]: transforms };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化光栅引擎
|
||||||
|
function initLenticularEngine() {
|
||||||
|
if (lenticularEngine.value) return;
|
||||||
|
import('@/utils/lenticular-engine.js').then(({ LenticularEngine, DEFAULT_PHYSICS }) => {
|
||||||
|
const physics = { ...DEFAULT_PHYSICS, parallaxDepth: 18 };
|
||||||
|
physics.gyroSimEnabled = false;
|
||||||
|
lenticularPhysics.value = physics;
|
||||||
|
lenticularEngine.value = new LenticularEngine(physics);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个资产初始化 transforms 为空(居中状态)
|
||||||
|
function initTransformsForAsset(assetId) {
|
||||||
|
const layers = lenticularLayersByAsset.value[assetId];
|
||||||
|
if (!layers || layers.length === 0) return;
|
||||||
|
const transforms = {};
|
||||||
|
for (const l of layers) {
|
||||||
|
transforms[l.id] = { x: 0, y: 0, opacity: l.opacity || 1 };
|
||||||
|
}
|
||||||
|
lenticularTransformsMap.value = { ...lenticularTransformsMap.value, [assetId]: transforms };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始光栅渲染循环(为所有卡片更新 transforms)
|
||||||
|
function startLenticularRenderLoop() {
|
||||||
|
if (lenticularRafId !== null) return;
|
||||||
|
const tick = () => {
|
||||||
|
// 遍历所有有层数据的卡片,更新 transforms
|
||||||
|
for (const assetId of Object.keys(lenticularLayersByAsset.value)) {
|
||||||
|
const layers = lenticularLayersByAsset.value[assetId];
|
||||||
|
if (!layers || layers.length === 0) continue;
|
||||||
|
// 使用当前的 sensorData 或默认的 gamma=0 来渲染
|
||||||
|
lenticularEngine.value.setLayers(layers);
|
||||||
|
const renderState = lenticularEngine.value.feedSimulatedTilt(0, 0);
|
||||||
|
const transforms = {};
|
||||||
|
for (const l of layers) {
|
||||||
|
const rs = renderState.layerOffsets.get(l.id);
|
||||||
|
const op = renderState.layerOpacities.get(l.id);
|
||||||
|
transforms[l.id] = {
|
||||||
|
x: rs ? rs.x : 0,
|
||||||
|
y: rs ? rs.y : 0,
|
||||||
|
opacity: op != null ? op : l.opacity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
lenticularTransformsMap.value = { ...lenticularTransformsMap.value, [assetId]: transforms };
|
||||||
|
}
|
||||||
|
lenticularRafId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
lenticularRafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLenticularRenderLoop() {
|
||||||
|
if (lenticularRafId !== null) {
|
||||||
|
cancelAnimationFrame(lenticularRafId);
|
||||||
|
lenticularRafId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 切换点赞标签
|
// 切换点赞标签
|
||||||
const switchLikedTab = async (tab) => {
|
const switchLikedTab = async (tab) => {
|
||||||
if (likedTab.value === tab) return;
|
if (likedTab.value === tab) return;
|
||||||
@ -519,8 +683,17 @@ const loadExhibitedAssets = async () => {
|
|||||||
expire_at: item.expire_at,
|
expire_at: item.expire_at,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
slot_index: item.slot_index ?? 0,
|
slot_index: item.slot_index ?? 0,
|
||||||
|
is_lenticular: item.is_lenticular ?? false,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => (a.slot_index ?? 0) - (b.slot_index ?? 0));
|
.sort((a, b) => (a.slot_index ?? 0) - (b.slot_index ?? 0));
|
||||||
|
|
||||||
|
// 为每个光栅卡加载层级数据
|
||||||
|
for (const item of exhibitionWorks.value) {
|
||||||
|
if (item.is_lenticular) {
|
||||||
|
loadLenticularLayersForAsset(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('展出作品:', exhibitionWorks.value);
|
console.log('展出作品:', exhibitionWorks.value);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -562,6 +735,8 @@ const loadLikedAssets = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
initLenticularEngine();
|
||||||
|
startLenticularRenderLoop();
|
||||||
loadExhibitedAssets();
|
loadExhibitedAssets();
|
||||||
loadLikedAssets();
|
loadLikedAssets();
|
||||||
|
|
||||||
@ -583,6 +758,7 @@ onUnmounted(() => {
|
|||||||
if (countdownTimer) {
|
if (countdownTimer) {
|
||||||
clearInterval(countdownTimer);
|
clearInterval(countdownTimer);
|
||||||
}
|
}
|
||||||
|
stopLenticularRenderLoop();
|
||||||
uni.$off('userInfoUpdated');
|
uni.$off('userInfoUpdated');
|
||||||
uni.$off('assetLiked');
|
uni.$off('assetLiked');
|
||||||
});
|
});
|
||||||
@ -792,6 +968,18 @@ onShow(() => {
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-lenticular {
|
||||||
|
width: 88%;
|
||||||
|
height: 93%;
|
||||||
|
left: 5%;
|
||||||
|
top: 4%;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
transform-origin: center center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* 领取收益按钮 */
|
/* 领取收益按钮 */
|
||||||
.claim-reward-btn {
|
.claim-reward-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user