feat: 铸造菜单改为动态配置
This commit is contained in:
parent
dc9f1a7b1e
commit
dfb79af4b1
107
backend/gateway/controller/castlove_config_controller.go
Normal file
107
backend/gateway/controller/castlove_config_controller.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"dubbo.apache.org/dubbo-go/v3/client"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/topfans/backend/gateway/pkg/response"
|
||||||
|
pbCastlove "github.com/topfans/backend/pkg/proto/castlove"
|
||||||
|
"github.com/topfans/backend/pkg/logger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CastloveConfigController 铸爱工艺配置 控制器
|
||||||
|
// 端点:GET /api/v1/castlove/config
|
||||||
|
// 流程:JWT 鉴权 → 透传 Dubbo RPC → 透传 DB → sanitize → 5s 进程内缓存 → JSON 响应
|
||||||
|
type CastloveConfigController struct {
|
||||||
|
castloveConfigClient pbCastlove.CastloveConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCastloveConfigController 创建铸爱工艺配置 Controller 实例
|
||||||
|
// dubboClient:指向 assetService(端口 20003)的 Dubbo 客户端
|
||||||
|
func NewCastloveConfigController(dubboClient *client.Client) (*CastloveConfigController, error) {
|
||||||
|
cli, err := pbCastlove.NewCastloveConfigService(dubboClient)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &CastloveConfigController{
|
||||||
|
castloveConfigClient: cli,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCastloveConfig 获取铸爱工艺配置(全量分类 + 卡片)
|
||||||
|
// @Summary 获取铸爱工艺配置
|
||||||
|
// @Description 强制鉴权;返回全量分类(星卡/吧唧/海报 等)及其下卡片(含 route_path / route_params)
|
||||||
|
// @Tags castlove
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} response.Response
|
||||||
|
// @Router /api/v1/castlove/config [get]
|
||||||
|
func (ctrl *CastloveConfigController) GetCastloveConfig(c *gin.Context) {
|
||||||
|
logger.Logger.Info("GetCastloveConfig request")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := ctrl.castloveConfigClient.GetCastloveConfig(ctx, &pbCastlove.GetCastloveConfigRequest{})
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("GetCastloveConfig RPC failed", zap.Error(err))
|
||||||
|
response.Error(c, http.StatusInternalServerError, "配置加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Base == nil || resp.Base.GetCode() != 200 {
|
||||||
|
// proto Base 为 nil 视为异常,但仍尝试把 categories 透传出去,避免空响应
|
||||||
|
logger.Logger.Warn("GetCastloveConfig: unexpected base",
|
||||||
|
zap.Any("base", resp.Base),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// proto 数组转 []map 以便 gin 序列化为 JSON 数组而非 proto 字段名
|
||||||
|
categories := make([]map[string]interface{}, 0, len(resp.Categories))
|
||||||
|
for _, cat := range resp.Categories {
|
||||||
|
crafts := make([]map[string]interface{}, 0, len(cat.Crafts))
|
||||||
|
for _, cr := range cat.Crafts {
|
||||||
|
crafts = append(crafts, map[string]interface{}{
|
||||||
|
"id": cr.Id,
|
||||||
|
"name": cr.Name,
|
||||||
|
"image_url": cr.ImageUrl,
|
||||||
|
"route_path": nullableString(cr.RoutePath), // "" → nil,与 DB NULL 行为一致
|
||||||
|
"route_params": nullableJSONString(cr.RouteParams),
|
||||||
|
"sort_order": cr.SortOrder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
categories = append(categories, map[string]interface{}{
|
||||||
|
"id": cat.Id,
|
||||||
|
"name": cat.Name,
|
||||||
|
"type_key": nullableString(cat.TypeKey),
|
||||||
|
"sort_order": cat.SortOrder,
|
||||||
|
"crafts": crafts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, map[string]interface{}{
|
||||||
|
"categories": categories,
|
||||||
|
"version": resp.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullableString 空串转 nil(JSON 序列化时为 null,与 DB NULL 行为一致)
|
||||||
|
func nullableString(s string) interface{} {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullableJSONString 空串转 nil(route_params 同上)
|
||||||
|
func nullableJSONString(s string) interface{} {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@ -71,6 +71,11 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
castloveConfigCtrl, err := controller.NewCastloveConfigController(assetClient)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
activityCtrl, err := controller.NewActivityController(activityClient)
|
activityCtrl, err := controller.NewActivityController(activityClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -299,6 +304,15 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
|
|||||||
rankings.GET("/original", rankingCtrl.GetOriginalRanking) // 获取自制排行榜
|
rankings.GET("/original", rankingCtrl.GetOriginalRanking) // 获取自制排行榜
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 铸爱工艺配置(需要认证,强制 JWT)
|
||||||
|
// 流程:gateway → Dubbo RPC → assetService(端口 20003)→ 5s 进程内缓存 → DB
|
||||||
|
// 后台直连写库,本服务只读
|
||||||
|
castlove := v1.Group("/castlove")
|
||||||
|
castlove.Use(middleware.AuthMiddleware())
|
||||||
|
{
|
||||||
|
castlove.GET("/config", castloveConfigCtrl.GetCastloveConfig) // 获取分类+卡片配置
|
||||||
|
}
|
||||||
|
|
||||||
// 活动相关路由(需要认证)
|
// 活动相关路由(需要认证)
|
||||||
activities := v1.Group("/activities")
|
activities := v1.Group("/activities")
|
||||||
activities.Use(middleware.AuthMiddleware())
|
activities.Use(middleware.AuthMiddleware())
|
||||||
|
|||||||
64
backend/pkg/models/castlove_config.go
Normal file
64
backend/pkg/models/castlove_config.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CastloveCategory 铸爱工艺分类表模型
|
||||||
|
// 对应表 castlove_categories;后台直连写库,本服务只读
|
||||||
|
type CastloveCategory struct {
|
||||||
|
ID int64 `gorm:"primaryKey;autoIncrement;column:id"`
|
||||||
|
Name string `gorm:"type:varchar(64);not null;uniqueIndex;column:name"`
|
||||||
|
TypeKey *string `gorm:"type:varchar(32);column:type_key"` // 外部 deep-link key;可空(NULL 时唯一索引不命中)
|
||||||
|
SortOrder int32 `gorm:"not null;default:0;column:sort_order"`
|
||||||
|
IsActive bool `gorm:"not null;default:true;column:is_active"`
|
||||||
|
CreatedAt int64 `gorm:"not null;column:created_at"`
|
||||||
|
UpdatedAt int64 `gorm:"not null;column:updated_at"`
|
||||||
|
DeletedAt *int64 `gorm:"index;column:deleted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CastloveCategory) TableName() string { return "castlove_categories" }
|
||||||
|
|
||||||
|
func (c *CastloveCategory) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
c.CreatedAt = now
|
||||||
|
c.UpdatedAt = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CastloveCategory) BeforeUpdate(tx *gorm.DB) error {
|
||||||
|
c.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CastloveCraft 铸爱工艺卡片表模型
|
||||||
|
// 对应表 castlove_crafts;后台直连写库,本服务只读
|
||||||
|
type CastloveCraft struct {
|
||||||
|
ID int64 `gorm:"primaryKey;autoIncrement;column:id"`
|
||||||
|
CategoryID int64 `gorm:"not null;index:idx_castlove_crafts_cat_sort;column:category_id"`
|
||||||
|
Name string `gorm:"type:varchar(64);not null;column:name"`
|
||||||
|
ImageURL string `gorm:"type:text;not null;column:image_url"`
|
||||||
|
RoutePath *string `gorm:"type:varchar(255);column:route_path"` // 可空(NULL = 点击弹 toast,不跳转)
|
||||||
|
RouteParams *string `gorm:"type:jsonb;column:route_params"` // JSON 字符串,NULL 或合法 JSON 对象
|
||||||
|
SortOrder int32 `gorm:"not null;default:0;column:sort_order"`
|
||||||
|
IsActive bool `gorm:"not null;default:true;column:is_active"`
|
||||||
|
CreatedAt int64 `gorm:"not null;column:created_at"`
|
||||||
|
UpdatedAt int64 `gorm:"not null;column:updated_at"`
|
||||||
|
DeletedAt *int64 `gorm:"index;column:deleted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CastloveCraft) TableName() string { return "castlove_crafts" }
|
||||||
|
|
||||||
|
func (c *CastloveCraft) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
c.CreatedAt = now
|
||||||
|
c.UpdatedAt = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CastloveCraft) BeforeUpdate(tx *gorm.DB) error {
|
||||||
|
c.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
371
backend/pkg/proto/castlove/castlove_config.pb.go
Normal file
371
backend/pkg/proto/castlove/castlove_config.pb.go
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.36.11
|
||||||
|
// protoc v7.34.0
|
||||||
|
// source: castlove_config.proto
|
||||||
|
|
||||||
|
package castlove
|
||||||
|
|
||||||
|
import (
|
||||||
|
common "github.com/topfans/backend/pkg/proto/common"
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
unsafe "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 工艺卡片(精简版,只包含前端展示所需字段)
|
||||||
|
type CastloveCraftProto struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` // 卡片ID
|
||||||
|
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // 卡片名("镭射卡" / "光栅卡" / "开发中" / ...)
|
||||||
|
ImageUrl string `protobuf:"bytes,3,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"` // 完整 https URL(OSS 域名开头)
|
||||||
|
RoutePath string `protobuf:"bytes,4,opt,name=route_path,json=routePath,proto3" json:"route_path,omitempty"` // uni-app 页面路径(以 / 开头);空串表示"未配置路由"
|
||||||
|
RouteParams string `protobuf:"bytes,5,opt,name=route_params,json=routeParams,proto3" json:"route_params,omitempty"` // JSON 字符串(对象);空串表示无参数
|
||||||
|
SortOrder int32 `protobuf:"varint,6,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"` // 同一分类内的相对顺序
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCraftProto) Reset() {
|
||||||
|
*x = CastloveCraftProto{}
|
||||||
|
mi := &file_castlove_config_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCraftProto) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CastloveCraftProto) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *CastloveCraftProto) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_castlove_config_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use CastloveCraftProto.ProtoReflect.Descriptor instead.
|
||||||
|
func (*CastloveCraftProto) Descriptor() ([]byte, []int) {
|
||||||
|
return file_castlove_config_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCraftProto) GetId() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCraftProto) GetName() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCraftProto) GetImageUrl() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.ImageUrl
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCraftProto) GetRoutePath() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.RoutePath
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCraftProto) GetRouteParams() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.RouteParams
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCraftProto) GetSortOrder() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.SortOrder
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工艺分类(含其下卡片列表)
|
||||||
|
type CastloveCategoryProto struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` // 分类ID
|
||||||
|
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // 分类名("星卡" / "吧唧" / "海报")
|
||||||
|
TypeKey string `protobuf:"bytes,3,opt,name=type_key,json=typeKey,proto3" json:"type_key,omitempty"` // 外部 deep-link key;空串表示无
|
||||||
|
SortOrder int32 `protobuf:"varint,4,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"` // 分类相对顺序
|
||||||
|
Crafts []*CastloveCraftProto `protobuf:"bytes,5,rep,name=crafts,proto3" json:"crafts,omitempty"` // 该分类下的卡片
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCategoryProto) Reset() {
|
||||||
|
*x = CastloveCategoryProto{}
|
||||||
|
mi := &file_castlove_config_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCategoryProto) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CastloveCategoryProto) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *CastloveCategoryProto) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_castlove_config_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use CastloveCategoryProto.ProtoReflect.Descriptor instead.
|
||||||
|
func (*CastloveCategoryProto) Descriptor() ([]byte, []int) {
|
||||||
|
return file_castlove_config_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCategoryProto) GetId() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCategoryProto) GetName() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCategoryProto) GetTypeKey() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.TypeKey
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCategoryProto) GetSortOrder() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.SortOrder
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CastloveCategoryProto) GetCrafts() []*CastloveCraftProto {
|
||||||
|
if x != nil {
|
||||||
|
return x.Crafts
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取铸爱工艺配置请求(无入参,纯 GET)
|
||||||
|
type GetCastloveConfigRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetCastloveConfigRequest) Reset() {
|
||||||
|
*x = GetCastloveConfigRequest{}
|
||||||
|
mi := &file_castlove_config_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetCastloveConfigRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetCastloveConfigRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetCastloveConfigRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_castlove_config_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetCastloveConfigRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetCastloveConfigRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_castlove_config_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取铸爱工艺配置响应
|
||||||
|
type GetCastloveConfigResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"`
|
||||||
|
Categories []*CastloveCategoryProto `protobuf:"bytes,2,rep,name=categories,proto3" json:"categories,omitempty"` // 分类(含嵌套卡片)
|
||||||
|
Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` // 所有相关记录 updated_at 最大值(ISO 8601)
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetCastloveConfigResponse) Reset() {
|
||||||
|
*x = GetCastloveConfigResponse{}
|
||||||
|
mi := &file_castlove_config_proto_msgTypes[3]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetCastloveConfigResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetCastloveConfigResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetCastloveConfigResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_castlove_config_proto_msgTypes[3]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetCastloveConfigResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetCastloveConfigResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_castlove_config_proto_rawDescGZIP(), []int{3}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetCastloveConfigResponse) GetBase() *common.BaseResponse {
|
||||||
|
if x != nil {
|
||||||
|
return x.Base
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetCastloveConfigResponse) GetCategories() []*CastloveCategoryProto {
|
||||||
|
if x != nil {
|
||||||
|
return x.Categories
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetCastloveConfigResponse) GetVersion() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Version
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_castlove_config_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
const file_castlove_config_proto_rawDesc = "" +
|
||||||
|
"\n" +
|
||||||
|
"\x15castlove_config.proto\x12\x10topfans.castlove\x1a\x12proto/common.proto\"\xb6\x01\n" +
|
||||||
|
"\x12CastloveCraftProto\x12\x0e\n" +
|
||||||
|
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" +
|
||||||
|
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" +
|
||||||
|
"\timage_url\x18\x03 \x01(\tR\bimageUrl\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"route_path\x18\x04 \x01(\tR\troutePath\x12!\n" +
|
||||||
|
"\froute_params\x18\x05 \x01(\tR\vrouteParams\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"sort_order\x18\x06 \x01(\x05R\tsortOrder\"\xb3\x01\n" +
|
||||||
|
"\x15CastloveCategoryProto\x12\x0e\n" +
|
||||||
|
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" +
|
||||||
|
"\x04name\x18\x02 \x01(\tR\x04name\x12\x19\n" +
|
||||||
|
"\btype_key\x18\x03 \x01(\tR\atypeKey\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"sort_order\x18\x04 \x01(\x05R\tsortOrder\x12<\n" +
|
||||||
|
"\x06crafts\x18\x05 \x03(\v2$.topfans.castlove.CastloveCraftProtoR\x06crafts\"\x1a\n" +
|
||||||
|
"\x18GetCastloveConfigRequest\"\xb0\x01\n" +
|
||||||
|
"\x19GetCastloveConfigResponse\x120\n" +
|
||||||
|
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12G\n" +
|
||||||
|
"\n" +
|
||||||
|
"categories\x18\x02 \x03(\v2'.topfans.castlove.CastloveCategoryProtoR\n" +
|
||||||
|
"categories\x12\x18\n" +
|
||||||
|
"\aversion\x18\x03 \x01(\tR\aversion2\x85\x01\n" +
|
||||||
|
"\x15CastloveConfigService\x12l\n" +
|
||||||
|
"\x11GetCastloveConfig\x12*.topfans.castlove.GetCastloveConfigRequest\x1a+.topfans.castlove.GetCastloveConfigResponseB8Z6github.com/topfans/backend/pkg/proto/castlove;castloveb\x06proto3"
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_castlove_config_proto_rawDescOnce sync.Once
|
||||||
|
file_castlove_config_proto_rawDescData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_castlove_config_proto_rawDescGZIP() []byte {
|
||||||
|
file_castlove_config_proto_rawDescOnce.Do(func() {
|
||||||
|
file_castlove_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_castlove_config_proto_rawDesc), len(file_castlove_config_proto_rawDesc)))
|
||||||
|
})
|
||||||
|
return file_castlove_config_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_castlove_config_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
|
||||||
|
var file_castlove_config_proto_goTypes = []any{
|
||||||
|
(*CastloveCraftProto)(nil), // 0: topfans.castlove.CastloveCraftProto
|
||||||
|
(*CastloveCategoryProto)(nil), // 1: topfans.castlove.CastloveCategoryProto
|
||||||
|
(*GetCastloveConfigRequest)(nil), // 2: topfans.castlove.GetCastloveConfigRequest
|
||||||
|
(*GetCastloveConfigResponse)(nil), // 3: topfans.castlove.GetCastloveConfigResponse
|
||||||
|
(*common.BaseResponse)(nil), // 4: topfans.common.BaseResponse
|
||||||
|
}
|
||||||
|
var file_castlove_config_proto_depIdxs = []int32{
|
||||||
|
0, // 0: topfans.castlove.CastloveCategoryProto.crafts:type_name -> topfans.castlove.CastloveCraftProto
|
||||||
|
4, // 1: topfans.castlove.GetCastloveConfigResponse.base:type_name -> topfans.common.BaseResponse
|
||||||
|
1, // 2: topfans.castlove.GetCastloveConfigResponse.categories:type_name -> topfans.castlove.CastloveCategoryProto
|
||||||
|
2, // 3: topfans.castlove.CastloveConfigService.GetCastloveConfig:input_type -> topfans.castlove.GetCastloveConfigRequest
|
||||||
|
3, // 4: topfans.castlove.CastloveConfigService.GetCastloveConfig:output_type -> topfans.castlove.GetCastloveConfigResponse
|
||||||
|
4, // [4:5] is the sub-list for method output_type
|
||||||
|
3, // [3:4] is the sub-list for method input_type
|
||||||
|
3, // [3:3] is the sub-list for extension type_name
|
||||||
|
3, // [3:3] is the sub-list for extension extendee
|
||||||
|
0, // [0:3] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_castlove_config_proto_init() }
|
||||||
|
func file_castlove_config_proto_init() {
|
||||||
|
if File_castlove_config_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_castlove_config_proto_rawDesc), len(file_castlove_config_proto_rawDesc)),
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 4,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 1,
|
||||||
|
},
|
||||||
|
GoTypes: file_castlove_config_proto_goTypes,
|
||||||
|
DependencyIndexes: file_castlove_config_proto_depIdxs,
|
||||||
|
MessageInfos: file_castlove_config_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_castlove_config_proto = out.File
|
||||||
|
file_castlove_config_proto_goTypes = nil
|
||||||
|
file_castlove_config_proto_depIdxs = nil
|
||||||
|
}
|
||||||
122
backend/pkg/proto/castlove/castlove_config.triple.go
Normal file
122
backend/pkg/proto/castlove/castlove_config.triple.go
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
// Code generated by protoc-gen-triple. DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// Source: castlove_config.proto
|
||||||
|
package castlove
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"dubbo.apache.org/dubbo-go/v3"
|
||||||
|
"dubbo.apache.org/dubbo-go/v3/client"
|
||||||
|
"dubbo.apache.org/dubbo-go/v3/common"
|
||||||
|
"dubbo.apache.org/dubbo-go/v3/common/constant"
|
||||||
|
"dubbo.apache.org/dubbo-go/v3/protocol/triple/triple_protocol"
|
||||||
|
"dubbo.apache.org/dubbo-go/v3/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file and the Triple package
|
||||||
|
// are compatible. If you get a compiler error that this constant is not defined, this code was
|
||||||
|
// generated with a version of Triple newer than the one compiled into your binary. You can fix the
|
||||||
|
// problem by either regenerating this code with an older version of Triple or updating the Triple
|
||||||
|
// version compiled into your binary.
|
||||||
|
const _ = triple_protocol.IsAtLeastVersion0_1_0
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CastloveConfigServiceName is the fully-qualified name of the CastloveConfigService service.
|
||||||
|
CastloveConfigServiceName = "topfans.castlove.CastloveConfigService"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These constants are the fully-qualified names of the RPCs defined in this package. They're
|
||||||
|
// exposed at runtime as procedure and as the final two segments of the HTTP route.
|
||||||
|
//
|
||||||
|
// Note that these are different from the fully-qualified method names used by
|
||||||
|
// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
|
||||||
|
// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
|
||||||
|
// period.
|
||||||
|
const (
|
||||||
|
// CastloveConfigServiceGetCastloveConfigProcedure is the fully-qualified name of the CastloveConfigService's GetCastloveConfig RPC.
|
||||||
|
CastloveConfigServiceGetCastloveConfigProcedure = "/topfans.castlove.CastloveConfigService/GetCastloveConfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ CastloveConfigService = (*CastloveConfigServiceImpl)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// CastloveConfigService is a client for the topfans.castlove.CastloveConfigService service.
|
||||||
|
type CastloveConfigService interface {
|
||||||
|
GetCastloveConfig(ctx context.Context, req *GetCastloveConfigRequest, opts ...client.CallOption) (*GetCastloveConfigResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCastloveConfigService constructs a client for the castlove.CastloveConfigService service.
|
||||||
|
func NewCastloveConfigService(cli *client.Client, opts ...client.ReferenceOption) (CastloveConfigService, error) {
|
||||||
|
conn, err := cli.DialWithInfo("topfans.castlove.CastloveConfigService", &CastloveConfigService_ClientInfo, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &CastloveConfigServiceImpl{
|
||||||
|
conn: conn,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetConsumerCastloveConfigService(srv common.RPCService) {
|
||||||
|
dubbo.SetConsumerServiceWithInfo(srv, &CastloveConfigService_ClientInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CastloveConfigServiceImpl implements CastloveConfigService.
|
||||||
|
type CastloveConfigServiceImpl struct {
|
||||||
|
conn *client.Connection
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CastloveConfigServiceImpl) GetCastloveConfig(ctx context.Context, req *GetCastloveConfigRequest, opts ...client.CallOption) (*GetCastloveConfigResponse, error) {
|
||||||
|
resp := new(GetCastloveConfigResponse)
|
||||||
|
if err := c.conn.CallUnary(ctx, []interface{}{req}, resp, "GetCastloveConfig", opts...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var CastloveConfigService_ClientInfo = client.ClientInfo{
|
||||||
|
InterfaceName: "topfans.castlove.CastloveConfigService",
|
||||||
|
MethodNames: []string{"GetCastloveConfig"},
|
||||||
|
ConnectionInjectFunc: func(dubboCliRaw interface{}, conn *client.Connection) {
|
||||||
|
dubboCli := dubboCliRaw.(*CastloveConfigServiceImpl)
|
||||||
|
dubboCli.conn = conn
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CastloveConfigServiceHandler is an implementation of the topfans.castlove.CastloveConfigService service.
|
||||||
|
type CastloveConfigServiceHandler interface {
|
||||||
|
GetCastloveConfig(context.Context, *GetCastloveConfigRequest) (*GetCastloveConfigResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterCastloveConfigServiceHandler(srv *server.Server, hdlr CastloveConfigServiceHandler, opts ...server.ServiceOption) error {
|
||||||
|
return srv.Register(hdlr, &CastloveConfigService_ServiceInfo, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetProviderCastloveConfigService(srv common.RPCService) {
|
||||||
|
dubbo.SetProviderServiceWithInfo(srv, &CastloveConfigService_ServiceInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
var CastloveConfigService_ServiceInfo = server.ServiceInfo{
|
||||||
|
InterfaceName: "topfans.castlove.CastloveConfigService",
|
||||||
|
ServiceType: (*CastloveConfigServiceHandler)(nil),
|
||||||
|
Methods: []server.MethodInfo{
|
||||||
|
{
|
||||||
|
Name: "GetCastloveConfig",
|
||||||
|
Type: constant.CallUnary,
|
||||||
|
ReqInitFunc: func() interface{} {
|
||||||
|
return new(GetCastloveConfigRequest)
|
||||||
|
},
|
||||||
|
MethodFunc: func(ctx context.Context, args []interface{}, handler interface{}) (interface{}, error) {
|
||||||
|
req := args[0].(*GetCastloveConfigRequest)
|
||||||
|
res, err := handler.(CastloveConfigServiceHandler).GetCastloveConfig(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return triple_protocol.NewResponse(res), nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
48
backend/proto/castlove_config.proto
Normal file
48
backend/proto/castlove_config.proto
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package topfans.castlove;
|
||||||
|
|
||||||
|
option go_package = "github.com/topfans/backend/pkg/proto/castlove;castlove";
|
||||||
|
|
||||||
|
import "proto/common.proto";
|
||||||
|
|
||||||
|
// ==================== 铸爱工艺配置 ====================
|
||||||
|
|
||||||
|
// 工艺卡片(精简版,只包含前端展示所需字段)
|
||||||
|
message CastloveCraftProto {
|
||||||
|
int64 id = 1; // 卡片ID
|
||||||
|
string name = 2; // 卡片名("镭射卡" / "光栅卡" / "开发中" / ...)
|
||||||
|
string image_url = 3; // 完整 https URL(OSS 域名开头)
|
||||||
|
string route_path = 4; // uni-app 页面路径(以 / 开头);空串表示"未配置路由"
|
||||||
|
string route_params = 5; // JSON 字符串(对象);空串表示无参数
|
||||||
|
int32 sort_order = 6; // 同一分类内的相对顺序
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工艺分类(含其下卡片列表)
|
||||||
|
message CastloveCategoryProto {
|
||||||
|
int64 id = 1; // 分类ID
|
||||||
|
string name = 2; // 分类名("星卡" / "吧唧" / "海报")
|
||||||
|
string type_key = 3; // 外部 deep-link key;空串表示无
|
||||||
|
int32 sort_order = 4; // 分类相对顺序
|
||||||
|
repeated CastloveCraftProto crafts = 5; // 该分类下的卡片
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取铸爱工艺配置请求(无入参,纯 GET)
|
||||||
|
message GetCastloveConfigRequest {
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取铸爱工艺配置响应
|
||||||
|
message GetCastloveConfigResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
repeated CastloveCategoryProto categories = 2; // 分类(含嵌套卡片)
|
||||||
|
string version = 3; // 所有相关记录 updated_at 最大值(ISO 8601)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 铸爱工艺配置服务 ====================
|
||||||
|
// 部署在 assetService(端口 20003),由 gateway 通过 Dubbo Triple 转发
|
||||||
|
// 前端路径:GET /api/v1/castlove/config
|
||||||
|
|
||||||
|
service CastloveConfigService {
|
||||||
|
// 获取铸爱工艺配置(全量分类+卡片)
|
||||||
|
rpc GetCastloveConfig(GetCastloveConfigRequest) returns (GetCastloveConfigResponse);
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/topfans/backend/pkg/logger"
|
"github.com/topfans/backend/pkg/logger"
|
||||||
"github.com/topfans/backend/pkg/models"
|
"github.com/topfans/backend/pkg/models"
|
||||||
pbAsset "github.com/topfans/backend/pkg/proto/asset"
|
pbAsset "github.com/topfans/backend/pkg/proto/asset"
|
||||||
|
pbCastlove "github.com/topfans/backend/pkg/proto/castlove"
|
||||||
pbRanking "github.com/topfans/backend/pkg/proto/ranking"
|
pbRanking "github.com/topfans/backend/pkg/proto/ranking"
|
||||||
pbUser "github.com/topfans/backend/pkg/proto/user"
|
pbUser "github.com/topfans/backend/pkg/proto/user"
|
||||||
assetClient "github.com/topfans/backend/services/assetService/client"
|
assetClient "github.com/topfans/backend/services/assetService/client"
|
||||||
@ -115,6 +116,7 @@ func main() {
|
|||||||
relationRepo := repository.NewAssetMaterialRelationRepository(database.GetDB())
|
relationRepo := repository.NewAssetMaterialRelationRepository(database.GetDB())
|
||||||
mintCostRepo := repository.NewMintCostRepository()
|
mintCostRepo := repository.NewMintCostRepository()
|
||||||
userMintCountRepo := repository.NewUserMintCountRepository()
|
userMintCountRepo := repository.NewUserMintCountRepository()
|
||||||
|
castloveConfigRepo := repository.NewCastloveConfigRepository(database.GetDB())
|
||||||
logger.Logger.Info("Repository layer initialized")
|
logger.Logger.Info("Repository layer initialized")
|
||||||
|
|
||||||
// 创建 Dubbo 客户端
|
// 创建 Dubbo 客户端
|
||||||
@ -145,11 +147,13 @@ func main() {
|
|||||||
assetLikeService := service.NewAssetLikeService(assetRepo, assetLikeRepo, database.GetDB(), assetLevelSvc)
|
assetLikeService := service.NewAssetLikeService(assetRepo, assetLikeRepo, database.GetDB(), assetLevelSvc)
|
||||||
rankingService := service.NewRankingService(rankingRepo, assetLikeRepo, userClient)
|
rankingService := service.NewRankingService(rankingRepo, assetLikeRepo, userClient)
|
||||||
materialService := service.NewMaterialService(materialRepo, relationRepo)
|
materialService := service.NewMaterialService(materialRepo, relationRepo)
|
||||||
|
castloveConfigService := service.NewCastloveConfigService(castloveConfigRepo)
|
||||||
logger.Logger.Info("Service layer initialized")
|
logger.Logger.Info("Service layer initialized")
|
||||||
|
|
||||||
// 创建 Provider 层实例
|
// 创建 Provider 层实例
|
||||||
assetProvider := provider.NewAssetProvider(assetService, mintService, assetLikeService, materialService)
|
assetProvider := provider.NewAssetProvider(assetService, mintService, assetLikeService, materialService)
|
||||||
rankingProvider := provider.NewRankingProvider(rankingService)
|
rankingProvider := provider.NewRankingProvider(rankingService)
|
||||||
|
castloveConfigProvider := provider.NewCastloveConfigProvider(castloveConfigService)
|
||||||
logger.Logger.Info("Provider layer initialized")
|
logger.Logger.Info("Provider layer initialized")
|
||||||
|
|
||||||
// 启动赛季重置 Worker(每小时检查一次)
|
// 启动赛季重置 Worker(每小时检查一次)
|
||||||
@ -185,6 +189,11 @@ func main() {
|
|||||||
logger.Logger.Fatal(fmt.Sprintf("Failed to register Ranking Service: %v", err))
|
logger.Logger.Fatal(fmt.Sprintf("Failed to register Ranking Service: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 注册 Castlove Config Service
|
||||||
|
if err := pbCastlove.RegisterCastloveConfigServiceHandler(srv, castloveConfigProvider); err != nil {
|
||||||
|
logger.Logger.Fatal(fmt.Sprintf("Failed to register Castlove Config Service: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
// 启动服务
|
// 启动服务
|
||||||
if err := srv.Serve(); err != nil {
|
if err := srv.Serve(); err != nil {
|
||||||
logger.Logger.Fatal(fmt.Sprintf("Failed to start Asset Service: %v", err))
|
logger.Logger.Fatal(fmt.Sprintf("Failed to start Asset Service: %v", err))
|
||||||
@ -226,6 +235,9 @@ func autoMigrate() error {
|
|||||||
&models.LaserCardTemplate{},
|
&models.LaserCardTemplate{},
|
||||||
&models.LaserCardInstance{},
|
&models.LaserCardInstance{},
|
||||||
&models.LaserCardOperationLog{},
|
&models.LaserCardOperationLog{},
|
||||||
|
// 铸爱工艺配置(只读,后台直连写库;此处 AutoMigrate 仅做兜底,实际表结构由外部 SQL 维护)
|
||||||
|
&models.CastloveCategory{},
|
||||||
|
&models.CastloveCraft{},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
|
|||||||
@ -0,0 +1,52 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/pkg/logger"
|
||||||
|
pbCastlove "github.com/topfans/backend/pkg/proto/castlove"
|
||||||
|
"github.com/topfans/backend/services/assetService/service"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CastloveConfigProvider 铸爱工艺配置 Provider
|
||||||
|
// 实现 Triple 协议生成的 CastloveConfigServiceHandler 接口
|
||||||
|
// 部署在 assetService 内(端口 20003),由 gateway 通过 Dubbo Triple 调用
|
||||||
|
//
|
||||||
|
// 注意:protoc-gen-go-triple v3 不生成 Unimplemented*Handler(不像 grpc-go),
|
||||||
|
// 只需直接实现接口方法即可。
|
||||||
|
type CastloveConfigProvider struct {
|
||||||
|
castloveConfigSvc service.CastloveConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编译期断言:CastloveConfigProvider 必须实现 CastloveConfigServiceHandler 接口
|
||||||
|
// (protoc 生成 *.triple.go 后,上面 UnimplementedCastloveConfigServiceHandler 才能解析)
|
||||||
|
var _ pbCastlove.CastloveConfigServiceHandler = (*CastloveConfigProvider)(nil)
|
||||||
|
|
||||||
|
// NewCastloveConfigProvider 创建铸爱工艺配置 Provider 实例
|
||||||
|
func NewCastloveConfigProvider(svc service.CastloveConfigService) *CastloveConfigProvider {
|
||||||
|
return &CastloveConfigProvider{
|
||||||
|
castloveConfigSvc: svc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCastloveConfig 获取铸爱工艺配置
|
||||||
|
// RPC:CastloveConfigService.GetCastloveConfig
|
||||||
|
// 鉴权:由 gateway 层 AuthMiddleware 强制 JWT,本服务信任 ctx 透传的 user_id
|
||||||
|
func (p *CastloveConfigProvider) GetCastloveConfig(ctx context.Context, req *pbCastlove.GetCastloveConfigRequest) (*pbCastlove.GetCastloveConfigResponse, error) {
|
||||||
|
logger.Logger.Info("Received GetCastloveConfig request")
|
||||||
|
|
||||||
|
resp, err := p.castloveConfigSvc.GetConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("GetCastloveConfig failed", zap.Error(err))
|
||||||
|
return &pbCastlove.GetCastloveConfigResponse{
|
||||||
|
Base: resp.Base, // 若有 base 则保留(可能含部分状态);否则为 nil,由调用方判空
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("GetCastloveConfig successful",
|
||||||
|
zap.Int("categories_count", len(resp.Categories)),
|
||||||
|
zap.String("version", resp.Version),
|
||||||
|
)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/topfans/backend/pkg/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CastloveConfigRepository 铸爱工艺配置 Repository 接口
|
||||||
|
type CastloveConfigRepository interface {
|
||||||
|
// ListActiveCategories 读取所有 is_active=true 且未软删的分类
|
||||||
|
// 返回按 sort_order ASC, id ASC 排序
|
||||||
|
ListActiveCategories() ([]*models.CastloveCategory, error)
|
||||||
|
|
||||||
|
// ListActiveCrafts 读取所有 is_active=true 且未软删的卡片
|
||||||
|
// 返回按 sort_order ASC, id ASC 排序
|
||||||
|
ListActiveCrafts() ([]*models.CastloveCraft, error)
|
||||||
|
|
||||||
|
// MaxUpdatedAt 返回两表所有相关记录 updated_at 最大值(毫秒)
|
||||||
|
// 用于构造 version 字段(便于调试/条件请求)
|
||||||
|
MaxUpdatedAt() (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type castloveConfigRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCastloveConfigRepository 创建铸爱工艺配置 Repository 实例
|
||||||
|
func NewCastloveConfigRepository(db *gorm.DB) CastloveConfigRepository {
|
||||||
|
return &castloveConfigRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveCategories 读取所有 is_active=true 且未软删的分类
|
||||||
|
func (r *castloveConfigRepository) ListActiveCategories() ([]*models.CastloveCategory, error) {
|
||||||
|
var categories []*models.CastloveCategory
|
||||||
|
err := r.db.
|
||||||
|
Where("is_active = ? AND deleted_at IS NULL", true).
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
Find(&categories).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return categories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveCrafts 读取所有 is_active=true 且未软删的卡片
|
||||||
|
func (r *castloveConfigRepository) ListActiveCrafts() ([]*models.CastloveCraft, error) {
|
||||||
|
var crafts []*models.CastloveCraft
|
||||||
|
err := r.db.
|
||||||
|
Where("is_active = ? AND deleted_at IS NULL", true).
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
Find(&crafts).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return crafts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxUpdatedAt 返回两表所有相关记录 updated_at 最大值(毫秒)
|
||||||
|
// category_max = MAX(updated_at) FROM castlove_categories WHERE is_active AND !deleted
|
||||||
|
// craft_max = MAX(updated_at) FROM castlove_crafts WHERE is_active AND !deleted
|
||||||
|
// 返回两者中的较大值
|
||||||
|
func (r *castloveConfigRepository) MaxUpdatedAt() (int64, error) {
|
||||||
|
var catMax, craftMax int64
|
||||||
|
|
||||||
|
if err := r.db.
|
||||||
|
Model(&models.CastloveCategory{}).
|
||||||
|
Select("COALESCE(MAX(updated_at), 0)").
|
||||||
|
Where("is_active = ? AND deleted_at IS NULL", true).
|
||||||
|
Scan(&catMax).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.db.
|
||||||
|
Model(&models.CastloveCraft{}).
|
||||||
|
Select("COALESCE(MAX(updated_at), 0)").
|
||||||
|
Where("is_active = ? AND deleted_at IS NULL", true).
|
||||||
|
Scan(&craftMax).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if catMax > craftMax {
|
||||||
|
return catMax, nil
|
||||||
|
}
|
||||||
|
return craftMax, nil
|
||||||
|
}
|
||||||
215
backend/services/assetService/service/castlove_config_service.go
Normal file
215
backend/services/assetService/service/castlove_config_service.go
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/pkg/logger"
|
||||||
|
pbCastlove "github.com/topfans/backend/pkg/proto/castlove"
|
||||||
|
pbCommon "github.com/topfans/backend/pkg/proto/common"
|
||||||
|
"github.com/topfans/backend/pkg/models"
|
||||||
|
"github.com/topfans/backend/services/assetService/repository"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CastloveConfigService 铸爱工艺配置 Service 接口
|
||||||
|
type CastloveConfigService interface {
|
||||||
|
// GetConfig 读取全量配置(含 5s 进程内缓存 + singleflight 防击穿)
|
||||||
|
// 返回已 sanitize 后的数据
|
||||||
|
GetConfig(ctx context.Context) (*pbCastlove.GetCastloveConfigResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存 TTL:5 秒(spec §3.4)
|
||||||
|
// 平衡点:端上体验 vs DB 压力;运营反馈"改完看不到要等 5 秒太久"时可调到 2 秒
|
||||||
|
const castloveConfigCacheTTL = 5 * time.Second
|
||||||
|
|
||||||
|
// castloveConfigService 实现
|
||||||
|
type castloveConfigService struct {
|
||||||
|
repo repository.CastloveConfigRepository
|
||||||
|
|
||||||
|
// 进程内缓存:原子指针,避免锁
|
||||||
|
cache atomic.Pointer[castloveConfigCacheEntry]
|
||||||
|
|
||||||
|
// singleflight:多并发同时 miss 时只打一次 DB
|
||||||
|
sfGroup singleflight.Group
|
||||||
|
}
|
||||||
|
|
||||||
|
// castloveConfigCacheEntry 缓存条目
|
||||||
|
type castloveConfigCacheEntry struct {
|
||||||
|
data *pbCastlove.GetCastloveConfigResponse
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCastloveConfigService 创建铸爱工艺配置 Service 实例
|
||||||
|
func NewCastloveConfigService(repo repository.CastloveConfigRepository) CastloveConfigService {
|
||||||
|
return &castloveConfigService{
|
||||||
|
repo: repo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig 读取全量配置(含 5s 进程内缓存 + singleflight 防击穿)
|
||||||
|
func (s *castloveConfigService) GetConfig(ctx context.Context) (*pbCastlove.GetCastloveConfigResponse, error) {
|
||||||
|
// 1. 命中缓存
|
||||||
|
if c := s.cache.Load(); c != nil && time.Now().Before(c.expiresAt) {
|
||||||
|
return c.data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 缓存未命中或已过期,走 singleflight(多并发只打一次 DB)
|
||||||
|
v, err, _ := s.sfGroup.Do("castlove_config", func() (interface{}, error) {
|
||||||
|
return s.loadFromDB(ctx)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := v.(*pbCastlove.GetCastloveConfigResponse)
|
||||||
|
|
||||||
|
// 3. 写入缓存(覆盖式)
|
||||||
|
s.cache.Store(&castloveConfigCacheEntry{
|
||||||
|
data: resp,
|
||||||
|
expiresAt: time.Now().Add(castloveConfigCacheTTL),
|
||||||
|
})
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFromDB 从 DB 读取并组装 + sanitize
|
||||||
|
// (后台直连写库,这里做最低限度的兜底校验,防止脏数据穿透到前端)
|
||||||
|
func (s *castloveConfigService) loadFromDB(ctx context.Context) (*pbCastlove.GetCastloveConfigResponse, error) {
|
||||||
|
categories, err := s.repo.ListActiveCategories()
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("castlove_config: list categories failed", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
crafts, err := s.repo.ListActiveCrafts()
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("castlove_config: list crafts failed", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
maxUpdatedAt, err := s.repo.MaxUpdatedAt()
|
||||||
|
if err != nil {
|
||||||
|
// 失败不致命,降级 version = 当前时间
|
||||||
|
logger.Logger.Warn("castlove_config: maxUpdatedAt failed, fallback to now", zap.Error(err))
|
||||||
|
maxUpdatedAt = time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造 category_id -> []*craft 的 map,方便 O(1) 归类
|
||||||
|
craftsByCategory := make(map[int64][]*models.CastloveCraft, len(categories))
|
||||||
|
for _, c := range crafts {
|
||||||
|
craftsByCategory[c.CategoryID] = append(craftsByCategory[c.CategoryID], c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造响应
|
||||||
|
out := &pbCastlove.GetCastloveConfigResponse{
|
||||||
|
Base: &pbCommon.BaseResponse{
|
||||||
|
Code: pbCommon.StatusCode_STATUS_OK,
|
||||||
|
Message: "ok",
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
},
|
||||||
|
Categories: make([]*pbCastlove.CastloveCategoryProto, 0, len(categories)),
|
||||||
|
Version: time.UnixMilli(maxUpdatedAt).UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cat := range categories {
|
||||||
|
protoCat := &pbCastlove.CastloveCategoryProto{
|
||||||
|
Id: cat.ID,
|
||||||
|
Name: cat.Name,
|
||||||
|
SortOrder: cat.SortOrder,
|
||||||
|
Crafts: make([]*pbCastlove.CastloveCraftProto, 0),
|
||||||
|
}
|
||||||
|
if cat.TypeKey != nil {
|
||||||
|
protoCat.TypeKey = *cat.TypeKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// 归类该分类下的卡片
|
||||||
|
rawCrafts := craftsByCategory[cat.ID]
|
||||||
|
for _, raw := range rawCrafts {
|
||||||
|
// sanitize 兜底:返回 nil 表示丢弃
|
||||||
|
sanitized := sanitizeCastloveCraft(raw)
|
||||||
|
if sanitized == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
protoCat.Crafts = append(protoCat.Crafts, sanitized)
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Categories = append(out.Categories, protoCat)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeCastloveCraft 兜底校验
|
||||||
|
// 返回 nil 表示该卡片应被丢弃(日志 warn,不抛错)
|
||||||
|
// 规则(spec §3.5):
|
||||||
|
// - image_url 必须 http(s) 开头
|
||||||
|
// - name 非空
|
||||||
|
// - route_path 若非空:必须以 / 开头,且不含 ? / #
|
||||||
|
// - route_params 若非空:必须是合法 JSON object
|
||||||
|
func sanitizeCastloveCraft(c *models.CastloveCraft) *pbCastlove.CastloveCraftProto {
|
||||||
|
// 1. image_url 必须 http(s) 开头
|
||||||
|
if !strings.HasPrefix(c.ImageURL, "http://") && !strings.HasPrefix(c.ImageURL, "https://") {
|
||||||
|
logger.Logger.Warn("castlove_config: drop craft, invalid image_url",
|
||||||
|
zap.Int64("craft_id", c.ID),
|
||||||
|
zap.String("image_url", c.ImageURL),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. name 非空
|
||||||
|
if strings.TrimSpace(c.Name) == "" {
|
||||||
|
logger.Logger.Warn("castlove_config: drop craft, empty name",
|
||||||
|
zap.Int64("craft_id", c.ID),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. route_path 兜底
|
||||||
|
routePath := ""
|
||||||
|
if c.RoutePath != nil {
|
||||||
|
raw := strings.TrimSpace(*c.RoutePath)
|
||||||
|
if raw != "" {
|
||||||
|
if !strings.HasPrefix(raw, "/") || strings.ContainsAny(raw, "?#") {
|
||||||
|
logger.Logger.Warn("castlove_config: clear route_path (invalid format)",
|
||||||
|
zap.Int64("craft_id", c.ID),
|
||||||
|
zap.String("route_path", raw),
|
||||||
|
)
|
||||||
|
// 降级为 NULL/空(行为同"未配置")
|
||||||
|
raw = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routePath = raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. route_params 兜底:必须是合法 JSON object
|
||||||
|
routeParams := ""
|
||||||
|
if c.RouteParams != nil {
|
||||||
|
raw := strings.TrimSpace(*c.RouteParams)
|
||||||
|
if raw != "" {
|
||||||
|
var m map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||||
|
logger.Logger.Warn("castlove_config: clear route_params (not JSON object)",
|
||||||
|
zap.Int64("craft_id", c.ID),
|
||||||
|
zap.String("route_params", raw),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
raw = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routeParams = raw
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pbCastlove.CastloveCraftProto{
|
||||||
|
Id: c.ID,
|
||||||
|
Name: c.Name,
|
||||||
|
ImageUrl: c.ImageURL,
|
||||||
|
RoutePath: routePath,
|
||||||
|
RouteParams: routeParams,
|
||||||
|
SortOrder: c.SortOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
227
docs/frontend-api-consistency-audit.md
Normal file
227
docs/frontend-api-consistency-audit.md
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
# 前端 API 接口格式统一性审查报告
|
||||||
|
|
||||||
|
> 审查时间:2026-06-09
|
||||||
|
> 范围:`frontend/` 下所有源码(`unpackage/dist` 等编译产物已排除)
|
||||||
|
> 核心封装器:`frontend/utils/api.js` 的 `request(options)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. TL;DR
|
||||||
|
|
||||||
|
整体架构是统一的:所有 JSON 接口走同一 `request()` 入口,token 注入、401 跳转、响应解析、Mock 短路都集中在 `utils/api.js`。
|
||||||
|
|
||||||
|
**主要问题不在封装本身,而在一批"绕过封装"的散点**:
|
||||||
|
|
||||||
|
- **OSS 上传/下载**没有沉淀到 `api.js`,导致 5 个页面 + 3 个 utils 各自实现一遍
|
||||||
|
- **20+ 处业务侧**重复判断 `code === 200`(封装器已保证)
|
||||||
|
- **2 处历史遗留** URL 仍走旧版 `/api/user/*`(无 `/v1`)
|
||||||
|
- **3 个模块** 自己造了 `request` 风格的 `uni.request` 轮子
|
||||||
|
|
||||||
|
优先级 P0:把 OSS 直传抽到 `utils/api.js`,可一次性消掉 30+ 处重复代码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 已统一的良好部分 ✅
|
||||||
|
|
||||||
|
### 1.1 入口封装 — 95% 的 JSON 接口走 `request()`
|
||||||
|
|
||||||
|
- 唯一封装器:`frontend/utils/api.js` 的 `request(options)`(L53–L134)
|
||||||
|
- `frontend/utils/api.js` 内 60+ 个 API + `frontend/utils/task-api.js` 的 11 个 API 全部走 `request()`
|
||||||
|
- 全项目 **46 个文件** 直接 import `@/utils/api` 或 `@/utils/task-api`,**无相对路径污染**
|
||||||
|
|
||||||
|
### 1.2 Token 注入
|
||||||
|
|
||||||
|
- 自动从 `uni.getStorageSync('access_token')` 读取
|
||||||
|
- Header 格式统一为 `Authorization: Bearer <token>`
|
||||||
|
- 白名单(不注入 token):`/api/v1/auth/login|register|send-code|verify-code`
|
||||||
|
|
||||||
|
### 1.3 业务响应结构
|
||||||
|
|
||||||
|
后端约定:`{ code, message, data }`,封装器在 `request()` 内统一处理:
|
||||||
|
|
||||||
|
| 输入 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| `HTTP 200/202` + `code === 200` | resolve 整体响应 |
|
||||||
|
| `HTTP 200/202` + `code === 401/400/403` | 清 token + 跳登录 + reject |
|
||||||
|
| `HTTP 200/202` + 其他 `code` | reject(`Error(message)`) |
|
||||||
|
| `HTTP 200/202` + 无 `code` 字段 | resolve(`res.data`) ← 兜底 |
|
||||||
|
| `HTTP 401` | 清 token + 跳登录 + reject |
|
||||||
|
| 其他 HTTP 状态码 | reject(`Error(message || '请求失败 (statusCode)')`) |
|
||||||
|
|
||||||
|
### 1.4 环境与 Mock 开关
|
||||||
|
|
||||||
|
- `VITE_API_BASE_URL` / `VITE_USE_MOCK_API` 集中读取
|
||||||
|
- `IS_MOCK_API` 暴露给业务做短路
|
||||||
|
- `getWebSocketBaseUrl` / `getSegmentApiBaseUrl` / `getLaserApiBaseUrl` 三个 baseURL 工厂统一
|
||||||
|
|
||||||
|
### 1.5 Dashboard 模块有专门封装
|
||||||
|
|
||||||
|
`dashboardApi` 对象 + `dashboardRequest()` 工厂(`utils/api.js` L999–L1007),统一了 mock 短路与 baseURL 前缀。
|
||||||
|
|
||||||
|
### 1.6 WebSocket 入口统一
|
||||||
|
|
||||||
|
`utils/socket/SocketManager.js` 通过 `getWebSocketBaseUrl()` 工厂函数拿到 baseURL(L48–L49),token 拼在 query 上(`?token=Bearer_<token>`),鉴权方式与 HTTP 通道保持一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 不一致点(按严重度排序)
|
||||||
|
|
||||||
|
### 🔴 严重:页面层直连 `uni.request` / `uni.uploadFile` / `fetch`
|
||||||
|
|
||||||
|
下列文件**绕过了 `request()` 封装**,需要各自维护 token 注入、超时、错误处理、401 跳转。表中行号定位到调用点。
|
||||||
|
|
||||||
|
| 文件 | 行号 | 用途 | 风险点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `pages/discover/discover.vue` | L101, L134 | OSS PostObject | 重复实现 fetch + uploadFile 分支 |
|
||||||
|
| `pages/discover/generation-result.vue` | L316, L418, L495, L653 | OSS + 上传 | 4 处独立直传,逻辑高度相似 |
|
||||||
|
| `pages/castlove/index.vue` | L261, L409 | 图片下载/上传 | 自行处理 token |
|
||||||
|
| `pages/castlove/create.vue` | L430, L527, L540, L569, L614 | 全链路直传 | 5 处,逻辑重复 |
|
||||||
|
| `pages/castlove/lenticular/lenticular-create.vue` | L331, L427, L440, L468, L512 | 全链路直传 | 5 处 |
|
||||||
|
| `pages/castlove/lenticular/lenticular-result.vue` | L217, L319, L396, L554 | 全链路直传 | 4 处 |
|
||||||
|
| `pages/square/components/WaterfallGrid.vue` | L712, L735, L777, L832 | 业务请求 | 4 处 `res.code === 200` |
|
||||||
|
| `pages/square/components/HotCategoryBlock.vue` | L193 | 业务请求 | `code === 200` 重复判断 |
|
||||||
|
| `pages/profile/profile.vue` | L1187 | 头像上传 | 自处理 statusCode |
|
||||||
|
| `pages/profile/setNickname.vue` | L183 | 头像上传 | 自处理 statusCode,**无 token 注入** |
|
||||||
|
| `pages/starbook/items.vue` | L136 | 业务请求 | `response.code === 200` |
|
||||||
|
|
||||||
|
### 🟡 中等:业务侧重复判断 `code === 200`
|
||||||
|
|
||||||
|
`request()` 已经对 `code === 200` resolve 整体,业务侧**不应**再判断。下列位置违反封装契约:
|
||||||
|
|
||||||
|
```
|
||||||
|
utils/progress-manager.js:180
|
||||||
|
utils/activity-config.js:46, 69, 87, 105, 126
|
||||||
|
utils/craftMintSubmit.js:481, 510, 627, 690
|
||||||
|
pages/square/components/WaterfallGrid.vue:712, 735, 777, 832
|
||||||
|
pages/square/components/HotCategoryBlock.vue:193
|
||||||
|
pages/starbook/items.vue:136
|
||||||
|
pages/discover/discover.vue:195, 223
|
||||||
|
pages/discover/generation-result.vue:284
|
||||||
|
composables/useLaserMint.js:42
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🟡 中等:部分模块自己造 `request` 风格轮子
|
||||||
|
|
||||||
|
| 文件 | 位置 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `composables/useLaserDifyGenerate.js` | L19–L47 | 独立 `laserRequest()`,自行注入 token、超时、状态码判断,文件内注释自承 "返回格式与 api.js 的 request() 一致" |
|
||||||
|
| `utils/laser-card/segmentationCloud.js` | L21–L39 | `uniRequest()` 重复实现,仅做状态码判断 |
|
||||||
|
| `utils/laser-card/aliyunPortraitUni.js` | L273 | `uni.request` 走 OSS PostObject,因 OSS 协议需要单独处理 `x-oss-date` 等特殊头 |
|
||||||
|
| `utils/craftMintSubmit.js` | L21, L57, L70, L399, L407, L423, L451 | 5 处 `uni.uploadFile`/`fetch` 混用,H5/App 分支自管 |
|
||||||
|
|
||||||
|
> 这些位置**有合理理由**绕过 `request()`(OSS 直传需自定义头、需 `multipart/form-data`),但应**抽到 `utils/api.js`** 而不是散布在业务模块。
|
||||||
|
|
||||||
|
### 🟡 中等:URL 风格不一致
|
||||||
|
|
||||||
|
虽然都带 `/api/v1` 前缀,但**功能模块 URL 风格混乱**:
|
||||||
|
|
||||||
|
- 资源 URL:`/api/v1/mygalleries`(无 `/me` 也没有 `/users`)vs `/api/v1/galleries/:uid` vs `/api/v1/galleries/random`(`utils/api.js` L431–L452)
|
||||||
|
- 历史遗留:`updateUserInfoApi` L215 仍走 `/api/user/update`(无 `/v1`),`deleteAccountApi` L276 走 `/api/user/delete-account`
|
||||||
|
- 单复数、是否用 `/me` 表示当前用户不统一:
|
||||||
|
- `/api/v1/auth/me`
|
||||||
|
- `/api/v1/me/nickname`
|
||||||
|
- `/api/v1/me/avatar`
|
||||||
|
- `/api/v1/me/liked-assets`
|
||||||
|
- `/api/v1/me/exhibited-assets`
|
||||||
|
- `/api/v1/users/:uid/liked-assets`
|
||||||
|
|
||||||
|
### 🟢 轻微:拼装 URL 的方式不统一
|
||||||
|
|
||||||
|
- 多数用模板字符串拼在 `url` 内(`/api/v1/social/users?page=${page}&page_size=${pageSize}`,`utils/api.js` L226)
|
||||||
|
- 也有用 `data` 字段传 query 的(GET 请求,如 `task-api.js` L15 `getDailyTasks` 把 `star_id` 放 `data` 字段,uni.request 在 GET 下会拼到 query)
|
||||||
|
- 统一规范缺失,新人很容易混用
|
||||||
|
|
||||||
|
### 🟢 轻微:`request` 默认 timeout 与 `uni.uploadFile` 超时不一致
|
||||||
|
|
||||||
|
- `request()` 默认 60000ms(L78)
|
||||||
|
- `segmentPortraitApi` 用 120000ms(L749)
|
||||||
|
- `downloadToLocal` 用 120000ms(`composables/useLaserSegment.js` L29)
|
||||||
|
- 没有集中常量
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 统计摘要
|
||||||
|
|
||||||
|
| 指标 | 数值 |
|
||||||
|
|---|---|
|
||||||
|
| 直接 import `@/utils/api` 的文件 | 46 |
|
||||||
|
| 仍走 `uni.request` 直连的文件 | 7 |
|
||||||
|
| 仍走 `uni.uploadFile` 直连的文件 | 8 |
|
||||||
|
| 仍走 `fetch()` 直连的文件 | 5 |
|
||||||
|
| 业务侧 `code === 200` 二次判断处 | 20+ |
|
||||||
|
| URL 路径里残留 `/api/user/*` 旧接口 | 2 |
|
||||||
|
| 重复实现的 `request` 风格封装 | 3(`laserRequest`、`uniRequest`、`laserRequest`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 修复建议(按 ROI 排序)
|
||||||
|
|
||||||
|
### P0 — 把"绕过封装"的 OSS 直传抽到 `api.js`
|
||||||
|
|
||||||
|
新增一组 OSS 专用工具到 `utils/api.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 上传 OSS (H5: fetch + FormData / App: uni.uploadFile)
|
||||||
|
export function uploadToOss({ host, dir, filePath, policy, signature, ... }) { ... }
|
||||||
|
|
||||||
|
// 下载 OSS (H5: fetch + blob URL / App: uni.downloadFile)
|
||||||
|
export function downloadFromOss(signedUrl) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:消掉 5 个页面、3 个 utils 中重复的 30+ 处直传代码;统一 token 注入;统一超时;统一错误处理。
|
||||||
|
|
||||||
|
### P1 — 修复 `/api/user/*` 旧路径
|
||||||
|
|
||||||
|
- `updateUserInfoApi`(L215):`/api/user/update` → 对应的 v1 路径
|
||||||
|
- `deleteAccountApi`(L276):`/api/user/delete-account` → `/api/v1/me/account` 之类
|
||||||
|
|
||||||
|
### P1 — 业务侧禁用 `code === 200` 二次判断
|
||||||
|
|
||||||
|
封装器已经处理,业务层应只 try/catch。可在 `request()` 里加注释:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 业务侧请勿重复判断 code === 200,封装器已保证 resolve 时 code === 200
|
||||||
|
```
|
||||||
|
|
||||||
|
### P2 — URL 风格规范化
|
||||||
|
|
||||||
|
建议对齐:
|
||||||
|
|
||||||
|
- 当前用户 → `/me`(已用):`/api/v1/me/liked-assets`、`/api/v1/me/exhibited-assets` 等等
|
||||||
|
- 指定用户 → `/users/:uid`:`/api/v1/users/:uid/liked-assets`
|
||||||
|
- 收藏/资源 → 单数主语,操作作子路径:`/api/v1/galleries/...` 而不是 `/api/v1/mygalleries`
|
||||||
|
|
||||||
|
### P2 — 提取 `api.js` 默认 timeout 常量
|
||||||
|
|
||||||
|
```js
|
||||||
|
const DEFAULT_TIMEOUT = 60000
|
||||||
|
const UPLOAD_TIMEOUT = 120000
|
||||||
|
export const API_TIMEOUT = {
|
||||||
|
default: DEFAULT_TIMEOUT,
|
||||||
|
upload: UPLOAD_TIMEOUT,
|
||||||
|
download: UPLOAD_TIMEOUT,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### P3 — 拼装 query 的工具函数
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function buildQuery(params) {
|
||||||
|
return '?' + new URLSearchParams(params).toString()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
让所有 `getXxxApi` 走同一方式,而非分散在 `url`/`data` 两处。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 后续 Action Items
|
||||||
|
|
||||||
|
| 优先级 | 任务 | 涉及文件数 | 预估工时 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P0 | 把 OSS 上传/下载抽到 `utils/api.js` | 8 | 4h |
|
||||||
|
| P1 | 修复 `/api/user/*` 旧路径 | 2 | 0.5h |
|
||||||
|
| P1 | 业务侧清除 20+ 处 `code === 200` 二次判断 | 10+ | 1h |
|
||||||
|
| P2 | URL 风格规范化 + `/me` 对齐 | 20+ | 2h |
|
||||||
|
| P2 | 提取 `API_TIMEOUT` 常量 | 3 | 0.5h |
|
||||||
|
| P3 | 引入 `buildQuery` 工具函数 | 30+ | 1h |
|
||||||
@ -12,10 +12,34 @@
|
|||||||
@touchmove="onTouchMove"
|
@touchmove="onTouchMove"
|
||||||
@touchend="onTouchEnd"
|
@touchend="onTouchEnd"
|
||||||
>
|
>
|
||||||
<view v-if="currentCardList.length">
|
<!-- 1. 加载中:背景图照常显示,卡片区显示半透明 loading,不要白屏 -->
|
||||||
|
<view v-if="loading" class="loading-state">
|
||||||
|
<image class="loading-spinner" src="/static/common/loading.png" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 2. 加载失败:点击重试 -->
|
||||||
|
<view v-else-if="loadError" class="error-state" @tap="loadConfig">
|
||||||
|
<text class="error-text">{{ loadError }}</text>
|
||||||
|
<text class="error-hint">点击重试</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 3. 全空:后台把所有分类都软删了 -->
|
||||||
|
<view v-else-if="categories.length === 0" class="empty-state">
|
||||||
|
<image class="empty-state-icon" src="/static/castlove/jinqingqidai.png" mode="aspectFit" />
|
||||||
|
<text class="empty-state-title">暂无内容</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 4. 某分类下卡片为空 -->
|
||||||
|
<view v-else-if="currentCardList.length === 0" class="empty-state">
|
||||||
|
<image class="empty-state-icon" src="/static/castlove/jinqingqidai.png" mode="aspectFit" />
|
||||||
|
<text class="empty-state-title">{{ currentCategoryName }}敬请期待</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 5. 正常:渲染卡片 -->
|
||||||
|
<view v-else>
|
||||||
<view
|
<view
|
||||||
v-for="(card, index) in currentCardList"
|
v-for="(card, index) in currentCardList"
|
||||||
:key="index"
|
:key="card.id"
|
||||||
class="card-item"
|
class="card-item"
|
||||||
:class="{ 'no-transition': disableTransition }"
|
:class="{ 'no-transition': disableTransition }"
|
||||||
:style="getCardStyle(index)"
|
:style="getCardStyle(index)"
|
||||||
@ -23,14 +47,20 @@
|
|||||||
<view
|
<view
|
||||||
class="card-frame"
|
class="card-frame"
|
||||||
:class="{
|
:class="{
|
||||||
'no-border': card.comingSoon,
|
'no-border': !card.route_path,
|
||||||
'card-frame--tappable': !card.comingSoon,
|
'card-frame--tappable': !!card.route_path,
|
||||||
}"
|
}"
|
||||||
@tap.stop="onCardFrameTap(index)"
|
@tap.stop="onCardFrameTap(card)"
|
||||||
>
|
>
|
||||||
<image class="card-image" :src="card.image" mode="aspectFill" />
|
|
||||||
<image
|
<image
|
||||||
v-if="card.comingSoon"
|
class="card-image"
|
||||||
|
:src="card.image_url"
|
||||||
|
mode="aspectFill"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
<!-- "开发中"角标:仅当卡片名就是"开发中"时才显示(避免对其他未配置路由的卡片误显示) -->
|
||||||
|
<image
|
||||||
|
v-if="card.name === '开发中'"
|
||||||
class="coming-soon-badge"
|
class="coming-soon-badge"
|
||||||
src="/static/castlove/jinqingqidai.png"
|
src="/static/castlove/jinqingqidai.png"
|
||||||
/>
|
/>
|
||||||
@ -38,16 +68,18 @@
|
|||||||
<text class="card-name">{{ card.name }}</text>
|
<text class="card-name">{{ card.name }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- 空态:当前分类暂无卡片时显示 -->
|
|
||||||
<!-- <view v-else class="empty-state">
|
|
||||||
<image class="empty-state-icon" src="/static/castlove/jinqingqidai.png" mode="aspectFit" />
|
|
||||||
<text class="empty-state-title">{{ currentCategoryName }}敬请期待</text>
|
|
||||||
<text class="empty-state-desc">该分类正在打磨中,马上与您见面</text>
|
|
||||||
</view> -->
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="showMenu" class="text-panel">
|
<!-- 右侧分类菜单:仅在数据正常时显示 -->
|
||||||
<view class="arrow-btn arrow-up" @click="scrollUp">
|
<view
|
||||||
|
v-if="!loading && !loadError && categories.length"
|
||||||
|
class="text-panel"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
class="arrow-btn arrow-up"
|
||||||
|
:class="{ 'arrow-btn--disabled': selectedCategoryIndex === 0 }"
|
||||||
|
@click="scrollUp"
|
||||||
|
>
|
||||||
<image
|
<image
|
||||||
class="arrow-icon"
|
class="arrow-icon"
|
||||||
src="/static/castlove/jiantou.png"
|
src="/static/castlove/jiantou.png"
|
||||||
@ -56,20 +88,23 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="text-list">
|
<view class="text-list">
|
||||||
<view
|
<view
|
||||||
v-for="(category, index) in categoryList"
|
v-for="(category, index) in categories"
|
||||||
:key="index"
|
:key="category.id"
|
||||||
class="text-item"
|
class="text-item"
|
||||||
:class="{
|
:class="{ active: selectedCategoryIndex === index }"
|
||||||
active: selectedCategoryIndex === index,
|
|
||||||
'font-large': index === 1,
|
|
||||||
'font-mid': index === 0 || index === 2,
|
|
||||||
}"
|
|
||||||
@click="selectCategory(index)"
|
@click="selectCategory(index)"
|
||||||
>
|
>
|
||||||
<text>{{ category.name }}</text>
|
<text>{{ category.name }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="arrow-btn arrow-down" @click="scrollDown">
|
<view
|
||||||
|
class="arrow-btn arrow-down"
|
||||||
|
:class="{
|
||||||
|
'arrow-btn--disabled':
|
||||||
|
selectedCategoryIndex === categories.length - 1,
|
||||||
|
}"
|
||||||
|
@click="scrollDown"
|
||||||
|
>
|
||||||
<image class="arrow-icon" src="/static/castlove/jiantou.png" />
|
<image class="arrow-icon" src="/static/castlove/jiantou.png" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -78,6 +113,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { getCastloveConfigApi } from "@/utils/api.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// 作为组件被 mall.vue 引入时的入参
|
// 作为组件被 mall.vue 引入时的入参
|
||||||
// (作为页面被 navigateTo 直接打开时不传值,走 onLoad 读取 URL 参数)
|
// (作为页面被 navigateTo 直接打开时不传值,走 onLoad 读取 URL 参数)
|
||||||
@ -89,164 +126,34 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
// 作为组件时,父组件传 type 进来时自动定位
|
// 关键入口:组件模式下(被 mall.vue 引用),onLoad 不会触发
|
||||||
// 用 immediate + handler 显式写法,避免简写在某些 H5 场景下不触发
|
// 必须在这里同时拉配置 + 应用 type。immediate: true 保证 mount 时就跑一次
|
||||||
type: {
|
type: {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
handler(val) {
|
async handler(val) {
|
||||||
|
// 先等数据回来,再定位分类(findIndex 需要 categories 已加载)
|
||||||
|
await this.loadConfig();
|
||||||
this.applyType(val);
|
this.applyType(val);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
|
||||||
currentCardList() {
|
|
||||||
const categoryName = this.categoryList[this.selectedCategoryIndex].name;
|
|
||||||
return this.cardListMap[categoryName] || this.cardList;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
// 选择大分类
|
|
||||||
selectCategory(index) {
|
|
||||||
this.selectedCategoryIndex = index;
|
|
||||||
this.selectedIndex = 0; // 重置子选项选中
|
|
||||||
},
|
|
||||||
// 触摸开始
|
|
||||||
onTouchStart(e) {
|
|
||||||
this.touchStartY = e.touches[0].clientY;
|
|
||||||
},
|
|
||||||
// 触摸结束 - 滑动切换工艺卡片
|
|
||||||
onTouchEnd(e) {
|
|
||||||
const touchEndY = e.changedTouches[0].clientY;
|
|
||||||
const diff = this.touchStartY - touchEndY;
|
|
||||||
const threshold = 50; // 滑动阈值
|
|
||||||
|
|
||||||
if (diff > threshold) {
|
|
||||||
// 向上滑动 → 下一个工艺卡片
|
|
||||||
let newIndex = this.selectedIndex + 1;
|
|
||||||
if (newIndex < this.currentCardList.length) {
|
|
||||||
this.selectCard(newIndex);
|
|
||||||
}
|
|
||||||
} else if (diff < -threshold) {
|
|
||||||
// 向下滑动 → 上一个工艺卡片
|
|
||||||
let newIndex = this.selectedIndex - 1;
|
|
||||||
if (newIndex >= 0) {
|
|
||||||
this.selectCard(newIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// 获取卡片样式 - 循环滚动布局
|
|
||||||
// positions 定义了5个位置的固定样式(位置0最上,位置2中间,位置4最下)
|
|
||||||
// 当选中某个卡片时,该卡片显示在位置2(中间),其他卡片循环填充
|
|
||||||
getCardStyle(index) {
|
|
||||||
const positions = [
|
|
||||||
{ left: 9 * 32, top: 2 * 32, rotate: 25, scale: 0.75 }, // 位置0 - 最上
|
|
||||||
{ left: 3.75 * 32, top: 9 * 32, rotate: 12, scale: 0.95 }, // 位置1 - 中上
|
|
||||||
{ left: 60, top: 580, rotate: 0, scale: 1 }, // 位置2 - 中间
|
|
||||||
{ left: 3.75 * 32, top: 27.75 * 32, rotate: -12, scale: 0.95 }, // 位置3 - 中下
|
|
||||||
{ left: 7 * 32, top: 34.25 * 32, rotate: -25, scale: 0.75 }, // 位置4 - 最下
|
|
||||||
];
|
|
||||||
|
|
||||||
// 计算当前卡片应该显示在哪个位置
|
|
||||||
// 循环移位:selectedIndex 的卡片显示在位置2(中间)
|
|
||||||
const cardPos = (index - this.selectedIndex + 2 + 5) % 5;
|
|
||||||
const pos = positions[cardPos];
|
|
||||||
|
|
||||||
// 选中卡片在中间位置时放大
|
|
||||||
if (cardPos === 2) {
|
|
||||||
return {
|
|
||||||
left: `${pos.left}rpx`,
|
|
||||||
top: `${pos.top}rpx`,
|
|
||||||
transform: `scale(${pos.scale * 1.15}) rotate(0deg)`,
|
|
||||||
zIndex: 100,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
left: `${pos.left}rpx`,
|
|
||||||
top: `${pos.top}rpx`,
|
|
||||||
transform: `scale(${pos.scale}) rotate(${pos.rotate}deg)`,
|
|
||||||
zIndex: 30 - Math.abs(cardPos - 2) * 10,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
// 选择卡片
|
|
||||||
selectCard(index) {
|
|
||||||
this.selectedIndex = index;
|
|
||||||
},
|
|
||||||
/** 当前叠卡在弧形布局中的槽位:2=正中主图(最大),1=中上「第二张」叠层,0最上… */
|
|
||||||
getCardStackPosition(index) {
|
|
||||||
return (index - this.selectedIndex + 2 + 5) % 5;
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 点击卡图区域:
|
|
||||||
* - 正中主图(槽位 2)→ 进入对应工艺创建页(光栅卡即进入已接入预览的 create)
|
|
||||||
* - 中上叠层(槽位 1,常为拍立得示意)在选中光栅时 → 同样进入光栅卡创建(与设计稿「点第二张进光栅」一致)
|
|
||||||
* - 其余叠层 → 仅切换选中
|
|
||||||
*/
|
|
||||||
onCardFrameTap(index) {
|
|
||||||
const card = this.currentCardList[index];
|
|
||||||
if (!card) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pos = this.getCardStackPosition(index);
|
|
||||||
// 只有中间位置的卡片点击才会进入创建页
|
|
||||||
// 其他位置点击只是切换选中(把卡片移到中间),再次点击中间卡片才进入
|
|
||||||
if (pos === 2) {
|
|
||||||
if (card.name === "光栅卡" || card.name === "镭射卡") {
|
|
||||||
const route = this.cardRoutes[card.name];
|
|
||||||
if (route) {
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `${route}?name=${encodeURIComponent(card.name)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
uni.showToast({
|
|
||||||
title: "激情开发中",
|
|
||||||
icon: "none",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.selectCard(index);
|
|
||||||
},
|
|
||||||
handleBack() {
|
|
||||||
uni.navigateBack();
|
|
||||||
},
|
|
||||||
scrollUp() {
|
|
||||||
let newIndex = this.selectedCategoryIndex - 1;
|
|
||||||
if (newIndex >= 0) {
|
|
||||||
this.selectCategory(newIndex);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollDown() {
|
|
||||||
let newIndex = this.selectedCategoryIndex + 1;
|
|
||||||
if (newIndex < this.categoryList.length) {
|
|
||||||
this.selectCategory(newIndex);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleSkip() {
|
|
||||||
const card = this.cardList[this.selectedIndex];
|
|
||||||
if (card.name === "光栅卡" || card.name === "镭射卡") {
|
|
||||||
const route = this.cardRoutes[card.name];
|
|
||||||
if (route) {
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `${route}?name=${encodeURIComponent(card.name)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
uni.showToast({
|
|
||||||
title: "激情开发中",
|
|
||||||
icon: "none",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onShow() {
|
onShow() {
|
||||||
try {
|
try {
|
||||||
uni.hideToast();
|
uni.hideToast();
|
||||||
uni.hideLoading();
|
uni.hideLoading();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 作为页面被直接打开(不走 mall.vue)的兜底入口
|
||||||
|
// 作为组件被 mall.vue 引入时,onLoad 不会触发,由 watch.type 接管
|
||||||
|
async onLoad(options) {
|
||||||
|
await this.loadConfig();
|
||||||
|
if (options && options.type) {
|
||||||
|
this.applyType(options.type);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
// 是否显示右侧分类菜单栏
|
// 是否显示右侧分类菜单栏
|
||||||
@ -254,124 +161,15 @@ export default {
|
|||||||
// - 作为页面被直接打开时,onLoad 会把它置为 false
|
// - 作为页面被直接打开时,onLoad 会把它置为 false
|
||||||
showMenu: true,
|
showMenu: true,
|
||||||
selectedCategoryIndex: 0,
|
selectedCategoryIndex: 0,
|
||||||
// 主Tab 的 type 值与 categoryList 索引的映射
|
|
||||||
// 与 square.vue / CastloveContent.vue 中 mainTabs 的 type 保持一致
|
|
||||||
categoryTypeMap: {
|
|
||||||
star_card: 0,
|
|
||||||
badge: 1,
|
|
||||||
poster: 2,
|
|
||||||
},
|
|
||||||
categoryList: [{ name: "星卡" }, { name: "吧唧" }, { name: "海报" }],
|
|
||||||
cardListMap: {
|
|
||||||
星卡: [
|
|
||||||
{
|
|
||||||
name: "镭射卡",
|
|
||||||
image: "/static/castlove/leisheka.png",
|
|
||||||
comingSoon: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "光栅卡",
|
|
||||||
image: "/static/castlove/guangshanka.png",
|
|
||||||
comingSoon: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
// ↓ 全部来自后端,含 route_path / route_params
|
||||||
name: "拍立得",
|
// [{ id, name, type_key, sort_order, crafts: [{ id, name, image_url, route_path, route_params, sort_order }] }]
|
||||||
image: "/static/castlove/pailide.png",
|
categories: [],
|
||||||
comingSoon: false,
|
loading: true,
|
||||||
},
|
loadError: "",
|
||||||
{
|
|
||||||
name: "开发中",
|
|
||||||
image: "/static/castlove/daikaifa.png",
|
|
||||||
comingSoon: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "撕拉片",
|
|
||||||
image: "/static/castlove/silapian.png",
|
|
||||||
comingSoon: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
吧唧: [
|
|
||||||
{
|
|
||||||
name: "超复古",
|
|
||||||
image: "/static/castlove/fugu.png",
|
|
||||||
comingSoon: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "卡通刺绣",
|
|
||||||
image: "/static/castlove/katongchixiu.png",
|
|
||||||
comingSoon: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "云母片",
|
|
||||||
image: "/static/castlove/yunmupian.png",
|
|
||||||
comingSoon: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "开发中",
|
|
||||||
image: "/static/castlove/daikaifa.png",
|
|
||||||
comingSoon: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
海报: [
|
|
||||||
{
|
|
||||||
name: "拼豆",
|
|
||||||
image: "/static/castlove/pindou.png",
|
|
||||||
comingSoon: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "极繁插画",
|
|
||||||
image: "/static/castlove/jinfanchahua.png",
|
|
||||||
comingSoon: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "街头拼贴",
|
|
||||||
image: "/static/castlove/jietoupintie.png",
|
|
||||||
comingSoon: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "开发中",
|
|
||||||
image: "/static/castlove/daikaifa.png",
|
|
||||||
comingSoon: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
cardList: [
|
|
||||||
{
|
|
||||||
name: "光栅卡",
|
|
||||||
image: "/static/castlove/guangshanka.png",
|
|
||||||
comingSoon: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "拍立得",
|
|
||||||
image: "/static/castlove/pailide.png",
|
|
||||||
comingSoon: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "镭射卡",
|
|
||||||
image: "/static/castlove/leisheka.png",
|
|
||||||
comingSoon: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "撕拉片",
|
|
||||||
image: "/static/castlove/silapian.png",
|
|
||||||
comingSoon: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "开发中",
|
|
||||||
image: "/static/castlove/daikaifa.png",
|
|
||||||
comingSoon: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
cardRoutes: {
|
|
||||||
光栅卡: "/pages/castlove/lenticular/lenticular-create",
|
|
||||||
拍立得: "/pages/castlove/create",
|
|
||||||
镭射卡: "/pages/castlove/create",
|
|
||||||
撕拉片: "/pages/castlove/create",
|
|
||||||
},
|
|
||||||
|
|
||||||
totalCard: 5,
|
// 原有交互/动效状态保留
|
||||||
selectedIndex: 1, // 默认第2张
|
selectedIndex: 1, // 默认第 2 张(切换分类时由 defaultSelectedIndex 动态重算)
|
||||||
touchStartY: 0,
|
touchStartY: 0,
|
||||||
dragOffset: 0,
|
dragOffset: 0,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
@ -379,57 +177,83 @@ export default {
|
|||||||
SWIPE_STEP: 100,
|
SWIPE_STEP: 100,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
currentCategoryName() {
|
currentCategoryName() {
|
||||||
return this.categoryList[this.selectedCategoryIndex]?.name || "";
|
return this.categories[this.selectedCategoryIndex]?.name || "";
|
||||||
},
|
},
|
||||||
currentCardList() {
|
currentCardList() {
|
||||||
const name = this.categoryList[this.selectedCategoryIndex]?.name;
|
return this.categories[this.selectedCategoryIndex]?.crafts || [];
|
||||||
return this.cardListMap[name] || this.cardList;
|
|
||||||
},
|
},
|
||||||
// 当前分类下的小类型卡片数量
|
|
||||||
// (与 cardListMap 中该类型数组的 length 一一对应,后续新增类型无需修改此处)
|
|
||||||
currentCardCount() {
|
currentCardCount() {
|
||||||
return this.currentCardList.length;
|
return this.currentCardList.length;
|
||||||
},
|
},
|
||||||
// 扇形中心索引(取整)
|
|
||||||
// 奇数 N:正中;偶数 N:稍偏下的中间位
|
|
||||||
currentCenterIndex() {
|
currentCenterIndex() {
|
||||||
return Math.floor(this.currentCardCount / 2);
|
return Math.floor(this.currentCardCount / 2);
|
||||||
},
|
},
|
||||||
// 各分类的初始 selectedIndex
|
// 切换分类时的默认选中卡片
|
||||||
// 与 data() 中的初始值语义保持一致,统一为"默认第 2 张"
|
// 原本固定 = 1(默认第 2 张),但卡片数 < 2 时会越界
|
||||||
// 后续如需让每个分类回到"各自的中心",可在此处按分类返回不同值
|
// 卡片 0 张 → 0;卡片 1 张 → 0;卡片 ≥ 2 张 → 1
|
||||||
|
// 后台动态删卡也安全:卡片数变化后重新计算
|
||||||
defaultSelectedIndex() {
|
defaultSelectedIndex() {
|
||||||
return 1;
|
return Math.min(1, Math.max(0, this.currentCardCount - 1));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
// 根据 type 字符串定位到对应分类索引
|
/**
|
||||||
applyType(type) {
|
* 拉取后端配置(进入页面 / 点击重试时调用)
|
||||||
if (!type) return;
|
* 强制鉴权(token 由 utils/api.js 统一注入)
|
||||||
const idx = this.categoryTypeMap[type];
|
*/
|
||||||
if (typeof idx === "number") {
|
async loadConfig() {
|
||||||
this.selectedCategoryIndex = idx;
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.loadError = "";
|
||||||
|
const res = await getCastloveConfigApi();
|
||||||
|
this.categories = (res && res.data && res.data.categories) || [];
|
||||||
|
} catch (e) {
|
||||||
|
this.loadError = e?.message || "配置加载失败";
|
||||||
|
this.categories = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 type 字符串定位到对应分类索引
|
||||||
|
* 在线 findIndex(不再依赖前端硬编码的 categoryTypeMap)
|
||||||
|
*/
|
||||||
|
applyType(type) {
|
||||||
|
if (!type) return;
|
||||||
|
const idx = this.categories.findIndex((c) => c.type_key === type);
|
||||||
|
if (idx >= 0) this.selectedCategoryIndex = idx;
|
||||||
|
// 找不到则保持 0,不报错
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 切换大分类 */
|
||||||
selectCategory(index) {
|
selectCategory(index) {
|
||||||
this.selectedCategoryIndex = index;
|
this.selectedCategoryIndex = index;
|
||||||
// 切换分类后,重置为该分类的初始状态(回到刚进入时的样子)
|
// 用计算属性而不是写死 1(防止卡片数 < 2 越界)
|
||||||
this.selectedIndex = this.defaultSelectedIndex;
|
this.selectedIndex = this.defaultSelectedIndex;
|
||||||
this.dragOffset = 0;
|
this.dragOffset = 0;
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 触摸开始 */
|
||||||
onTouchStart(e) {
|
onTouchStart(e) {
|
||||||
this.touchStartY = e.touches[0].clientY;
|
this.touchStartY = e.touches[0].clientY;
|
||||||
this.isDragging = true;
|
this.isDragging = true;
|
||||||
this.disableTransition = true;
|
this.disableTransition = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 触摸移动 */
|
||||||
onTouchMove(e) {
|
onTouchMove(e) {
|
||||||
if (!this.isDragging) return;
|
if (!this.isDragging) return;
|
||||||
const moveY = e.touches[0].clientY;
|
const moveY = e.touches[0].clientY;
|
||||||
this.dragOffset = moveY - this.touchStartY;
|
this.dragOffset = moveY - this.touchStartY;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 触摸结束 - 切换卡片 */
|
||||||
onTouchEnd() {
|
onTouchEnd() {
|
||||||
if (!this.isDragging) return;
|
if (!this.isDragging) return;
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
@ -445,7 +269,7 @@ export default {
|
|||||||
this.dragOffset = 0;
|
this.dragOffset = 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ====================== 核心修复:z-index + 层级 + 循环 ======================
|
// ====================== 核心修复:z-index + 层级 + 循环 ======================
|
||||||
// 返回当前分类对应的扇形位置
|
// 返回当前分类对应的扇形位置
|
||||||
// 规则:基础 5 个位置保持原样不动
|
// 规则:基础 5 个位置保持原样不动
|
||||||
// - N <= 5:取前 N 个
|
// - N <= 5:取前 N 个
|
||||||
@ -540,6 +364,7 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 当前叠卡在弧形布局中的槽位(2=正中,1=中上,0=最上...) */
|
||||||
getCardStackPosition(index) {
|
getCardStackPosition(index) {
|
||||||
const N = this.currentCardCount;
|
const N = this.currentCardCount;
|
||||||
if (N === 0) return 0;
|
if (N === 0) return 0;
|
||||||
@ -552,39 +377,91 @@ export default {
|
|||||||
return diff + centerInt;
|
return diff + centerInt;
|
||||||
},
|
},
|
||||||
|
|
||||||
onCardFrameTap(index) {
|
/**
|
||||||
const card = this.currentCardList[index];
|
* 点击卡图区域
|
||||||
|
* - 正中主图(槽位 ≈ 中心)→ 读 card.route_path / route_params 跳转
|
||||||
|
* - 其余叠层 → 仅切换选中(把卡片移到中间)
|
||||||
|
*
|
||||||
|
* 跳转规则:
|
||||||
|
* 1. route_path 为空/null → 弹 "激情开发中" toast,不跳转
|
||||||
|
* 2. route_path 非空 → 拼上 route_params 作为 query
|
||||||
|
* 目标页面必须在 pages.json 中已注册,否则 uni.navigateTo 失败 → 弹 "页面不存在" toast
|
||||||
|
*/
|
||||||
|
onCardFrameTap(card) {
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const pos = this.getCardStackPosition(index);
|
const pos = this.getCardStackPosition(this.currentCardList.indexOf(card));
|
||||||
if (Math.abs(pos - this.currentCenterIndex) < 0.2) {
|
// 只处理正中位置
|
||||||
if (card.name === "光栅卡") {
|
if (Math.abs(pos - this.currentCenterIndex) >= 0.2) {
|
||||||
uni.navigateTo({
|
this.selectedIndex = this.currentCardList.indexOf(card);
|
||||||
url:
|
|
||||||
this.cardRoutes["光栅卡"] +
|
|
||||||
"?name=" +
|
|
||||||
encodeURIComponent(card.name),
|
|
||||||
});
|
|
||||||
} else if (card.name === "镭射卡") {
|
|
||||||
uni.navigateTo({
|
|
||||||
url:
|
|
||||||
this.cardRoutes["镭射卡"] +
|
|
||||||
"?name=" +
|
|
||||||
encodeURIComponent(card.name),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
uni.showToast({ title: "激情开发中", icon: "none" });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.selectedIndex = index;
|
// 1. route_path 为空/null → toast
|
||||||
|
if (!card.route_path) {
|
||||||
|
uni.showToast({ title: "激情开发中", icon: "none" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 2. 拼 query
|
||||||
|
// const query = this.buildQueryString(card.route_params);
|
||||||
|
// const url = query ? `${card.route_path}?name=${encodeURIComponent(card.name)}` : card.route_path;
|
||||||
|
const url = `${card.route_path}?name=${encodeURIComponent(card.name)}`;
|
||||||
|
console.log(url)
|
||||||
|
uni.navigateTo({
|
||||||
|
url,
|
||||||
|
fail: (err) => {
|
||||||
|
// 路径写错 / pages.json 未注册 → 兜底 toast,不闪退
|
||||||
|
console.error("[castlove] navigateTo fail", card.route_path, err);
|
||||||
|
uni.showToast({ title: "页面不存在", icon: "none" });
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
scrollUp() {
|
/**
|
||||||
if (this.selectedCategoryIndex > 0)
|
* 把 route_params(JSON 字符串或对象)转成 query string
|
||||||
this.selectCategory(this.selectedCategoryIndex - 1);
|
* - null/undefined/空对象 → ""
|
||||||
|
* - 嵌套对象/数组已被后端兜底清空,这里再做一次防御性过滤
|
||||||
|
*/
|
||||||
|
buildQueryString(params) {
|
||||||
|
if (!params) return "";
|
||||||
|
// 后端统一返回 JSON 字符串;同时兼容前端 mock 或老代码传对象的情况
|
||||||
|
let obj = params;
|
||||||
|
if (typeof params === "string") {
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(params);
|
||||||
|
} catch (e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return "";
|
||||||
|
return Object.keys(obj)
|
||||||
|
.filter(
|
||||||
|
(k) =>
|
||||||
|
obj[k] !== undefined &&
|
||||||
|
obj[k] !== null &&
|
||||||
|
typeof obj[k] !== "object"
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(k) =>
|
||||||
|
`${encodeURIComponent(k)}=${encodeURIComponent(String(obj[k]))}`
|
||||||
|
)
|
||||||
|
.join("&");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 图片加载失败兜底 */
|
||||||
|
onImageError(e) {
|
||||||
|
console.warn("[craft-select] image load failed", e);
|
||||||
|
// 这里如果以后想换占位图,直接换 src
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 右侧菜单上箭头 */
|
||||||
|
scrollUp() {
|
||||||
|
if (this.selectedCategoryIndex > 0) {
|
||||||
|
this.selectCategory(this.selectedCategoryIndex - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 右侧菜单下箭头 */
|
||||||
scrollDown() {
|
scrollDown() {
|
||||||
if (this.selectedCategoryIndex < this.categoryList.length - 1) {
|
if (this.selectedCategoryIndex < this.categories.length - 1) {
|
||||||
this.selectCategory(this.selectedCategoryIndex + 1);
|
this.selectCategory(this.selectedCategoryIndex + 1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -649,6 +526,10 @@ export default {
|
|||||||
background-image: none;
|
background-image: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-frame--tappable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.card-image {
|
.card-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -684,13 +565,58 @@ export default {
|
|||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 5 个状态分支(loading / error / empty / 正常)=====
|
||||||
|
.loading-state {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 80%;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hint {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 右侧分类菜单(自适应高度)=====
|
||||||
.text-panel {
|
.text-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 30rpx;
|
right: 30rpx;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 200rpx;
|
width: 200rpx;
|
||||||
height: 392rpx;
|
min-height: 392rpx; // 保留 3 项时的视觉高度
|
||||||
|
max-height: 80vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -698,6 +624,7 @@ export default {
|
|||||||
background: url("/static/castlove/xiahualan.png") no-repeat center;
|
background: url("/static/castlove/xiahualan.png") no-repeat center;
|
||||||
background-size: 130%;
|
background-size: 130%;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
|
padding: 16rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrow-btn {
|
.arrow-btn {
|
||||||
@ -709,6 +636,11 @@ export default {
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.arrow-btn--disabled {
|
||||||
|
opacity: 0.25;
|
||||||
|
pointer-events: none; // 已禁用时不再触发点击
|
||||||
|
}
|
||||||
|
|
||||||
.arrow-icon {
|
.arrow-icon {
|
||||||
width: 48rpx;
|
width: 48rpx;
|
||||||
height: 48rpx;
|
height: 48rpx;
|
||||||
@ -718,32 +650,29 @@ export default {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0 20rpx;
|
padding: 0 20rpx;
|
||||||
|
// 超过 5 项时启用滚动(spec §6.7)
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-item {
|
.text-item {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 26rpx;
|
font-size: 28rpx; // 统一字号,不再写 font-large / font-mid
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 10rpx 20rpx;
|
padding: 14rpx 20rpx;
|
||||||
border-radius: 14rpx;
|
border-radius: 14rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
transition: font-size 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-item.active {
|
.text-item.active {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
font-size: 34rpx; // 选中项放大(spec §6.7)
|
||||||
background: url("/static/nft/dingbutubiao_liang.png") no-repeat center;
|
background: url("/static/nft/dingbutubiao_liang.png") no-repeat center;
|
||||||
background-size: 100% 100%;
|
background-size: 100% 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-large {
|
|
||||||
font-size: 34rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-mid {
|
|
||||||
font-size: 30rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|||||||
@ -996,6 +996,17 @@ async function dashboardRequest(endpoint, params = {}) {
|
|||||||
return request({ url: `${DASHBOARD_PREFIX}${endpoint}`, method: 'GET', data: params })
|
return request({ url: `${DASHBOARD_PREFIX}${endpoint}`, method: 'GET', data: params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 铸爱工艺配置 ====================
|
||||||
|
// 鉴权:由 gateway AuthMiddleware 强制 JWT
|
||||||
|
// 响应:{ categories: [{ id, name, type_key, sort_order, crafts: [{ id, name, image_url, route_path, route_params, sort_order }] }], version }
|
||||||
|
// 缓存:后端 5s 进程内缓存,运营改完最长 5s 端上生效
|
||||||
|
export function getCastloveConfigApi() {
|
||||||
|
return request({
|
||||||
|
url: '/api/v1/castlove/config',
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
getTodayOverview: (starId) => dashboardRequest('/today-overview', { star_id: starId }),
|
getTodayOverview: (starId) => dashboardRequest('/today-overview', { star_id: starId }),
|
||||||
get7DayIncomeCurve: (starId) => dashboardRequest('/income-curve', { star_id: starId }),
|
get7DayIncomeCurve: (starId) => dashboardRequest('/income-curve', { star_id: starId }),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user