feat: 铸造菜单改为动态配置

This commit is contained in:
zerosaturation 2026-06-09 12:38:12 +08:00
parent dc9f1a7b1e
commit dfb79af4b1
13 changed files with 1597 additions and 340 deletions

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

View File

@ -71,6 +71,11 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
return nil, err
}
castloveConfigCtrl, err := controller.NewCastloveConfigController(assetClient)
if err != nil {
return nil, err
}
activityCtrl, err := controller.NewActivityController(activityClient)
if err != nil {
return nil, err
@ -299,6 +304,15 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
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.Use(middleware.AuthMiddleware())

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

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

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

View 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);
}

View File

@ -20,6 +20,7 @@ import (
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
pbAsset "github.com/topfans/backend/pkg/proto/asset"
pbCastlove "github.com/topfans/backend/pkg/proto/castlove"
pbRanking "github.com/topfans/backend/pkg/proto/ranking"
pbUser "github.com/topfans/backend/pkg/proto/user"
assetClient "github.com/topfans/backend/services/assetService/client"
@ -115,6 +116,7 @@ func main() {
relationRepo := repository.NewAssetMaterialRelationRepository(database.GetDB())
mintCostRepo := repository.NewMintCostRepository()
userMintCountRepo := repository.NewUserMintCountRepository()
castloveConfigRepo := repository.NewCastloveConfigRepository(database.GetDB())
logger.Logger.Info("Repository layer initialized")
// 创建 Dubbo 客户端
@ -145,11 +147,13 @@ func main() {
assetLikeService := service.NewAssetLikeService(assetRepo, assetLikeRepo, database.GetDB(), assetLevelSvc)
rankingService := service.NewRankingService(rankingRepo, assetLikeRepo, userClient)
materialService := service.NewMaterialService(materialRepo, relationRepo)
castloveConfigService := service.NewCastloveConfigService(castloveConfigRepo)
logger.Logger.Info("Service layer initialized")
// 创建 Provider 层实例
assetProvider := provider.NewAssetProvider(assetService, mintService, assetLikeService, materialService)
rankingProvider := provider.NewRankingProvider(rankingService)
castloveConfigProvider := provider.NewCastloveConfigProvider(castloveConfigService)
logger.Logger.Info("Provider layer initialized")
// 启动赛季重置 Worker每小时检查一次
@ -185,6 +189,11 @@ func main() {
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 {
logger.Logger.Fatal(fmt.Sprintf("Failed to start Asset Service: %v", err))
@ -226,6 +235,9 @@ func autoMigrate() error {
&models.LaserCardTemplate{},
&models.LaserCardInstance{},
&models.LaserCardOperationLog{},
// 铸爱工艺配置(只读,后台直连写库;此处 AutoMigrate 仅做兜底,实际表结构由外部 SQL 维护)
&models.CastloveCategory{},
&models.CastloveCraft{},
}
for _, table := range tables {

View File

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

View File

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

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

View 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)`(L53L134)
- `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` L999L1007),统一了 mock 短路与 baseURL 前缀。
### 1.6 WebSocket 入口统一
`utils/socket/SocketManager.js` 通过 `getWebSocketBaseUrl()` 工厂函数拿到 baseURL(L48L49),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` | L19L47 | 独立 `laserRequest()`,自行注入 token、超时、状态码判断,文件内注释自承 "返回格式与 api.js 的 request() 一致" |
| `utils/laser-card/segmentationCloud.js` | L21L39 | `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` L431L452)
- 历史遗留:`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 |

View File

@ -12,10 +12,34 @@
@touchmove="onTouchMove"
@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
v-for="(card, index) in currentCardList"
:key="index"
:key="card.id"
class="card-item"
:class="{ 'no-transition': disableTransition }"
:style="getCardStyle(index)"
@ -23,14 +47,20 @@
<view
class="card-frame"
:class="{
'no-border': card.comingSoon,
'card-frame--tappable': !card.comingSoon,
'no-border': !card.route_path,
'card-frame--tappable': !!card.route_path,
}"
@tap.stop="onCardFrameTap(index)"
@tap.stop="onCardFrameTap(card)"
>
<image class="card-image" :src="card.image" mode="aspectFill" />
<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"
src="/static/castlove/jinqingqidai.png"
/>
@ -38,16 +68,18 @@
<text class="card-name">{{ card.name }}</text>
</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 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
class="arrow-icon"
src="/static/castlove/jiantou.png"
@ -56,20 +88,23 @@
</view>
<view class="text-list">
<view
v-for="(category, index) in categoryList"
:key="index"
v-for="(category, index) in categories"
:key="category.id"
class="text-item"
:class="{
active: selectedCategoryIndex === index,
'font-large': index === 1,
'font-mid': index === 0 || index === 2,
}"
:class="{ active: selectedCategoryIndex === index }"
@click="selectCategory(index)"
>
<text>{{ category.name }}</text>
</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" />
</view>
</view>
@ -78,6 +113,8 @@
</template>
<script>
import { getCastloveConfigApi } from "@/utils/api.js";
export default {
// mall.vue
// ( navigateTo , onLoad URL )
@ -89,164 +126,34 @@ export default {
},
},
watch: {
// , type
// immediate + handler , H5
// :( mall.vue ),onLoad
// + typeimmediate: true mount
type: {
immediate: true,
handler(val) {
async handler(val) {
// ,(findIndex categories )
await this.loadConfig();
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 5024
// 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() {
try {
uni.hideToast();
uni.hideLoading();
} catch (e) {}
},
// ( mall.vue)
// mall.vue ,onLoad , watch.type
async onLoad(options) {
await this.loadConfig();
if (options && options.type) {
this.applyType(options.type);
}
},
data() {
return {
//
@ -254,124 +161,15 @@ export default {
// - ,onLoad false
showMenu: true,
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,
},
{
name: "拍立得",
image: "/static/castlove/pailide.png",
comingSoon: false,
},
{
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",
},
// , route_path / route_params
// [{ id, name, type_key, sort_order, crafts: [{ id, name, image_url, route_path, route_params, sort_order }] }]
categories: [],
loading: true,
loadError: "",
totalCard: 5,
selectedIndex: 1, // 2
// /
selectedIndex: 1, // 2 ( defaultSelectedIndex )
touchStartY: 0,
dragOffset: 0,
isDragging: false,
@ -379,57 +177,83 @@ export default {
SWIPE_STEP: 100,
};
},
computed: {
currentCategoryName() {
return this.categoryList[this.selectedCategoryIndex]?.name || "";
return this.categories[this.selectedCategoryIndex]?.name || "";
},
currentCardList() {
const name = this.categoryList[this.selectedCategoryIndex]?.name;
return this.cardListMap[name] || this.cardList;
return this.categories[this.selectedCategoryIndex]?.crafts || [];
},
//
// ( cardListMap length ,)
currentCardCount() {
return this.currentCardList.length;
},
// ()
// N:; N:
currentCenterIndex() {
return Math.floor(this.currentCardCount / 2);
},
// selectedIndex
// data() ," 2 "
// "",
//
// = 1( 2 ), < 2
// 0 0; 1 0; 2 1
// :
defaultSelectedIndex() {
return 1;
return Math.min(1, Math.max(0, this.currentCardCount - 1));
},
},
methods: {
// type
applyType(type) {
if (!type) return;
const idx = this.categoryTypeMap[type];
if (typeof idx === "number") {
this.selectedCategoryIndex = idx;
/**
* 拉取后端配置(进入页面 / 点击重试时调用)
* 强制鉴权(token utils/api.js 统一注入)
*/
async loadConfig() {
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) {
this.selectedCategoryIndex = index;
// ,()
// 1( < 2 )
this.selectedIndex = this.defaultSelectedIndex;
this.dragOffset = 0;
this.isDragging = false;
},
/** 触摸开始 */
onTouchStart(e) {
this.touchStartY = e.touches[0].clientY;
this.isDragging = true;
this.disableTransition = true;
},
/** 触摸移动 */
onTouchMove(e) {
if (!this.isDragging) return;
const moveY = e.touches[0].clientY;
this.dragOffset = moveY - this.touchStartY;
},
/** 触摸结束 - 切换卡片 */
onTouchEnd() {
if (!this.isDragging) return;
this.isDragging = false;
@ -445,7 +269,7 @@ export default {
this.dragOffset = 0;
},
// ====================== z-index + + ======================
// ====================== :z-index + + ======================
//
// : 5
// - N <= 5: N
@ -540,6 +364,7 @@ export default {
};
},
/** 当前叠卡在弧形布局中的槽位(2=正中,1=中上,0=最上...) */
getCardStackPosition(index) {
const N = this.currentCardCount;
if (N === 0) return 0;
@ -552,39 +377,91 @@ export default {
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;
const pos = this.getCardStackPosition(index);
if (Math.abs(pos - this.currentCenterIndex) < 0.2) {
if (card.name === "光栅卡") {
uni.navigateTo({
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" });
}
const pos = this.getCardStackPosition(this.currentCardList.indexOf(card));
//
if (Math.abs(pos - this.currentCenterIndex) >= 0.2) {
this.selectedIndex = this.currentCardList.indexOf(card);
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)
this.selectCategory(this.selectedCategoryIndex - 1);
/**
* route_params(JSON 字符串或对象)转成 query string
* - 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() {
if (this.selectedCategoryIndex < this.categoryList.length - 1) {
if (this.selectedCategoryIndex < this.categories.length - 1) {
this.selectCategory(this.selectedCategoryIndex + 1);
}
},
@ -649,6 +526,10 @@ export default {
background-image: none;
}
.card-frame--tappable {
cursor: pointer;
}
.card-image {
width: 100%;
height: 100%;
@ -684,13 +565,58 @@ export default {
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 {
position: absolute;
right: 30rpx;
top: 50%;
transform: translateY(-50%);
width: 200rpx;
height: 392rpx;
min-height: 392rpx; // 3
max-height: 80vh;
display: flex;
flex-direction: column;
align-items: center;
@ -698,6 +624,7 @@ export default {
background: url("/static/castlove/xiahualan.png") no-repeat center;
background-size: 130%;
border-radius: 20rpx;
padding: 16rpx 0;
}
.arrow-btn {
@ -709,6 +636,11 @@ export default {
opacity: 0.8;
}
.arrow-btn--disabled {
opacity: 0.25;
pointer-events: none; //
}
.arrow-icon {
width: 48rpx;
height: 48rpx;
@ -718,32 +650,29 @@ export default {
display: flex;
flex-direction: column;
padding: 0 20rpx;
// 5 (spec §6.7)
max-height: 60vh;
overflow-y: auto;
}
.text-item {
color: #fff;
font-size: 26rpx;
font-size: 28rpx; // , font-large / font-mid
font-weight: 500;
padding: 10rpx 20rpx;
padding: 14rpx 20rpx;
border-radius: 14rpx;
display: flex;
justify-content: center;
transition: font-size 0.2s;
}
.text-item.active {
font-weight: bold;
font-size: 34rpx; // (spec §6.7)
background: url("/static/nft/dingbutubiao_liang.png") no-repeat center;
background-size: 100% 100%;
}
.font-large {
font-size: 34rpx;
}
.font-mid {
font-size: 30rpx;
}
.empty-state {
position: absolute;
top: 50%;

View File

@ -996,6 +996,17 @@ async function dashboardRequest(endpoint, 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 = {
getTodayOverview: (starId) => dashboardRequest('/today-overview', { star_id: starId }),
get7DayIncomeCurve: (starId) => dashboardRequest('/income-curve', { star_id: starId }),