feat(asset): restore starbook home More button by Asset.LikeCount ordering
starbook 首页每个 grade/group 按 Asset.LikeCount DESC, Asset.ID ASC 取前三张,超三显示 has_more; GetAssetsByType 不截断返回全部匹配项。包含: - proto/asset.proto 声明 GetAssetsByType RPC + 消息,重新生成 .pb.go/.triple.go - gateway starbook controller 改用标准 AssetService 客户端 - assetService: 三个 build*GroupForAssets 增加 previewLimit 参数 - 新增 sortAndLimitAssetItems 共享助手 - 新增 asset_service_group_test.go DB-free 单测 - 新增 TestStarbookHomePreviewAndMoreUseAssetLikeRanking 端到端回归 - frontend pages/starbook/items.vue 适配嵌套 groups/grades/items 响应,单 grade 竖向展示 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
23e5a600ad
commit
e079a6c2e2
@ -17,18 +17,14 @@ import (
|
|||||||
// StarbookController 星册控制器
|
// StarbookController 星册控制器
|
||||||
//
|
//
|
||||||
// ★ 批次 4.2 起,星册路由由 gateway → assetService 接管(原 starbookService 已删除)。
|
// ★ 批次 4.2 起,星册路由由 gateway → assetService 接管(原 starbookService 已删除)。
|
||||||
// client 类型使用 pbAsset.AssetServiceGetAssetsByTypeClient(扩展接口):
|
// GetMyAssets(home) 与 GetAssetsByType(items) 均由 asset.proto 生成的标准 AssetService client 提供。
|
||||||
// - 嵌入 AssetService → 可调 GetMyAssets(home)
|
|
||||||
// - 自带 GetAssetsByType(items)
|
|
||||||
// 这样 gateway 端只需要一个 client 实例即可完成两个端点。
|
|
||||||
type StarbookController struct {
|
type StarbookController struct {
|
||||||
assetClient pbAsset.AssetServiceGetAssetsByTypeClient
|
assetClient pbAsset.AssetService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStarbookController 创建星册控制器
|
// NewStarbookController 创建星册控制器
|
||||||
func NewStarbookController(assetClient *client.Client) (*StarbookController, error) {
|
func NewStarbookController(assetClient *client.Client) (*StarbookController, error) {
|
||||||
// 使用扩展接口构造器(asset.pb.go 在 Task 1 已添加),保持与 AssetController 同一 Dubbo 客户端实例
|
assetService, err := pbAsset.NewAssetService(assetClient)
|
||||||
assetService, err := pbAsset.NewAssetServiceGetAssetsByTypeClient(assetClient)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -152,4 +148,4 @@ func (ctrl *StarbookController) GetStarbookItems(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, resp)
|
response.Success(c, resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,12 +14,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// fakeStarbookClient — 实现 pbAsset.AssetServiceGetAssetsByTypeClient
|
// fakeStarbookClient — 实现 pbAsset.AssetService
|
||||||
// 只 stub 关注的 GetMyAssets / GetAssetsByType 两个方法,
|
// 只 stub 关注的 GetMyAssets / GetAssetsByType 两个方法,
|
||||||
// 其余方法通过嵌入 nil 接口在调用时触发 panic(测试不会触发)
|
// 其余方法通过嵌入 nil 接口在调用时触发 panic(测试不会触发)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
type fakeStarbookClient struct {
|
type fakeStarbookClient struct {
|
||||||
pbAsset.AssetServiceGetAssetsByTypeClient // 嵌入 nil 接口
|
pbAsset.AssetService // 嵌入 nil 接口
|
||||||
|
|
||||||
// GetMyAssets 配置
|
// GetMyAssets 配置
|
||||||
homeResp *pbAsset.GetMyAssetsResponse
|
homeResp *pbAsset.GetMyAssetsResponse
|
||||||
@ -269,4 +269,4 @@ func TestStarbookItems_MissingType(t *testing.T) {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
type errString string
|
type errString string
|
||||||
|
|
||||||
func (e errString) Error() string { return string(e) }
|
func (e errString) Error() string { return string(e) }
|
||||||
|
|||||||
@ -7,11 +7,6 @@
|
|||||||
package asset
|
package asset
|
||||||
|
|
||||||
import (
|
import (
|
||||||
context "context"
|
|
||||||
client "dubbo.apache.org/dubbo-go/v3/client"
|
|
||||||
constant "dubbo.apache.org/dubbo-go/v3/common/constant"
|
|
||||||
triple_protocol "dubbo.apache.org/dubbo-go/v3/protocol/triple/triple_protocol"
|
|
||||||
server "dubbo.apache.org/dubbo-go/v3/server"
|
|
||||||
common "github.com/topfans/backend/pkg/proto/common"
|
common "github.com/topfans/backend/pkg/proto/common"
|
||||||
_ "google.golang.org/genproto/googleapis/api/annotations"
|
_ "google.golang.org/genproto/googleapis/api/annotations"
|
||||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
@ -3949,10 +3944,8 @@ func (x *TrackShareResponse) GetShareEventId() int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAssetsByTypeRequest filters a user's starbook assets by type.
|
// 按类型/分类/等级过滤查询星册藏品请求
|
||||||
//
|
// user_id/star_id 仅为兼容既有 wire contract;服务端身份必须从可信 Dubbo attachments 读取。
|
||||||
// This type is appended manually because regenerating asset.triple.go is
|
|
||||||
// intentionally prohibited for the starbook recovery change.
|
|
||||||
type GetAssetsByTypeRequest struct {
|
type GetAssetsByTypeRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||||
@ -4045,7 +4038,7 @@ func (x *GetAssetsByTypeRequest) GetPageSize() int32 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAssetsByTypeResponse returns the existing grouped AssetListData shape.
|
// 按类型/分类/等级过滤查询星册藏品响应
|
||||||
type GetAssetsByTypeResponse struct {
|
type GetAssetsByTypeResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"`
|
Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"`
|
||||||
@ -4098,59 +4091,6 @@ func (x *GetAssetsByTypeResponse) GetData() *AssetListData {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssetServiceGetAssetsByTypeProcedure is the fully-qualified RPC procedure.
|
|
||||||
const AssetServiceGetAssetsByTypeProcedure = "/topfans.asset.AssetService/GetAssetsByType"
|
|
||||||
|
|
||||||
// AssetServiceGetAssetsByTypeClient extends the generated AssetService client
|
|
||||||
// without editing asset.triple.go.
|
|
||||||
type AssetServiceGetAssetsByTypeClient interface {
|
|
||||||
AssetService
|
|
||||||
GetAssetsByType(context.Context, *GetAssetsByTypeRequest, ...client.CallOption) (*GetAssetsByTypeResponse, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssetServiceGetAssetsByTypeHandler extends the generated provider contract
|
|
||||||
// without editing asset.triple.go.
|
|
||||||
type AssetServiceGetAssetsByTypeHandler interface {
|
|
||||||
AssetServiceHandler
|
|
||||||
GetAssetsByType(context.Context, *GetAssetsByTypeRequest) (*GetAssetsByTypeResponse, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAssetServiceGetAssetsByTypeClient constructs a client exposing the new RPC.
|
|
||||||
func NewAssetServiceGetAssetsByTypeClient(cli *client.Client, opts ...client.ReferenceOption) (AssetServiceGetAssetsByTypeClient, error) {
|
|
||||||
conn, err := cli.DialWithInfo("topfans.asset.AssetService", &AssetService_ClientInfo, opts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &AssetServiceImpl{conn: conn}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AssetServiceImpl) GetAssetsByType(ctx context.Context, req *GetAssetsByTypeRequest, opts ...client.CallOption) (*GetAssetsByTypeResponse, error) {
|
|
||||||
resp := new(GetAssetsByTypeResponse)
|
|
||||||
if err := c.conn.CallUnary(ctx, []interface{}{req}, resp, "GetAssetsByType", opts...); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerAssetServiceGetAssetsByType() {
|
|
||||||
AssetService_ClientInfo.MethodNames = append(AssetService_ClientInfo.MethodNames, "GetAssetsByType")
|
|
||||||
AssetService_ServiceInfo.Methods = append(AssetService_ServiceInfo.Methods, server.MethodInfo{
|
|
||||||
Name: "GetAssetsByType",
|
|
||||||
Type: constant.CallUnary,
|
|
||||||
ReqInitFunc: func() interface{} {
|
|
||||||
return new(GetAssetsByTypeRequest)
|
|
||||||
},
|
|
||||||
MethodFunc: func(ctx context.Context, args []interface{}, handler interface{}) (interface{}, error) {
|
|
||||||
req := args[0].(*GetAssetsByTypeRequest)
|
|
||||||
res, err := handler.(AssetServiceGetAssetsByTypeHandler).GetAssetsByType(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return triple_protocol.NewResponse(res), nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var File_asset_proto protoreflect.FileDescriptor
|
var File_asset_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_asset_proto_rawDesc = "" +
|
const file_asset_proto_rawDesc = "" +
|
||||||
@ -4479,13 +4419,18 @@ const file_asset_proto_rawDesc = "" +
|
|||||||
"\x05extra\x18\a \x01(\v2\x17.google.protobuf.StructR\x05extra\"l\n" +
|
"\x05extra\x18\a \x01(\v2\x17.google.protobuf.StructR\x05extra\"l\n" +
|
||||||
"\x12TrackShareResponse\x120\n" +
|
"\x12TrackShareResponse\x120\n" +
|
||||||
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12$\n" +
|
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12$\n" +
|
||||||
"\x0eshare_event_id\x18\x02 \x01(\x03R\fshareEventId" +
|
"\x0eshare_event_id\x18\x02 \x01(\x03R\fshareEventId\"\xc1\x01\n" +
|
||||||
"\"\xc1\x01\x0a\x16GetAssetsByTypeRequest\x12\x17\x0a\x07user_id\x18\x01 \x01(\x03R\x06userId\x12\x17\x0a\x07star" +
|
"\x16GetAssetsByTypeRequest\x12\x17\n" +
|
||||||
"_id\x18\x02 \x01(\x03R\x06starId\x12\x12\x0a\x04type\x18\x03 \x01(\x09R\x04type\x12\x1a\x0a\x08category\x18\x04 \x01(\x09R\x08cat" +
|
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x17\n" +
|
||||||
"egory\x12\x14\x0a\x05grade\x18\x05 \x01(\x05R\x05grade\x12\x12\x0a\x04page\x18\x06 \x01(\x05R\x04page\x12\x1b\x0a\x09page_size" +
|
"\astar_id\x18\x02 \x01(\x03R\x06starId\x12\x12\n" +
|
||||||
"\x18\x07 \x01(\x05R\x08pageSize\"}\x0a\x17GetAssetsByTypeResponse\x120\x0a\x04base\x18\x01 \x01(\x0b2\x1c." +
|
"\x04type\x18\x03 \x01(\tR\x04type\x12\x1a\n" +
|
||||||
"topfans.common.BaseResponseR\x04base\x120\x0a\x04data\x18\x02 \x01(\x0b2\x1c.topfans.as" +
|
"\bcategory\x18\x04 \x01(\tR\bcategory\x12\x14\n" +
|
||||||
"set.AssetListDataR\x04data2\xf9\x13\n" +
|
"\x05grade\x18\x05 \x01(\x05R\x05grade\x12\x12\n" +
|
||||||
|
"\x04page\x18\x06 \x01(\x05R\x04page\x12\x1b\n" +
|
||||||
|
"\tpage_size\x18\a \x01(\x05R\bpageSize\"}\n" +
|
||||||
|
"\x17GetAssetsByTypeResponse\x120\n" +
|
||||||
|
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x120\n" +
|
||||||
|
"\x04data\x18\x02 \x01(\v2\x1c.topfans.asset.AssetListDataR\x04data2\xf9\x13\n" +
|
||||||
"\fAssetService\x12Z\n" +
|
"\fAssetService\x12Z\n" +
|
||||||
"\rInitMintOrder\x12#.topfans.asset.InitMintOrderRequest\x1a$.topfans.asset.InitMintOrderResponse\x12\x94\x01\n" +
|
"\rInitMintOrder\x12#.topfans.asset.InitMintOrderRequest\x1a$.topfans.asset.InitMintOrderResponse\x12\x94\x01\n" +
|
||||||
"\x12PreCreateMintOrder\x12(.topfans.asset.PreCreateMintOrderRequest\x1a).topfans.asset.PreCreateMintOrderResponse\")\x82\xd3\xe4\x93\x02#:\x01*\"\x1e/api/v1/assets/mints/precreate\x12\x81\x01\n" +
|
"\x12PreCreateMintOrder\x12(.topfans.asset.PreCreateMintOrderRequest\x1a).topfans.asset.PreCreateMintOrderResponse\")\x82\xd3\xe4\x93\x02#:\x01*\"\x1e/api/v1/assets/mints/precreate\x12\x81\x01\n" +
|
||||||
@ -4509,9 +4454,8 @@ const file_asset_proto_rawDesc = "" +
|
|||||||
"\x13UnbindAssetMaterial\x12).topfans.asset.UnbindAssetMaterialRequest\x1a*.topfans.asset.UnbindAssetMaterialResponse\x12]\n" +
|
"\x13UnbindAssetMaterial\x12).topfans.asset.UnbindAssetMaterialRequest\x1a*.topfans.asset.UnbindAssetMaterialResponse\x12]\n" +
|
||||||
"\x0eGetAssetQrcode\x12$.topfans.asset.GetAssetQrcodeRequest\x1a%.topfans.asset.GetAssetQrcodeResponse\x12Q\n" +
|
"\x0eGetAssetQrcode\x12$.topfans.asset.GetAssetQrcodeRequest\x1a%.topfans.asset.GetAssetQrcodeResponse\x12Q\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"TrackShare\x12 .topfans.asset.TrackShareRequest\x1a!.topfans.asset.TrackShareResponse" +
|
"TrackShare\x12 .topfans.asset.TrackShareRequest\x1a!.topfans.asset.TrackShareResponse\x12`\n" +
|
||||||
"\x12`\x0a\x0fGetAssetsByType\x12%.topfans.asset.GetAssetsByTypeRequest\x1a&" +
|
"\x0fGetAssetsByType\x12%.topfans.asset.GetAssetsByTypeRequest\x1a&.topfans.asset.GetAssetsByTypeResponseB2Z0github.com/topfans/backend/pkg/proto/asset;assetb\x06proto3"
|
||||||
".topfans.asset.GetAssetsByTypeResponseB2Z0github.com/topfans/backend/pkg/proto/asset;assetb\x06proto3"
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_asset_proto_rawDescOnce sync.Once
|
file_asset_proto_rawDescOnce sync.Once
|
||||||
@ -4685,10 +4629,7 @@ var file_asset_proto_depIdxs = []int32{
|
|||||||
0, // [0:43] is the sub-list for field type_name
|
0, // [0:43] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() { file_asset_proto_init() }
|
||||||
file_asset_proto_init()
|
|
||||||
registerAssetServiceGetAssetsByType()
|
|
||||||
}
|
|
||||||
func file_asset_proto_init() {
|
func file_asset_proto_init() {
|
||||||
if File_asset_proto != nil {
|
if File_asset_proto != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -80,6 +80,8 @@ const (
|
|||||||
AssetServiceGetAssetQrcodeProcedure = "/topfans.asset.AssetService/GetAssetQrcode"
|
AssetServiceGetAssetQrcodeProcedure = "/topfans.asset.AssetService/GetAssetQrcode"
|
||||||
// AssetServiceTrackShareProcedure is the fully-qualified name of the AssetService's TrackShare RPC.
|
// AssetServiceTrackShareProcedure is the fully-qualified name of the AssetService's TrackShare RPC.
|
||||||
AssetServiceTrackShareProcedure = "/topfans.asset.AssetService/TrackShare"
|
AssetServiceTrackShareProcedure = "/topfans.asset.AssetService/TrackShare"
|
||||||
|
// AssetServiceGetAssetsByTypeProcedure is the fully-qualified name of the AssetService's GetAssetsByType RPC.
|
||||||
|
AssetServiceGetAssetsByTypeProcedure = "/topfans.asset.AssetService/GetAssetsByType"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -110,6 +112,7 @@ type AssetService interface {
|
|||||||
UnbindAssetMaterial(ctx context.Context, req *UnbindAssetMaterialRequest, opts ...client.CallOption) (*UnbindAssetMaterialResponse, error)
|
UnbindAssetMaterial(ctx context.Context, req *UnbindAssetMaterialRequest, opts ...client.CallOption) (*UnbindAssetMaterialResponse, error)
|
||||||
GetAssetQrcode(ctx context.Context, req *GetAssetQrcodeRequest, opts ...client.CallOption) (*GetAssetQrcodeResponse, error)
|
GetAssetQrcode(ctx context.Context, req *GetAssetQrcodeRequest, opts ...client.CallOption) (*GetAssetQrcodeResponse, error)
|
||||||
TrackShare(ctx context.Context, req *TrackShareRequest, opts ...client.CallOption) (*TrackShareResponse, error)
|
TrackShare(ctx context.Context, req *TrackShareRequest, opts ...client.CallOption) (*TrackShareResponse, error)
|
||||||
|
GetAssetsByType(ctx context.Context, req *GetAssetsByTypeRequest, opts ...client.CallOption) (*GetAssetsByTypeResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAssetService constructs a client for the asset.AssetService service.
|
// NewAssetService constructs a client for the asset.AssetService service.
|
||||||
@ -308,9 +311,17 @@ func (c *AssetServiceImpl) TrackShare(ctx context.Context, req *TrackShareReques
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *AssetServiceImpl) GetAssetsByType(ctx context.Context, req *GetAssetsByTypeRequest, opts ...client.CallOption) (*GetAssetsByTypeResponse, error) {
|
||||||
|
resp := new(GetAssetsByTypeResponse)
|
||||||
|
if err := c.conn.CallUnary(ctx, []interface{}{req}, resp, "GetAssetsByType", opts...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
var AssetService_ClientInfo = client.ClientInfo{
|
var AssetService_ClientInfo = client.ClientInfo{
|
||||||
InterfaceName: "topfans.asset.AssetService",
|
InterfaceName: "topfans.asset.AssetService",
|
||||||
MethodNames: []string{"InitMintOrder", "PreCreateMintOrder", "CreateMintOrder", "EstimateMintCost", "GetMyAssets", "GetAsset", "GetAssetStatus", "GetMintOrder", "CancelMintOrder", "GetAssetForRPC", "LikeAsset", "UnlikeAsset", "CheckAssetLike", "GetAssetLikes", "ClearAssetLikeRecords", "UploadMaterial", "BindAssetMaterials", "GetAssetMaterials", "UpdateMaterialLayerOrder", "UnbindAssetMaterial", "GetAssetQrcode", "TrackShare"},
|
MethodNames: []string{"InitMintOrder", "PreCreateMintOrder", "CreateMintOrder", "EstimateMintCost", "GetMyAssets", "GetAsset", "GetAssetStatus", "GetMintOrder", "CancelMintOrder", "GetAssetForRPC", "LikeAsset", "UnlikeAsset", "CheckAssetLike", "GetAssetLikes", "ClearAssetLikeRecords", "UploadMaterial", "BindAssetMaterials", "GetAssetMaterials", "UpdateMaterialLayerOrder", "UnbindAssetMaterial", "GetAssetQrcode", "TrackShare", "GetAssetsByType"},
|
||||||
ConnectionInjectFunc: func(dubboCliRaw interface{}, conn *client.Connection) {
|
ConnectionInjectFunc: func(dubboCliRaw interface{}, conn *client.Connection) {
|
||||||
dubboCli := dubboCliRaw.(*AssetServiceImpl)
|
dubboCli := dubboCliRaw.(*AssetServiceImpl)
|
||||||
dubboCli.conn = conn
|
dubboCli.conn = conn
|
||||||
@ -341,6 +352,7 @@ type AssetServiceHandler interface {
|
|||||||
UnbindAssetMaterial(context.Context, *UnbindAssetMaterialRequest) (*UnbindAssetMaterialResponse, error)
|
UnbindAssetMaterial(context.Context, *UnbindAssetMaterialRequest) (*UnbindAssetMaterialResponse, error)
|
||||||
GetAssetQrcode(context.Context, *GetAssetQrcodeRequest) (*GetAssetQrcodeResponse, error)
|
GetAssetQrcode(context.Context, *GetAssetQrcodeRequest) (*GetAssetQrcodeResponse, error)
|
||||||
TrackShare(context.Context, *TrackShareRequest) (*TrackShareResponse, error)
|
TrackShare(context.Context, *TrackShareRequest) (*TrackShareResponse, error)
|
||||||
|
GetAssetsByType(context.Context, *GetAssetsByTypeRequest) (*GetAssetsByTypeResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterAssetServiceHandler(srv *server.Server, hdlr AssetServiceHandler, opts ...server.ServiceOption) error {
|
func RegisterAssetServiceHandler(srv *server.Server, hdlr AssetServiceHandler, opts ...server.ServiceOption) error {
|
||||||
@ -685,5 +697,20 @@ var AssetService_ServiceInfo = server.ServiceInfo{
|
|||||||
return triple_protocol.NewResponse(res), nil
|
return triple_protocol.NewResponse(res), nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "GetAssetsByType",
|
||||||
|
Type: constant.CallUnary,
|
||||||
|
ReqInitFunc: func() interface{} {
|
||||||
|
return new(GetAssetsByTypeRequest)
|
||||||
|
},
|
||||||
|
MethodFunc: func(ctx context.Context, args []interface{}, handler interface{}) (interface{}, error) {
|
||||||
|
req := args[0].(*GetAssetsByTypeRequest)
|
||||||
|
res, err := handler.(AssetServiceHandler).GetAssetsByType(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return triple_protocol.NewResponse(res), nil
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -532,6 +532,9 @@ service AssetService {
|
|||||||
rpc GetAssetQrcode(GetAssetQrcodeRequest) returns (GetAssetQrcodeResponse);
|
rpc GetAssetQrcode(GetAssetQrcodeRequest) returns (GetAssetQrcodeResponse);
|
||||||
// 记录分享归因事件(spec § 3.5)
|
// 记录分享归因事件(spec § 3.5)
|
||||||
rpc TrackShare(TrackShareRequest) returns (TrackShareResponse);
|
rpc TrackShare(TrackShareRequest) returns (TrackShareResponse);
|
||||||
|
|
||||||
|
// 按类型/分类/等级过滤查询星册藏品(内部 RPC,由 gateway 暴露 REST 接口)
|
||||||
|
rpc GetAssetsByType(GetAssetsByTypeRequest) returns (GetAssetsByTypeResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除资产点赞记录请求(内部RPC,供Gallery Service调用)
|
// 清除资产点赞记录请求(内部RPC,供Gallery Service调用)
|
||||||
@ -579,3 +582,21 @@ message TrackShareResponse {
|
|||||||
topfans.common.BaseResponse base = 1;
|
topfans.common.BaseResponse base = 1;
|
||||||
int64 share_event_id = 2; // 后端落库的分享事件 ID
|
int64 share_event_id = 2; // 后端落库的分享事件 ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 按类型/分类/等级过滤查询星册藏品请求
|
||||||
|
// user_id/star_id 仅为兼容既有 wire contract;服务端身份必须从可信 Dubbo attachments 读取。
|
||||||
|
message GetAssetsByTypeRequest {
|
||||||
|
int64 user_id = 1;
|
||||||
|
int64 star_id = 2;
|
||||||
|
string type = 3;
|
||||||
|
string category = 4;
|
||||||
|
int32 grade = 5;
|
||||||
|
int32 page = 6;
|
||||||
|
int32 page_size = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按类型/分类/等级过滤查询星册藏品响应
|
||||||
|
message GetAssetsByTypeResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
AssetListData data = 2;
|
||||||
|
}
|
||||||
|
|||||||
@ -6,10 +6,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dubbo.apache.org/dubbo-go/v3/common/constant"
|
"dubbo.apache.org/dubbo-go/v3/common/constant"
|
||||||
|
"github.com/topfans/backend/pkg/authctx"
|
||||||
appErrors "github.com/topfans/backend/pkg/errors"
|
appErrors "github.com/topfans/backend/pkg/errors"
|
||||||
"github.com/topfans/backend/pkg/logger"
|
"github.com/topfans/backend/pkg/logger"
|
||||||
"github.com/topfans/backend/pkg/models"
|
"github.com/topfans/backend/pkg/models"
|
||||||
"github.com/topfans/backend/pkg/authctx"
|
|
||||||
pb "github.com/topfans/backend/pkg/proto/asset"
|
pb "github.com/topfans/backend/pkg/proto/asset"
|
||||||
pbCommon "github.com/topfans/backend/pkg/proto/common"
|
pbCommon "github.com/topfans/backend/pkg/proto/common"
|
||||||
"github.com/topfans/backend/services/assetService/service"
|
"github.com/topfans/backend/services/assetService/service"
|
||||||
@ -39,16 +39,9 @@ type AssetProvider struct {
|
|||||||
materialService *service.MaterialService
|
materialService *service.MaterialService
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保 AssetProvider 实现了 AssetServiceHandler 接口
|
// 确保 AssetProvider 实现了 AssetServiceHandler 接口(包含 GetAssetsByType)
|
||||||
var _ pb.AssetServiceHandler = (*AssetProvider)(nil)
|
var _ pb.AssetServiceHandler = (*AssetProvider)(nil)
|
||||||
|
|
||||||
// 确保 AssetProvider 同时满足扩展 handler(Task 1 在 asset.pb.go 里通过 init()
|
|
||||||
// 把 GetAssetsByType 追加进了共享的 AssetService_ServiceInfo.Methods,
|
|
||||||
// 其 dispatch 会把注册的 handler 断言为 AssetServiceGetAssetsByTypeHandler。
|
|
||||||
// 因此 main.go 无需改动:现有 RegisterAssetServiceHandler(srv, assetProvider)
|
|
||||||
// 已经把本方法一并路由,只要 *AssetProvider 实现了它)。
|
|
||||||
var _ pb.AssetServiceGetAssetsByTypeHandler = (*AssetProvider)(nil)
|
|
||||||
|
|
||||||
// NewAssetProvider 创建资产服务Provider实例
|
// NewAssetProvider 创建资产服务Provider实例
|
||||||
func NewAssetProvider(assetService service.AssetService, mintService service.MintService, assetLikeService *service.AssetLikeService, materialService *service.MaterialService) *AssetProvider {
|
func NewAssetProvider(assetService service.AssetService, mintService service.MintService, assetLikeService *service.AssetLikeService, materialService *service.MaterialService) *AssetProvider {
|
||||||
return &AssetProvider{
|
return &AssetProvider{
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package provider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
@ -145,6 +146,75 @@ func sbSeed(t *testing.T, db *gorm.DB) {
|
|||||||
t.Cleanup(func() { sbCleanup(t, db) })
|
t.Cleanup(func() { sbCleanup(t, db) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sbAddRankedCollectionAssets 在 sbSeed 基础上追加一组带 like_count 梯度的
|
||||||
|
// 典藏藏品(手办分类),用于断言 home(top-3)与 More(全部)的 LikeCount 排序契约。
|
||||||
|
// - collection_x(sbSeed 写入) : like_count = 5
|
||||||
|
// - collection_ranked_0/1/2(追加): like_count = 20/40/30
|
||||||
|
//
|
||||||
|
// 预期 home(limit=3): [40, 30, 20];预期 More(limit=0): [40, 30, 20, 5]。
|
||||||
|
// 注意:Asset 记录不显式指定 id,交由 PostgreSQL autoIncrement 分配,避免触 CLAUDE.md
|
||||||
|
// 序列同步规则;新的 AssetRegistry 记录只引用 autoIncrement 出来的 AssetID。
|
||||||
|
func sbAddRankedCollectionAssets(t *testing.T, db *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var existing models.Asset
|
||||||
|
require.NoError(t, db.Where(
|
||||||
|
"owner_uid = ? AND star_id = ? AND name = ?",
|
||||||
|
sbTestOwnerUID, sbTestStarID, "collection_x",
|
||||||
|
).First(&existing).Error)
|
||||||
|
require.NoError(t, db.Model(&existing).Update("like_count", int32(5)).Error)
|
||||||
|
require.NoError(t, db.Model(&models.AssetRegistry{}).
|
||||||
|
Where("asset_id = ?", existing.ID).
|
||||||
|
Update("like_count", int32(999)).Error)
|
||||||
|
|
||||||
|
category := "手办"
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
for index, likeCount := range []int32{20, 40, 30} {
|
||||||
|
asset := &models.Asset{
|
||||||
|
OwnerUID: sbTestOwnerUID,
|
||||||
|
StarID: sbTestStarID,
|
||||||
|
Name: fmt.Sprintf("collection_ranked_%d", index),
|
||||||
|
CoverURL: fmt.Sprintf("https://cdn/collection_ranked_%d.png", index),
|
||||||
|
Status: 1,
|
||||||
|
IsActive: true,
|
||||||
|
LikeCount: likeCount,
|
||||||
|
CreatedAt: now + int64(index+1),
|
||||||
|
UpdatedAt: now + int64(index+1),
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Create(asset).Error)
|
||||||
|
require.NoError(t, db.Create(&models.AssetRegistry{
|
||||||
|
AssetID: asset.ID,
|
||||||
|
AssetType: models.AssetTypeCollection,
|
||||||
|
OwnerUID: sbTestOwnerUID,
|
||||||
|
StarID: sbTestStarID,
|
||||||
|
CollectionCategory: &category,
|
||||||
|
LikeCount: 1000 - likeCount,
|
||||||
|
DisplayStatus: 1,
|
||||||
|
}).Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireGroupByType 在 AssetListData.Groups 中按 type 查找目标分组,找不到直接 FailNow。
|
||||||
|
func requireGroupByType(t *testing.T, groups []*pb.AssetGroup, assetType string) *pb.AssetGroup {
|
||||||
|
t.Helper()
|
||||||
|
for _, group := range groups {
|
||||||
|
if group.Type == assetType {
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("group type %q not found", assetType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemLikeCounts 提取分组内 AssetItem 的 LikeCount 序列,便于断言排序契约。
|
||||||
|
func itemLikeCounts(items []*pb.AssetItem) []int32 {
|
||||||
|
counts := make([]int32, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
counts = append(counts, item.LikeCount)
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
// sbProvider 组装真实 service(真实 repo + test DB),无关依赖传 nil。
|
// sbProvider 组装真实 service(真实 repo + test DB),无关依赖传 nil。
|
||||||
func sbProvider(db *gorm.DB) *AssetProvider {
|
func sbProvider(db *gorm.DB) *AssetProvider {
|
||||||
assetRepo := repository.NewAssetRepository(db)
|
assetRepo := repository.NewAssetRepository(db)
|
||||||
@ -230,3 +300,34 @@ func TestGetAssetsByType_NoIdentity(t *testing.T) {
|
|||||||
assert.Equal(t, uint32(codes.Unauthenticated), resp.Base.Code)
|
assert.Equal(t, uint32(codes.Unauthenticated), resp.Base.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStarbookHomePreviewAndMoreUseAssetLikeRanking 回归 home(limit=3) 与 More(limit=0)
|
||||||
|
// 的排序与截断契约:两个入口都应按 Asset.LikeCount DESC, Asset.ID ASC 排序,
|
||||||
|
// home 只返 top-3(HasMore=true),More 返回全部 4 条(HasMore=false)。
|
||||||
|
// 排序稳定性通过 like_count 全相同时的 ID 次序隐式覆盖。
|
||||||
|
func TestStarbookHomePreviewAndMoreUseAssetLikeRanking(t *testing.T) {
|
||||||
|
db := starbookTestDB(t)
|
||||||
|
sbSeed(t, db)
|
||||||
|
sbAddRankedCollectionAssets(t, db)
|
||||||
|
provider := sbProvider(db)
|
||||||
|
ctx := sbCtx(sbTestOwnerUID, sbTestStarID)
|
||||||
|
|
||||||
|
homeResp, err := provider.GetMyAssets(ctx, &pb.GetMyAssetsRequest{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, homeResp.Data)
|
||||||
|
homeGroup := requireGroupByType(t, homeResp.Data.Groups, models.AssetTypeCollection)
|
||||||
|
assert.Equal(t, []int32{40, 30, 20}, itemLikeCounts(homeGroup.Items))
|
||||||
|
assert.Equal(t, int32(4), homeGroup.TotalCount)
|
||||||
|
assert.True(t, homeGroup.HasMore)
|
||||||
|
|
||||||
|
moreResp, err := provider.GetAssetsByType(ctx, &pb.GetAssetsByTypeRequest{
|
||||||
|
Type: models.AssetTypeCollection,
|
||||||
|
Category: "手办",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, moreResp.Data)
|
||||||
|
moreGroup := requireGroupByType(t, moreResp.Data.Groups, models.AssetTypeCollection)
|
||||||
|
assert.Equal(t, []int32{40, 30, 20, 5}, itemLikeCounts(moreGroup.Items))
|
||||||
|
assert.Equal(t, int32(4), moreGroup.TotalCount)
|
||||||
|
assert.False(t, moreGroup.HasMore)
|
||||||
|
}
|
||||||
|
|||||||
@ -16,10 +16,13 @@ import (
|
|||||||
"github.com/topfans/backend/services/assetService/client"
|
"github.com/topfans/backend/services/assetService/client"
|
||||||
"github.com/topfans/backend/services/assetService/repository"
|
"github.com/topfans/backend/services/assetService/repository"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// homePreviewLimit 首页每个等级分组的预览上限
|
||||||
|
const homePreviewLimit = 3
|
||||||
|
|
||||||
// AssetService 资产服务接口
|
// AssetService 资产服务接口
|
||||||
type AssetService interface {
|
type AssetService interface {
|
||||||
// GetMyAssets 获取我的藏品列表
|
// GetMyAssets 获取我的藏品列表
|
||||||
@ -161,7 +164,7 @@ func (s *assetService) GetMyAssets(req *pb.GetMyAssetsRequest, userID, starID in
|
|||||||
|
|
||||||
// 处理原创藏品 (regular)
|
// 处理原创藏品 (regular)
|
||||||
if regs, ok := typeGroups[models.AssetTypeRegular]; ok {
|
if regs, ok := typeGroups[models.AssetTypeRegular]; ok {
|
||||||
group := s.buildRegularGroupForAssets(allAssets, registryMap, regs)
|
group := s.buildRegularGroupForAssets(allAssets, registryMap, regs, homePreviewLimit)
|
||||||
if group != nil {
|
if group != nil {
|
||||||
groups = append(groups, group)
|
groups = append(groups, group)
|
||||||
}
|
}
|
||||||
@ -169,7 +172,7 @@ func (s *assetService) GetMyAssets(req *pb.GetMyAssetsRequest, userID, starID in
|
|||||||
|
|
||||||
// 处理典藏藏品 (collection)
|
// 处理典藏藏品 (collection)
|
||||||
if regs, ok := typeGroups[models.AssetTypeCollection]; ok {
|
if regs, ok := typeGroups[models.AssetTypeCollection]; ok {
|
||||||
group := s.buildCollectionGroupForAssets(allAssets, registryMap, regs)
|
group := s.buildCollectionGroupForAssets(allAssets, registryMap, regs, homePreviewLimit)
|
||||||
if group != nil {
|
if group != nil {
|
||||||
groups = append(groups, group)
|
groups = append(groups, group)
|
||||||
}
|
}
|
||||||
@ -177,7 +180,7 @@ func (s *assetService) GetMyAssets(req *pb.GetMyAssetsRequest, userID, starID in
|
|||||||
|
|
||||||
// 处理活动藏品 (activity)
|
// 处理活动藏品 (activity)
|
||||||
if regs, ok := typeGroups[models.AssetTypeActivity]; ok {
|
if regs, ok := typeGroups[models.AssetTypeActivity]; ok {
|
||||||
group := s.buildActivityGroupForAssets(allAssets, registryMap, regs)
|
group := s.buildActivityGroupForAssets(allAssets, registryMap, regs, homePreviewLimit)
|
||||||
if group != nil {
|
if group != nil {
|
||||||
groups = append(groups, group)
|
groups = append(groups, group)
|
||||||
}
|
}
|
||||||
@ -228,9 +231,9 @@ func (s *assetService) GetMyAssets(req *pb.GetMyAssetsRequest, userID, starID in
|
|||||||
// - req.Type 非空时只保留该类型(regular/collection/activity)
|
// - req.Type 非空时只保留该类型(regular/collection/activity)
|
||||||
// - req.Grade >0 时按 registry.grade 过滤(仅 regular 类型有效,其它类型 grade 恒为 nil 会被过滤空)
|
// - req.Grade >0 时按 registry.grade 过滤(仅 regular 类型有效,其它类型 grade 恒为 nil 会被过滤空)
|
||||||
// - req.Category 非空时按逻辑分类过滤:
|
// - req.Category 非空时按逻辑分类过滤:
|
||||||
// * regular -> 恒为 "castlove"
|
// - regular -> 恒为 "castlove"
|
||||||
// * collection -> registry.collection_category
|
// - collection -> registry.collection_category
|
||||||
// * activity -> registry.activity_type
|
// - activity -> registry.activity_type
|
||||||
//
|
//
|
||||||
// 返回结构与 GetMyAssets 一致(AssetListData 分组),便于 gateway 复用旧 starbook 契约。
|
// 返回结构与 GetMyAssets 一致(AssetListData 分组),便于 gateway 复用旧 starbook 契约。
|
||||||
func (s *assetService) GetAssetsByType(req *pb.GetAssetsByTypeRequest, userID, starID int64) (*pb.GetAssetsByTypeResponse, error) {
|
func (s *assetService) GetAssetsByType(req *pb.GetAssetsByTypeRequest, userID, starID int64) (*pb.GetAssetsByTypeResponse, error) {
|
||||||
@ -309,17 +312,17 @@ func (s *assetService) GetAssetsByType(req *pb.GetAssetsByTypeRequest, userID, s
|
|||||||
|
|
||||||
groups := make([]*pb.AssetGroup, 0)
|
groups := make([]*pb.AssetGroup, 0)
|
||||||
if regs, ok := typeGroups[models.AssetTypeRegular]; ok {
|
if regs, ok := typeGroups[models.AssetTypeRegular]; ok {
|
||||||
if group := s.buildRegularGroupForAssets(allAssets, registryMap, regs); group != nil {
|
if group := s.buildRegularGroupForAssets(allAssets, registryMap, regs, 0); group != nil {
|
||||||
groups = append(groups, group)
|
groups = append(groups, group)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if regs, ok := typeGroups[models.AssetTypeCollection]; ok {
|
if regs, ok := typeGroups[models.AssetTypeCollection]; ok {
|
||||||
if group := s.buildCollectionGroupForAssets(allAssets, registryMap, regs); group != nil {
|
if group := s.buildCollectionGroupForAssets(allAssets, registryMap, regs, 0); group != nil {
|
||||||
groups = append(groups, group)
|
groups = append(groups, group)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if regs, ok := typeGroups[models.AssetTypeActivity]; ok {
|
if regs, ok := typeGroups[models.AssetTypeActivity]; ok {
|
||||||
if group := s.buildActivityGroupForAssets(allAssets, registryMap, regs); group != nil {
|
if group := s.buildActivityGroupForAssets(allAssets, registryMap, regs, 0); group != nil {
|
||||||
groups = append(groups, group)
|
groups = append(groups, group)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -368,15 +371,29 @@ func registryLogicalCategory(reg *models.AssetRegistry) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortAndLimitAssetItems applies the starbook ordering contract and optionally
|
||||||
|
// truncates a home-page preview. previewLimit <= 0 means unlimited.
|
||||||
|
func sortAndLimitAssetItems(items []*pb.AssetItem, previewLimit int) ([]*pb.AssetItem, bool) {
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
if items[i].LikeCount != items[j].LikeCount {
|
||||||
|
return items[i].LikeCount > items[j].LikeCount
|
||||||
|
}
|
||||||
|
return items[i].AssetId < items[j].AssetId
|
||||||
|
})
|
||||||
|
|
||||||
|
if previewLimit > 0 && len(items) > previewLimit {
|
||||||
|
return items[:previewLimit], true
|
||||||
|
}
|
||||||
|
return items, false
|
||||||
|
}
|
||||||
|
|
||||||
// buildRegularGroupForAssets 构建原创藏品分组
|
// buildRegularGroupForAssets 构建原创藏品分组
|
||||||
func (s *assetService) buildRegularGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry) *pb.AssetGroup {
|
func (s *assetService) buildRegularGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry, previewLimit int) *pb.AssetGroup {
|
||||||
// 创建 assetID -> asset 映射
|
|
||||||
assetMap := make(map[int64]*models.Asset)
|
assetMap := make(map[int64]*models.Asset)
|
||||||
for _, asset := range allAssets {
|
for _, asset := range allAssets {
|
||||||
assetMap[asset.ID] = asset
|
assetMap[asset.ID] = asset
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按 grade 分组
|
|
||||||
gradeGroups := make(map[int32][]*models.AssetRegistry)
|
gradeGroups := make(map[int32][]*models.AssetRegistry)
|
||||||
for _, reg := range registries {
|
for _, reg := range registries {
|
||||||
if reg.Grade != nil {
|
if reg.Grade != nil {
|
||||||
@ -384,17 +401,15 @@ func (s *assetService) buildRegularGroupForAssets(allAssets []*models.Asset, reg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建 GradeSection
|
|
||||||
grades := make([]*pb.GradeSection, 0)
|
grades := make([]*pb.GradeSection, 0)
|
||||||
for grade, regs := range gradeGroups {
|
for grade, regs := range gradeGroups {
|
||||||
// 构建 items(返回所有,不限制数量)
|
items := make([]*pb.AssetItem, 0, len(regs))
|
||||||
items := make([]*pb.AssetItem, 0)
|
|
||||||
for _, reg := range regs {
|
for _, reg := range regs {
|
||||||
asset := assetMap[reg.AssetID]
|
asset := assetMap[reg.AssetID]
|
||||||
if asset == nil {
|
if asset == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
item := &pb.AssetItem{
|
items = append(items, &pb.AssetItem{
|
||||||
AssetId: asset.ID,
|
AssetId: asset.ID,
|
||||||
Name: asset.Name,
|
Name: asset.Name,
|
||||||
CoverUrlSigned: asset.CoverURL,
|
CoverUrlSigned: asset.CoverURL,
|
||||||
@ -403,28 +418,28 @@ func (s *assetService) buildRegularGroupForAssets(allAssets []*models.Asset, reg
|
|||||||
Category: "castlove",
|
Category: "castlove",
|
||||||
Grade: grade,
|
Grade: grade,
|
||||||
DisplayStatus: reg.DisplayStatus,
|
DisplayStatus: reg.DisplayStatus,
|
||||||
}
|
})
|
||||||
items = append(items, item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gradeSection := &pb.GradeSection{
|
totalCount := int32(len(items))
|
||||||
|
items, hasMore := sortAndLimitAssetItems(items, previewLimit)
|
||||||
|
grades = append(grades, &pb.GradeSection{
|
||||||
Grade: grade,
|
Grade: grade,
|
||||||
Items: items,
|
Items: items,
|
||||||
TotalCount: int32(len(regs)),
|
TotalCount: totalCount,
|
||||||
HasMore: false, // 返回所有,不限制
|
HasMore: hasMore,
|
||||||
}
|
})
|
||||||
grades = append(grades, gradeSection)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按 grade 降序排序
|
|
||||||
sort.Slice(grades, func(i, j int) bool {
|
sort.Slice(grades, func(i, j int) bool {
|
||||||
return grades[i].Grade > grades[j].Grade
|
return grades[i].Grade > grades[j].Grade
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算 total_count
|
|
||||||
totalCount := int32(0)
|
totalCount := int32(0)
|
||||||
for _, g := range grades {
|
hasMore := false
|
||||||
totalCount += g.TotalCount
|
for _, grade := range grades {
|
||||||
|
totalCount += grade.TotalCount
|
||||||
|
hasMore = hasMore || grade.HasMore
|
||||||
}
|
}
|
||||||
|
|
||||||
return &pb.AssetGroup{
|
return &pb.AssetGroup{
|
||||||
@ -433,20 +448,18 @@ func (s *assetService) buildRegularGroupForAssets(allAssets []*models.Asset, reg
|
|||||||
CategoryName: "原创",
|
CategoryName: "原创",
|
||||||
Grades: grades,
|
Grades: grades,
|
||||||
TotalCount: totalCount,
|
TotalCount: totalCount,
|
||||||
HasMore: false,
|
HasMore: hasMore,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildCollectionGroupForAssets 构建典藏藏品分组
|
// buildCollectionGroupForAssets 构建典藏藏品分组
|
||||||
func (s *assetService) buildCollectionGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry) *pb.AssetGroup {
|
func (s *assetService) buildCollectionGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry, previewLimit int) *pb.AssetGroup {
|
||||||
// 创建 assetID -> asset 映射
|
|
||||||
assetMap := make(map[int64]*models.Asset)
|
assetMap := make(map[int64]*models.Asset)
|
||||||
for _, asset := range allAssets {
|
for _, asset := range allAssets {
|
||||||
assetMap[asset.ID] = asset
|
assetMap[asset.ID] = asset
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建 items
|
items := make([]*pb.AssetItem, 0, len(registries))
|
||||||
items := make([]*pb.AssetItem, 0)
|
|
||||||
for _, reg := range registries {
|
for _, reg := range registries {
|
||||||
asset := assetMap[reg.AssetID]
|
asset := assetMap[reg.AssetID]
|
||||||
if asset == nil {
|
if asset == nil {
|
||||||
@ -456,7 +469,7 @@ func (s *assetService) buildCollectionGroupForAssets(allAssets []*models.Asset,
|
|||||||
if reg.CollectionCategory != nil {
|
if reg.CollectionCategory != nil {
|
||||||
category = *reg.CollectionCategory
|
category = *reg.CollectionCategory
|
||||||
}
|
}
|
||||||
item := &pb.AssetItem{
|
items = append(items, &pb.AssetItem{
|
||||||
AssetId: asset.ID,
|
AssetId: asset.ID,
|
||||||
Name: asset.Name,
|
Name: asset.Name,
|
||||||
CoverUrlSigned: asset.CoverURL,
|
CoverUrlSigned: asset.CoverURL,
|
||||||
@ -465,30 +478,29 @@ func (s *assetService) buildCollectionGroupForAssets(allAssets []*models.Asset,
|
|||||||
Category: category,
|
Category: category,
|
||||||
Grade: 0,
|
Grade: 0,
|
||||||
DisplayStatus: reg.DisplayStatus,
|
DisplayStatus: reg.DisplayStatus,
|
||||||
}
|
})
|
||||||
items = append(items, item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalCount := int32(len(items))
|
||||||
|
items, hasMore := sortAndLimitAssetItems(items, previewLimit)
|
||||||
return &pb.AssetGroup{
|
return &pb.AssetGroup{
|
||||||
Type: models.AssetTypeCollection,
|
Type: models.AssetTypeCollection,
|
||||||
Category: "",
|
Category: "",
|
||||||
CategoryName: "典藏",
|
CategoryName: "典藏",
|
||||||
Items: items,
|
Items: items,
|
||||||
TotalCount: int32(len(registries)),
|
TotalCount: totalCount,
|
||||||
HasMore: false,
|
HasMore: hasMore,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildActivityGroupForAssets 构建活动藏品分组
|
// buildActivityGroupForAssets 构建活动藏品分组
|
||||||
func (s *assetService) buildActivityGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry) *pb.AssetGroup {
|
func (s *assetService) buildActivityGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry, previewLimit int) *pb.AssetGroup {
|
||||||
// 创建 assetID -> asset 映射
|
|
||||||
assetMap := make(map[int64]*models.Asset)
|
assetMap := make(map[int64]*models.Asset)
|
||||||
for _, asset := range allAssets {
|
for _, asset := range allAssets {
|
||||||
assetMap[asset.ID] = asset
|
assetMap[asset.ID] = asset
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建 items
|
items := make([]*pb.AssetItem, 0, len(registries))
|
||||||
items := make([]*pb.AssetItem, 0)
|
|
||||||
for _, reg := range registries {
|
for _, reg := range registries {
|
||||||
asset := assetMap[reg.AssetID]
|
asset := assetMap[reg.AssetID]
|
||||||
if asset == nil {
|
if asset == nil {
|
||||||
@ -498,7 +510,7 @@ func (s *assetService) buildActivityGroupForAssets(allAssets []*models.Asset, re
|
|||||||
if reg.ActivityType != nil {
|
if reg.ActivityType != nil {
|
||||||
activityType = *reg.ActivityType
|
activityType = *reg.ActivityType
|
||||||
}
|
}
|
||||||
item := &pb.AssetItem{
|
items = append(items, &pb.AssetItem{
|
||||||
AssetId: asset.ID,
|
AssetId: asset.ID,
|
||||||
Name: asset.Name,
|
Name: asset.Name,
|
||||||
CoverUrlSigned: asset.CoverURL,
|
CoverUrlSigned: asset.CoverURL,
|
||||||
@ -507,17 +519,18 @@ func (s *assetService) buildActivityGroupForAssets(allAssets []*models.Asset, re
|
|||||||
Category: activityType,
|
Category: activityType,
|
||||||
Grade: 0,
|
Grade: 0,
|
||||||
DisplayStatus: reg.DisplayStatus,
|
DisplayStatus: reg.DisplayStatus,
|
||||||
}
|
})
|
||||||
items = append(items, item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalCount := int32(len(items))
|
||||||
|
items, hasMore := sortAndLimitAssetItems(items, previewLimit)
|
||||||
return &pb.AssetGroup{
|
return &pb.AssetGroup{
|
||||||
Type: models.AssetTypeActivity,
|
Type: models.AssetTypeActivity,
|
||||||
Category: "",
|
Category: "",
|
||||||
CategoryName: "活动",
|
CategoryName: "活动",
|
||||||
Items: items,
|
Items: items,
|
||||||
TotalCount: int32(len(registries)),
|
TotalCount: totalCount,
|
||||||
HasMore: false,
|
HasMore: hasMore,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,110 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/topfans/backend/pkg/models"
|
||||||
|
pb "github.com/topfans/backend/pkg/proto/asset"
|
||||||
|
)
|
||||||
|
|
||||||
|
func assetIDs(items []*pb.AssetItem) []int64 {
|
||||||
|
ids := make([]int64, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
ids = append(ids, item.AssetId)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func rankedAssets() []*models.Asset {
|
||||||
|
return []*models.Asset{
|
||||||
|
{ID: 1, Name: "ten", LikeCount: 10},
|
||||||
|
{ID: 2, Name: "thirty-first", LikeCount: 30},
|
||||||
|
{ID: 3, Name: "twenty", LikeCount: 20},
|
||||||
|
{ID: 4, Name: "thirty-second", LikeCount: 30},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRegularGroupForAssets_HomePreviewUsesAssetLikes(t *testing.T) {
|
||||||
|
grade := int32(2)
|
||||||
|
registries := []*models.AssetRegistry{
|
||||||
|
{AssetID: 1, AssetType: models.AssetTypeRegular, Grade: &grade, LikeCount: 1000},
|
||||||
|
{AssetID: 2, AssetType: models.AssetTypeRegular, Grade: &grade, LikeCount: 1},
|
||||||
|
{AssetID: 3, AssetType: models.AssetTypeRegular, Grade: &grade, LikeCount: 900},
|
||||||
|
{AssetID: 4, AssetType: models.AssetTypeRegular, Grade: &grade, LikeCount: 2},
|
||||||
|
{AssetID: 999, AssetType: models.AssetTypeRegular, Grade: &grade, LikeCount: 9999},
|
||||||
|
}
|
||||||
|
|
||||||
|
group := (&assetService{}).buildRegularGroupForAssets(rankedAssets(), nil, registries, 3)
|
||||||
|
|
||||||
|
require.NotNil(t, group)
|
||||||
|
require.Len(t, group.Grades, 1)
|
||||||
|
section := group.Grades[0]
|
||||||
|
assert.Equal(t, []int64{2, 4, 3}, assetIDs(section.Items))
|
||||||
|
assert.Equal(t, int32(4), section.TotalCount, "dangling registry must not count")
|
||||||
|
assert.True(t, section.HasMore)
|
||||||
|
assert.Equal(t, int32(4), group.TotalCount)
|
||||||
|
assert.True(t, group.HasMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRegularGroupForAssets_UnlimitedReturnsAllSorted(t *testing.T) {
|
||||||
|
grade := int32(2)
|
||||||
|
registries := []*models.AssetRegistry{
|
||||||
|
{AssetID: 1, AssetType: models.AssetTypeRegular, Grade: &grade},
|
||||||
|
{AssetID: 2, AssetType: models.AssetTypeRegular, Grade: &grade},
|
||||||
|
{AssetID: 3, AssetType: models.AssetTypeRegular, Grade: &grade},
|
||||||
|
{AssetID: 4, AssetType: models.AssetTypeRegular, Grade: &grade},
|
||||||
|
}
|
||||||
|
|
||||||
|
group := (&assetService{}).buildRegularGroupForAssets(rankedAssets(), nil, registries, 0)
|
||||||
|
|
||||||
|
require.Len(t, group.Grades, 1)
|
||||||
|
assert.Equal(t, []int64{2, 4, 3, 1}, assetIDs(group.Grades[0].Items))
|
||||||
|
assert.False(t, group.Grades[0].HasMore)
|
||||||
|
assert.False(t, group.HasMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrString(value string) *string { return &value }
|
||||||
|
|
||||||
|
func TestBuildCollectionGroupForAssets_PreviewAndUnlimited(t *testing.T) {
|
||||||
|
registries := []*models.AssetRegistry{
|
||||||
|
{AssetID: 1, AssetType: models.AssetTypeCollection, CollectionCategory: ptrString("cards")},
|
||||||
|
{AssetID: 2, AssetType: models.AssetTypeCollection, CollectionCategory: ptrString("cards")},
|
||||||
|
{AssetID: 3, AssetType: models.AssetTypeCollection, CollectionCategory: ptrString("cards")},
|
||||||
|
{AssetID: 4, AssetType: models.AssetTypeCollection, CollectionCategory: ptrString("cards")},
|
||||||
|
{AssetID: 999, AssetType: models.AssetTypeCollection, CollectionCategory: ptrString("cards")},
|
||||||
|
}
|
||||||
|
svc := &assetService{}
|
||||||
|
|
||||||
|
preview := svc.buildCollectionGroupForAssets(rankedAssets(), nil, registries, 3)
|
||||||
|
assert.Equal(t, []int64{2, 4, 3}, assetIDs(preview.Items))
|
||||||
|
assert.Equal(t, int32(4), preview.TotalCount)
|
||||||
|
assert.True(t, preview.HasMore)
|
||||||
|
|
||||||
|
full := svc.buildCollectionGroupForAssets(rankedAssets(), nil, registries, 0)
|
||||||
|
assert.Equal(t, []int64{2, 4, 3, 1}, assetIDs(full.Items))
|
||||||
|
assert.Equal(t, int32(4), full.TotalCount)
|
||||||
|
assert.False(t, full.HasMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildActivityGroupForAssets_PreviewAndUnlimited(t *testing.T) {
|
||||||
|
registries := []*models.AssetRegistry{
|
||||||
|
{AssetID: 1, AssetType: models.AssetTypeActivity, ActivityType: ptrString("offline")},
|
||||||
|
{AssetID: 2, AssetType: models.AssetTypeActivity, ActivityType: ptrString("offline")},
|
||||||
|
{AssetID: 3, AssetType: models.AssetTypeActivity, ActivityType: ptrString("offline")},
|
||||||
|
{AssetID: 4, AssetType: models.AssetTypeActivity, ActivityType: ptrString("offline")},
|
||||||
|
{AssetID: 999, AssetType: models.AssetTypeActivity, ActivityType: ptrString("offline")},
|
||||||
|
}
|
||||||
|
svc := &assetService{}
|
||||||
|
|
||||||
|
preview := svc.buildActivityGroupForAssets(rankedAssets(), nil, registries, 3)
|
||||||
|
assert.Equal(t, []int64{2, 4, 3}, assetIDs(preview.Items))
|
||||||
|
assert.Equal(t, int32(4), preview.TotalCount)
|
||||||
|
assert.True(t, preview.HasMore)
|
||||||
|
|
||||||
|
full := svc.buildActivityGroupForAssets(rankedAssets(), nil, registries, 0)
|
||||||
|
assert.Equal(t, []int64{2, 4, 3, 1}, assetIDs(full.Items))
|
||||||
|
assert.Equal(t, int32(4), full.TotalCount)
|
||||||
|
assert.False(t, full.HasMore)
|
||||||
|
}
|
||||||
@ -15,29 +15,70 @@
|
|||||||
|
|
||||||
<!-- 藏品网格 -->
|
<!-- 藏品网格 -->
|
||||||
<view v-else class="nft-grid-container">
|
<view v-else class="nft-grid-container">
|
||||||
<view
|
<!-- 原创藏品:按 grade 分组展示 -->
|
||||||
v-for="item in items"
|
<template v-if="isRegular">
|
||||||
:key="item.asset_id"
|
<view v-for="gradeSection in gradeSections" :key="gradeSection.grade" class="grade-block">
|
||||||
class="nft-grid-item"
|
<view class="grade-block-header">
|
||||||
@click="handleCardClick(item)"
|
<text class="grade-block-title">{{ formatGrade(gradeSection.grade) }}</text>
|
||||||
>
|
</view>
|
||||||
<image
|
<scroll-view
|
||||||
class="nft-cover"
|
scroll-y
|
||||||
:class="{ 'nft-image-displayed': item.display_status === 1 }"
|
class="image-scroll-area"
|
||||||
:src="item.coverUrl || item.cover_url_signed"
|
:show-scrollbar="false"
|
||||||
mode="aspectFill"
|
:enhanced="true"
|
||||||
/>
|
:bounces="true"
|
||||||
<view v-if="item.display_status === 1" class="status-overlay">
|
:scroll-animation-duration="300"
|
||||||
<text class="status-text-center">已展示</text>
|
:enable-flex="true"
|
||||||
|
>
|
||||||
|
<view class="nft-grid-row">
|
||||||
|
<view
|
||||||
|
v-for="item in gradeSection.items"
|
||||||
|
:key="item.asset_id"
|
||||||
|
class="nft-grid-item"
|
||||||
|
@click="handleCardClick(item)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="nft-cover"
|
||||||
|
:class="{ 'nft-image-displayed': item.display_status === 1 }"
|
||||||
|
:src="item.coverUrl || item.cover_url_signed"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view v-if="item.display_status === 1" class="status-overlay">
|
||||||
|
<text class="status-text-center">已展示</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-rate-badge-overlay">
|
||||||
|
<image class="heart-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
|
||||||
|
<text class="card-rate-text">{{ item.like_count || 0 }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
<view class="card-rate-badge-overlay">
|
</template>
|
||||||
<image class="heart-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
|
|
||||||
<text class="card-rate-text">{{ item.like_count || 0 }}</text>
|
<!-- 典藏/活动藏品:平铺展示 -->
|
||||||
|
<template v-else>
|
||||||
|
<view
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.asset_id"
|
||||||
|
class="nft-grid-item"
|
||||||
|
@click="handleCardClick(item)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="nft-cover"
|
||||||
|
:class="{ 'nft-image-displayed': item.display_status === 1 }"
|
||||||
|
:src="item.coverUrl || item.cover_url_signed"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view v-if="item.display_status === 1" class="status-overlay">
|
||||||
|
<text class="status-text-center">已展示</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-rate-badge-overlay">
|
||||||
|
<image class="heart-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
|
||||||
|
<text class="card-rate-text">{{ item.like_count || 0 }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- <view class="nft-info">
|
</template>
|
||||||
<text class="nft-name">{{ item.name }}</text>
|
|
||||||
</view> -->
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 分页提示 -->
|
<!-- 分页提示 -->
|
||||||
@ -82,6 +123,9 @@ const total = ref(0);
|
|||||||
// 藏品列表
|
// 藏品列表
|
||||||
const items = ref([]);
|
const items = ref([]);
|
||||||
|
|
||||||
|
// 原创等级分组(每个等级一段,用于原创分块展示)
|
||||||
|
const gradeSections = ref([]);
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
@ -108,12 +152,15 @@ const pageTitle = computed(() => {
|
|||||||
|
|
||||||
// 判断是否有数据
|
// 判断是否有数据
|
||||||
const hasData = computed(() => {
|
const hasData = computed(() => {
|
||||||
return items.value.length > 0;
|
return items.value.length > 0 || gradeSections.value.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 当前类型是否为原创
|
||||||
|
const isRegular = computed(() => type.value === 'regular');
|
||||||
|
|
||||||
// 判断是否有更多
|
// 判断是否有更多
|
||||||
const hasMore = computed(() => {
|
const hasMore = computed(() => {
|
||||||
return items.value.length < total.value;
|
return groupHasMore.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
// grade 中文转换
|
// grade 中文转换
|
||||||
@ -133,18 +180,44 @@ const loadData = async (append = false) => {
|
|||||||
page.value,
|
page.value,
|
||||||
pageSize.value
|
pageSize.value
|
||||||
);
|
);
|
||||||
if (response.code === 0 && response.data) {
|
if (response.code === 0 && response.data && response.data.data) {
|
||||||
const newItems = response.data.data.items || [];
|
const group = findGroupByType(response.data.data.groups, type.value);
|
||||||
// 转换封面 URL
|
if (group) {
|
||||||
for (const item of newItems) {
|
total.value = group.total_count || 0;
|
||||||
item.coverUrl = await getAssetCoverRealUrl(item.cover_url_signed || '');
|
groupHasMore.value = Boolean(group.has_more);
|
||||||
}
|
const flatItems = flattenGroupItems(group);
|
||||||
if (append) {
|
// 转换封面 URL
|
||||||
items.value = [...items.value, ...newItems];
|
for (const item of flatItems) {
|
||||||
|
item.coverUrl = await getAssetCoverRealUrl(item.cover_url_signed || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.value === 'regular') {
|
||||||
|
const sections = (group.grades || []).map((gradeSection) => ({
|
||||||
|
grade: gradeSection.grade,
|
||||||
|
items: (gradeSection.items || []).map(hydrateItem),
|
||||||
|
}));
|
||||||
|
if (append) {
|
||||||
|
gradeSections.value = [...gradeSections.value, ...sections];
|
||||||
|
} else {
|
||||||
|
gradeSections.value = sections;
|
||||||
|
}
|
||||||
|
items.value = gradeSections.value.flatMap((section) => section.items);
|
||||||
|
} else {
|
||||||
|
const newItems = flatItems.map(hydrateItem);
|
||||||
|
if (append) {
|
||||||
|
items.value = [...items.value, ...newItems];
|
||||||
|
} else {
|
||||||
|
items.value = newItems;
|
||||||
|
}
|
||||||
|
gradeSections.value = [];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
items.value = newItems;
|
if (!append) {
|
||||||
|
items.value = [];
|
||||||
|
gradeSections.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
total.value = response.data.total || 0;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取藏品列表失败:', error);
|
console.error('获取藏品列表失败:', error);
|
||||||
@ -158,6 +231,25 @@ const loadData = async (append = false) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// findGroupByType 取出与请求类型一致的分组(忽略其余 group,例如后续扩展)
|
||||||
|
function findGroupByType(groups, assetType) {
|
||||||
|
if (!Array.isArray(groups)) return null;
|
||||||
|
return groups.find((group) => group && group.type === assetType) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenGroupItems 将 group 内的所有 items 展平成单一列表(优先取 grades,兼容 flat items)
|
||||||
|
function flattenGroupItems(group) {
|
||||||
|
if (Array.isArray(group.grades) && group.grades.length > 0) {
|
||||||
|
return group.grades.flatMap((gradeSection) => gradeSection.items || []);
|
||||||
|
}
|
||||||
|
return Array.isArray(group.items) ? group.items : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// hydrateItem 给每个 item 补一个稳定 key 占位,便于模板 v-for 使用
|
||||||
|
function hydrateItem(item) {
|
||||||
|
return item || {};
|
||||||
|
}
|
||||||
|
|
||||||
// 加载更多
|
// 加载更多
|
||||||
const loadMore = () => {
|
const loadMore = () => {
|
||||||
if (!hasMore.value || loading.value) return;
|
if (!hasMore.value || loading.value) return;
|
||||||
@ -165,6 +257,9 @@ const loadMore = () => {
|
|||||||
loadData(true);
|
loadData(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 当前分组是否还有未返回的更多
|
||||||
|
const groupHasMore = ref(false);
|
||||||
|
|
||||||
// 点击卡片跳转到详情页
|
// 点击卡片跳转到详情页
|
||||||
const handleCardClick = (item) => {
|
const handleCardClick = (item) => {
|
||||||
if (item.asset_id) {
|
if (item.asset_id) {
|
||||||
@ -186,7 +281,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
type.value = options.type || 'regular';
|
type.value = options.type || 'regular';
|
||||||
category.value = options.category || 'castlove';
|
category.value = options.category || 'castlove';
|
||||||
grade.value = options.grade ? parseInt(options.grade) : null;
|
if (type.value === 'regular' && options.grade) {
|
||||||
|
grade.value = parseInt(options.grade);
|
||||||
|
} else {
|
||||||
|
grade.value = null;
|
||||||
|
}
|
||||||
page.value = parseInt(options.page) || 1;
|
page.value = parseInt(options.page) || 1;
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
@ -239,6 +338,37 @@ onMounted(() => {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 原创等级分块 */
|
||||||
|
.grade-block {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-block-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 0 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-block-title {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nft-grid-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片区滚动容器(不改变内部 3 列 grid 布局) */
|
||||||
|
.image-scroll-area {
|
||||||
|
max-height: calc(100vh - 320rpx);
|
||||||
|
touch-action: pan-y;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.nft-grid-item {
|
.nft-grid-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user