feat: support merchant team roles and permissions
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
-- 商户自定义员工角色。role code 仅在所属商户内唯一,权限以逗号分隔保存。
|
||||||
|
CREATE TABLE IF NOT EXISTS merchant_roles (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ,
|
||||||
|
merchant_id BIGINT NOT NULL,
|
||||||
|
code VARCHAR(64) NOT NULL,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
permissions TEXT NOT NULL DEFAULT '',
|
||||||
|
status BIGINT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_merchant_role_code
|
||||||
|
ON merchant_roles (merchant_id, code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_merchant_roles_merchant_id
|
||||||
|
ON merchant_roles (merchant_id);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- 为已有商户补齐可编辑的默认团队角色。负责人仍是系统保留角色,不落库。
|
||||||
|
INSERT INTO merchant_roles (merchant_id, code, name, permissions, status)
|
||||||
|
SELECT merchants.id, roles.code, roles.name, roles.permissions, 1
|
||||||
|
FROM merchants
|
||||||
|
CROSS JOIN (
|
||||||
|
VALUES
|
||||||
|
('operator', '运营', 'api:manage,callbacks:manage,orders:manage,products:manage,wallet:view'),
|
||||||
|
('finance', '财务', 'recharge:manage,wallet:ledger,wallet:view'),
|
||||||
|
('support', '客服', 'orders:manage')
|
||||||
|
) AS roles(code, name, permissions)
|
||||||
|
ON CONFLICT (merchant_id, code) DO NOTHING;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -38,11 +39,21 @@ func (h *MerchantHandler) Current(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{
|
response.OK(c, gin.H{
|
||||||
"merchant": merchant,
|
"merchant": merchant,
|
||||||
"role": middleware.GetMerchantRole(c),
|
"role": middleware.GetMerchantRole(c),
|
||||||
|
"permissions": mapKeys(middleware.GetMerchantPermissions(c)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapKeys(values map[string]struct{}) []string {
|
||||||
|
keys := make([]string, 0, len(values))
|
||||||
|
for key := range values {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MerchantHandler) ListProducts(c *gin.Context) {
|
func (h *MerchantHandler) ListProducts(c *gin.Context) {
|
||||||
page, size := pageParams(c)
|
page, size := pageParams(c)
|
||||||
list, total, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), page, size, false)
|
list, total, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), page, size, false)
|
||||||
@@ -398,7 +409,10 @@ func (h *MerchantHandler) ListMembers(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type addMemberReq struct {
|
type addMemberReq struct {
|
||||||
UserID uint `json:"user_id" binding:"required"`
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
Role string `json:"role" binding:"required"`
|
Role string `json:"role" binding:"required"`
|
||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
}
|
}
|
||||||
@@ -406,11 +420,14 @@ type addMemberReq struct {
|
|||||||
func (h *MerchantHandler) AddCurrentMerchantMember(c *gin.Context) {
|
func (h *MerchantHandler) AddCurrentMerchantMember(c *gin.Context) {
|
||||||
var req addMemberReq
|
var req addMemberReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
response.BadRequest(c, "参数错误:user_id 与 role 必填")
|
response.BadRequest(c, "参数错误:角色必填")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
member, err := h.merchantSvc.AddMember(middleware.GetMerchantID(c), service.AddMemberInput{
|
member, err := h.merchantSvc.AddMember(middleware.GetMerchantID(c), service.AddMemberInput{
|
||||||
UserID: req.UserID,
|
UserID: req.UserID,
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
Nickname: req.Nickname,
|
||||||
Role: req.Role,
|
Role: req.Role,
|
||||||
IsDefault: req.IsDefault,
|
IsDefault: req.IsDefault,
|
||||||
}, middleware.GetUserID(c))
|
}, middleware.GetUserID(c))
|
||||||
@@ -421,6 +438,54 @@ func (h *MerchantHandler) AddCurrentMerchantMember(c *gin.Context) {
|
|||||||
response.OK(c, member)
|
response.OK(c, member)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *MerchantHandler) ListRoles(c *gin.Context) {
|
||||||
|
roles, err := h.merchantSvc.ListRoles(middleware.GetMerchantID(c))
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createMerchantRoleReq struct {
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MerchantHandler) CreateRole(c *gin.Context) {
|
||||||
|
var req createMerchantRoleReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "参数错误:角色编码和名称必填")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role, err := h.merchantSvc.CreateRole(middleware.GetMerchantID(c), service.MerchantRoleInput{
|
||||||
|
Code: req.Code, Name: req.Name, Permissions: req.Permissions,
|
||||||
|
}, middleware.GetUserID(c))
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MerchantHandler) UpdateRole(c *gin.Context) {
|
||||||
|
roleID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
var req createMerchantRoleReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "参数错误:角色名称必填")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role, err := h.merchantSvc.UpdateRole(middleware.GetMerchantID(c), uint(roleID), service.MerchantRoleInput{
|
||||||
|
Name: req.Name, Permissions: req.Permissions,
|
||||||
|
}, middleware.GetUserID(c))
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, role)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MerchantHandler) ListPlatformMerchants(c *gin.Context) {
|
func (h *MerchantHandler) ListPlatformMerchants(c *gin.Context) {
|
||||||
page, size := pageParams(c)
|
page, size := pageParams(c)
|
||||||
list, total, err := h.merchantSvc.ListMerchants(page, size)
|
list, total, err := h.merchantSvc.ListMerchants(page, size)
|
||||||
@@ -595,6 +660,9 @@ func (h *MerchantHandler) AddPlatformMerchantMember(c *gin.Context) {
|
|||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
member, err := h.merchantSvc.AddMember(uint(id), service.AddMemberInput{
|
member, err := h.merchantSvc.AddMember(uint(id), service.AddMemberInput{
|
||||||
UserID: req.UserID,
|
UserID: req.UserID,
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
Nickname: req.Nickname,
|
||||||
Role: req.Role,
|
Role: req.Role,
|
||||||
IsDefault: req.IsDefault,
|
IsDefault: req.IsDefault,
|
||||||
}, middleware.GetUserID(c))
|
}, middleware.GetUserID(c))
|
||||||
|
|||||||
@@ -19,10 +19,50 @@ func NewDashboardHandler(svc *service.FulfillmentService) *DashboardHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DashboardHandler) Dashboard(c *gin.Context) {
|
func (h *DashboardHandler) Dashboard(c *gin.Context) {
|
||||||
stats, err := h.svc.Dashboard(middleware.GetMerchantID(c), middleware.GetRole(c) == model.RoleAdmin)
|
isAdmin := middleware.GetRole(c) == model.RoleAdmin
|
||||||
|
stats, err := h.svc.Dashboard(middleware.GetMerchantID(c), isAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ServerError(c, err.Error())
|
response.ServerError(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !isAdmin {
|
||||||
|
permissions := middleware.GetMerchantPermissions(c)
|
||||||
|
if _, ok := permissions[model.PermissionProductsManage]; !ok {
|
||||||
|
stats.CatalogProductCount = 0
|
||||||
|
stats.ProductCount = 0
|
||||||
|
stats.ActiveProductCount = 0
|
||||||
|
}
|
||||||
|
if _, ok := permissions[model.PermissionMembersManage]; !ok {
|
||||||
|
stats.UserCount = 0
|
||||||
|
}
|
||||||
|
if _, ok := permissions[model.PermissionOrdersManage]; !ok {
|
||||||
|
stats.OrderCount = 0
|
||||||
|
stats.TodayOrderCount = 0
|
||||||
|
stats.PaidOrderCount = 0
|
||||||
|
stats.DeliveringOrderCount = 0
|
||||||
|
stats.DeliveredOrderCount = 0
|
||||||
|
stats.ShipFailedOrderCount = 0
|
||||||
|
stats.CancelledOrderCount = 0
|
||||||
|
}
|
||||||
|
if _, ok := permissions[model.PermissionWalletView]; !ok {
|
||||||
|
stats.TotalSales = 0
|
||||||
|
stats.TodaySales = 0
|
||||||
|
stats.TotalFees = 0
|
||||||
|
stats.TodayFees = 0
|
||||||
|
}
|
||||||
|
if _, ok := permissions[model.PermissionWalletView]; !ok {
|
||||||
|
stats.WalletAvailableBalance = 0
|
||||||
|
stats.WalletFrozenBalance = 0
|
||||||
|
}
|
||||||
|
if _, ok := permissions[model.PermissionAPIManage]; !ok {
|
||||||
|
stats.APIClientCount = 0
|
||||||
|
stats.ActiveAPIClientCount = 0
|
||||||
|
}
|
||||||
|
if _, ok := permissions[model.PermissionCallbacksManage]; !ok {
|
||||||
|
stats.CallbackSubscriptionCount = 0
|
||||||
|
stats.PendingCallbackCount = 0
|
||||||
|
stats.FailedCallbackCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
response.OK(c, stats)
|
response.OK(c, stats)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"affiliate_dash/internal/model"
|
||||||
"affiliate_dash/internal/pkg/jwt"
|
"affiliate_dash/internal/pkg/jwt"
|
||||||
"affiliate_dash/internal/pkg/response"
|
"affiliate_dash/internal/pkg/response"
|
||||||
"affiliate_dash/internal/service"
|
"affiliate_dash/internal/service"
|
||||||
@@ -11,10 +12,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CtxUserID = "user_id"
|
CtxUserID = "user_id"
|
||||||
CtxUsername = "username"
|
CtxUsername = "username"
|
||||||
CtxRole = "role"
|
CtxRole = "role"
|
||||||
CtxMerchantRole = "merchant_role"
|
CtxMerchantRole = "merchant_role"
|
||||||
|
CtxMerchantPermissions = "merchant_permissions"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Auth(jm *jwt.Manager) gin.HandlerFunc {
|
func Auth(jm *jwt.Manager) gin.HandlerFunc {
|
||||||
@@ -77,7 +79,22 @@ func GetRole(c *gin.Context) string {
|
|||||||
// 未指定时选择该账号的默认商户,确保旧后台继续落到“自营商户”。
|
// 未指定时选择该账号的默认商户,确保旧后台继续落到“自营商户”。
|
||||||
func Tenant(tenantSvc *service.TenantService) gin.HandlerFunc {
|
func Tenant(tenantSvc *service.TenantService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
member, err := tenantSvc.ResolveMember(GetUserID(c), c.GetHeader("X-Merchant-ID"))
|
merchantRef := c.GetHeader("X-Merchant-ID")
|
||||||
|
if GetRole(c) == model.RoleAdmin && merchantRef != "" {
|
||||||
|
merchant, err := tenantSvc.ResolveMerchantForAdmin(merchantRef)
|
||||||
|
if err != nil {
|
||||||
|
response.Forbidden(c, err.Error())
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(CtxMerchantID, merchant.ID)
|
||||||
|
c.Set(CtxMerchantRole, model.MemberRoleOwner)
|
||||||
|
permissions, _ := tenantSvc.MerchantRolePermissions(merchant.ID, model.MemberRoleOwner)
|
||||||
|
c.Set(CtxMerchantPermissions, permissions)
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
member, err := tenantSvc.ResolveMember(GetUserID(c), merchantRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Forbidden(c, err.Error())
|
response.Forbidden(c, err.Error())
|
||||||
c.Abort()
|
c.Abort()
|
||||||
@@ -85,6 +102,13 @@ func Tenant(tenantSvc *service.TenantService) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
c.Set(CtxMerchantID, member.MerchantID)
|
c.Set(CtxMerchantID, member.MerchantID)
|
||||||
c.Set(CtxMerchantRole, member.Role)
|
c.Set(CtxMerchantRole, member.Role)
|
||||||
|
permissions, err := tenantSvc.MerchantRolePermissions(member.MerchantID, member.Role)
|
||||||
|
if err != nil {
|
||||||
|
response.Forbidden(c, err.Error())
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(CtxMerchantPermissions, permissions)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +130,28 @@ func RequireMerchantRole(roles ...string) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequireMerchantPermissions 按当前商户的角色权限校验。保留角色通过兼容权限集计算,
|
||||||
|
// 自定义角色从 merchant_roles 读取,所有列出的权限均需具备。
|
||||||
|
func RequireMerchantPermissions(tenantSvc *service.TenantService, permissions ...string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
granted := GetMerchantPermissions(c)
|
||||||
|
for _, permission := range permissions {
|
||||||
|
if _, ok := granted[permission]; !ok {
|
||||||
|
response.Forbidden(c, "商户权限不足")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMerchantPermissions(c *gin.Context) map[string]struct{} {
|
||||||
|
value, _ := c.Get(CtxMerchantPermissions)
|
||||||
|
permissions, _ := value.(map[string]struct{})
|
||||||
|
return permissions
|
||||||
|
}
|
||||||
|
|
||||||
func GetMerchantRole(c *gin.Context) string {
|
func GetMerchantRole(c *gin.Context) string {
|
||||||
value, _ := c.Get(CtxMerchantRole)
|
value, _ := c.Get(CtxMerchantRole)
|
||||||
role, _ := value.(string)
|
role, _ := value.(string)
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ const (
|
|||||||
MemberRoleFinance = "finance"
|
MemberRoleFinance = "finance"
|
||||||
MemberRoleViewer = "viewer"
|
MemberRoleViewer = "viewer"
|
||||||
|
|
||||||
|
PermissionMembersManage = "members:manage"
|
||||||
|
PermissionProductsManage = "products:manage"
|
||||||
|
PermissionOrdersManage = "orders:manage"
|
||||||
|
PermissionWalletView = "wallet:view"
|
||||||
|
PermissionWalletLedger = "wallet:ledger"
|
||||||
|
PermissionRechargeManage = "recharge:manage"
|
||||||
|
PermissionAPIManage = "api:manage"
|
||||||
|
PermissionCallbacksManage = "callbacks:manage"
|
||||||
|
|
||||||
FeeTypeRate = "rate" // 手续费按百分比
|
FeeTypeRate = "rate" // 手续费按百分比
|
||||||
FeeTypeFixed = "fixed" // 手续费按固定金额
|
FeeTypeFixed = "fixed" // 手续费按固定金额
|
||||||
|
|
||||||
@@ -104,6 +113,18 @@ type MerchantMember struct {
|
|||||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MerchantRole 是商户内可配置的员工角色;负责人仍是系统保留角色。
|
||||||
|
type MerchantRole struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
MerchantID uint `gorm:"not null;uniqueIndex:idx_merchant_role_code;index" json:"merchant_id"`
|
||||||
|
Code string `gorm:"size:64;not null;uniqueIndex:idx_merchant_role_code" json:"code"`
|
||||||
|
Name string `gorm:"size:64;not null" json:"name"`
|
||||||
|
Permissions string `gorm:"type:text;not null;default:''" json:"permissions"`
|
||||||
|
Status int `gorm:"not null;default:1" json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
// APIClient 为一个商户的外部系统集成凭证。SecretCiphertext 仅保存加密后的密钥。
|
// APIClient 为一个商户的外部系统集成凭证。SecretCiphertext 仅保存加密后的密钥。
|
||||||
type APIClient struct {
|
type APIClient struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
|||||||
@@ -152,33 +152,36 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
merchant := auth.Group("/merchant")
|
merchant := auth.Group("/merchant")
|
||||||
{
|
{
|
||||||
merchant.GET("", h.Merchant.Current)
|
merchant.GET("", h.Merchant.Current)
|
||||||
merchant.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), h.Merchant.ListProducts)
|
merchant.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionProductsManage), h.Merchant.ListProducts)
|
||||||
merchant.PATCH("/products/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProductStatus)
|
merchant.PATCH("/products/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionProductsManage), h.Merchant.UpdateProductStatus)
|
||||||
merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), h.Merchant.ListOrders)
|
merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.ListOrders)
|
||||||
merchant.POST("/orders/manual", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateManualOrder)
|
merchant.POST("/orders/manual", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.CreateManualOrder)
|
||||||
merchant.POST("/orders/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateTestOrder)
|
merchant.POST("/orders/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.CreateTestOrder)
|
||||||
merchant.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.GetDeliveryLink)
|
merchant.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.GetDeliveryLink)
|
||||||
merchant.POST("/orders/:order_no/delivery-link/revoke", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.RevokeDeliveryLink)
|
merchant.POST("/orders/:order_no/delivery-link/revoke", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.RevokeDeliveryLink)
|
||||||
merchant.POST("/orders/:order_no/delivery-link/restore", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.RestoreDeliveryLink)
|
merchant.POST("/orders/:order_no/delivery-link/restore", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.RestoreDeliveryLink)
|
||||||
merchant.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Merchant.GetWallet)
|
merchant.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionWalletView), h.Merchant.GetWallet)
|
||||||
merchant.GET("/wallet/ledger", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.ListWalletLedger)
|
merchant.GET("/wallet/ledger", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionWalletLedger), h.Merchant.ListWalletLedger)
|
||||||
merchant.POST("/recharge/applications", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.CreateApplication)
|
merchant.POST("/recharge/applications", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.CreateApplication)
|
||||||
merchant.GET("/recharge/applications", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Recharge.ListApplications)
|
merchant.GET("/recharge/applications", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionWalletView), h.Recharge.ListApplications)
|
||||||
merchant.GET("/recharge/alert-config", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.GetAlertConfig)
|
merchant.GET("/recharge/alert-config", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.GetAlertConfig)
|
||||||
merchant.PUT("/recharge/alert-config", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.SaveAlertConfig)
|
merchant.PUT("/recharge/alert-config", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.SaveAlertConfig)
|
||||||
merchant.GET("/alert-channels", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.ListAlertChannels)
|
merchant.GET("/alert-channels", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.ListAlertChannels)
|
||||||
merchant.POST("/alert-channels", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.CreateAlertChannel)
|
merchant.POST("/alert-channels", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.CreateAlertChannel)
|
||||||
merchant.PUT("/alert-channels/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.UpdateAlertChannel)
|
merchant.PUT("/alert-channels/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.UpdateAlertChannel)
|
||||||
merchant.DELETE("/alert-channels/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.DeleteAlertChannel)
|
merchant.DELETE("/alert-channels/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.DeleteAlertChannel)
|
||||||
merchant.POST("/alert-channels/:id/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.TestAlertChannel)
|
merchant.POST("/alert-channels/:id/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.TestAlertChannel)
|
||||||
merchant.GET("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), h.Merchant.ListAPIClients)
|
merchant.GET("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionAPIManage), h.Merchant.ListAPIClients)
|
||||||
merchant.POST("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateAPIClient)
|
merchant.POST("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionAPIManage), h.Merchant.CreateAPIClient)
|
||||||
merchant.PATCH("/api-clients/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateAPIClientStatus)
|
merchant.PATCH("/api-clients/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionAPIManage), h.Merchant.UpdateAPIClientStatus)
|
||||||
merchant.DELETE("/api-clients/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.DeleteAPIClient)
|
merchant.DELETE("/api-clients/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionAPIManage), h.Merchant.DeleteAPIClient)
|
||||||
merchant.GET("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListCallbacks)
|
merchant.GET("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionCallbacksManage), h.Merchant.ListCallbacks)
|
||||||
merchant.POST("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateCallback)
|
merchant.POST("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionCallbacksManage), h.Merchant.CreateCallback)
|
||||||
merchant.GET("/members", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListMembers)
|
merchant.GET("/members", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.ListMembers)
|
||||||
merchant.POST("/members", middleware.RequireMerchantRole(model.MemberRoleOwner), h.Merchant.AddCurrentMerchantMember)
|
merchant.POST("/members", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.AddCurrentMerchantMember)
|
||||||
|
merchant.GET("/roles", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.ListRoles)
|
||||||
|
merchant.POST("/roles", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.CreateRole)
|
||||||
|
merchant.PATCH("/roles/:id", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.UpdateRole)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户 / 发货记录 / 平台商户(仅管理员)
|
// 用户 / 发货记录 / 平台商户(仅管理员)
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ func (s *MerchantService) CreateMerchant(in CreateMerchantInput, actorUserID uin
|
|||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := createDefaultMerchantRoles(tx, merchant.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := copyDefaultProducts(tx, merchant.ID); err != nil {
|
if err := copyDefaultProducts(tx, merchant.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,39 +3,201 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"affiliate_dash/internal/model"
|
"affiliate_dash/internal/model"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AddMemberInput struct {
|
type AddMemberInput struct {
|
||||||
UserID uint
|
UserID uint
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Nickname string
|
||||||
Role string
|
Role string
|
||||||
IsDefault bool
|
IsDefault bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MerchantRoleInput struct {
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
Permissions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var merchantRoleCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,63}$`)
|
||||||
|
|
||||||
|
func merchantPermissionsText(permissions []string) (string, error) {
|
||||||
|
allowed := map[string]struct{}{
|
||||||
|
model.PermissionProductsManage: {}, model.PermissionOrdersManage: {}, model.PermissionWalletView: {},
|
||||||
|
model.PermissionWalletLedger: {}, model.PermissionRechargeManage: {}, model.PermissionAPIManage: {},
|
||||||
|
model.PermissionCallbacksManage: {}, model.PermissionMembersManage: {},
|
||||||
|
}
|
||||||
|
set := make(map[string]struct{}, len(permissions))
|
||||||
|
for _, permission := range permissions {
|
||||||
|
permission = strings.TrimSpace(permission)
|
||||||
|
if _, ok := allowed[permission]; !ok {
|
||||||
|
return "", errors.New("包含无效的角色权限")
|
||||||
|
}
|
||||||
|
set[permission] = struct{}{}
|
||||||
|
}
|
||||||
|
items := make([]string, 0, len(set))
|
||||||
|
for permission := range set {
|
||||||
|
items = append(items, permission)
|
||||||
|
}
|
||||||
|
sort.Strings(items)
|
||||||
|
return strings.Join(items, ","), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MerchantService) ListRoles(merchantID uint) ([]model.MerchantRole, error) {
|
||||||
|
roles := builtinMerchantRoles(merchantID)
|
||||||
|
var custom []model.MerchantRole
|
||||||
|
if err := s.db.Where("merchant_id = ?", merchantID).Order("id ASC").Find(&custom).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(roles, custom...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func builtinMerchantRoles(merchantID uint) []model.MerchantRole {
|
||||||
|
permissions, _ := (&TenantService{}).MerchantRolePermissions(merchantID, model.MemberRoleOwner)
|
||||||
|
items := make([]string, 0, len(permissions))
|
||||||
|
for permission := range permissions {
|
||||||
|
items = append(items, permission)
|
||||||
|
}
|
||||||
|
sort.Strings(items)
|
||||||
|
return []model.MerchantRole{{MerchantID: merchantID, Code: model.MemberRoleOwner, Name: "负责人", Permissions: strings.Join(items, ","), Status: 1}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDefaultMerchantRoles(tx *gorm.DB, merchantID uint) error {
|
||||||
|
roles := []model.MerchantRole{
|
||||||
|
{MerchantID: merchantID, Code: model.MemberRoleOperator, Name: "运营", Permissions: strings.Join([]string{model.PermissionAPIManage, model.PermissionCallbacksManage, model.PermissionOrdersManage, model.PermissionProductsManage, model.PermissionWalletView}, ","), Status: 1},
|
||||||
|
{MerchantID: merchantID, Code: model.MemberRoleFinance, Name: "财务", Permissions: strings.Join([]string{model.PermissionRechargeManage, model.PermissionWalletLedger, model.PermissionWalletView}, ","), Status: 1},
|
||||||
|
{MerchantID: merchantID, Code: "support", Name: "客服", Permissions: model.PermissionOrdersManage, Status: 1},
|
||||||
|
}
|
||||||
|
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "merchant_id"}, {Name: "code"}}, DoNothing: true}).Create(&roles).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MerchantService) CreateRole(merchantID uint, in MerchantRoleInput, actorUserID uint) (*model.MerchantRole, error) {
|
||||||
|
in.Code = strings.ToLower(strings.TrimSpace(in.Code))
|
||||||
|
in.Name = strings.TrimSpace(in.Name)
|
||||||
|
if !merchantRoleCodePattern.MatchString(in.Code) {
|
||||||
|
return nil, errors.New("角色编码需为 2-64 位小写字母、数字或连字符")
|
||||||
|
}
|
||||||
|
if isReservedMerchantRoleCode(in.Code) {
|
||||||
|
return nil, errors.New("角色编码不能使用系统保留角色")
|
||||||
|
}
|
||||||
|
if in.Name == "" {
|
||||||
|
return nil, errors.New("角色名称不能为空")
|
||||||
|
}
|
||||||
|
permissions, err := merchantPermissionsText(in.Permissions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
role := &model.MerchantRole{MerchantID: merchantID, Code: in.Code, Name: in.Name, Permissions: permissions, Status: 1}
|
||||||
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var merchant model.Merchant
|
||||||
|
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
|
||||||
|
return errors.New("商户不存在或已禁用")
|
||||||
|
}
|
||||||
|
if err := tx.Create(role).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.role.create", "merchant_role", fmt.Sprint(role.ID), map[string]string{"code": role.Code})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "duplicate key") {
|
||||||
|
return nil, errors.New("角色编码已存在")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isReservedMerchantRoleCode(code string) bool {
|
||||||
|
switch code {
|
||||||
|
case model.MemberRoleOwner, model.MemberRoleViewer, model.MemberRoleOperator, model.MemberRoleFinance, "support":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MerchantService) UpdateRole(merchantID, roleID uint, in MerchantRoleInput, actorUserID uint) (*model.MerchantRole, error) {
|
||||||
|
in.Name = strings.TrimSpace(in.Name)
|
||||||
|
if in.Name == "" {
|
||||||
|
return nil, errors.New("角色名称不能为空")
|
||||||
|
}
|
||||||
|
permissions, err := merchantPermissionsText(in.Permissions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var role model.MerchantRole
|
||||||
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("id = ? AND merchant_id = ?", roleID, merchantID).First(&role).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errors.New("角色不存在")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&role).Updates(map[string]interface{}{"name": in.Name, "permissions": permissions}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.role.update", "merchant_role", fmt.Sprint(roleID), map[string]string{"code": role.Code})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &role, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUserID uint) (*model.MerchantMember, error) {
|
func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUserID uint) (*model.MerchantMember, error) {
|
||||||
if !isValidMemberRole(in.Role) {
|
in.Role = strings.TrimSpace(in.Role)
|
||||||
return nil, errors.New("无效的商户成员角色")
|
if in.Role == "" {
|
||||||
|
return nil, errors.New("商户成员角色不能为空")
|
||||||
}
|
}
|
||||||
member := &model.MerchantMember{
|
if in.UserID == 0 && strings.TrimSpace(in.Username) == "" {
|
||||||
MerchantID: merchantID,
|
return nil, errors.New("请选择已有账号或填写新员工账号")
|
||||||
UserID: in.UserID,
|
|
||||||
Role: in.Role,
|
|
||||||
Status: 1,
|
|
||||||
IsDefault: in.IsDefault,
|
|
||||||
}
|
}
|
||||||
|
member := &model.MerchantMember{MerchantID: merchantID, Role: in.Role, Status: 1, IsDefault: in.IsDefault}
|
||||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var merchant model.Merchant
|
var merchant model.Merchant
|
||||||
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
|
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
|
||||||
return errors.New("商户不存在或已禁用")
|
return errors.New("商户不存在或已禁用")
|
||||||
}
|
}
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil {
|
createdUser := false
|
||||||
return errors.New("用户不存在或已禁用")
|
if in.UserID != 0 {
|
||||||
|
if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil {
|
||||||
|
return errors.New("用户不存在或已禁用")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
username := strings.TrimSpace(in.Username)
|
||||||
|
if len(username) < 3 {
|
||||||
|
return errors.New("员工用户名至少 3 位")
|
||||||
|
}
|
||||||
|
if len(in.Password) < 6 {
|
||||||
|
return errors.New("员工密码至少 6 位")
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user = model.User{Username: username, PasswordHash: string(hash), Nickname: fallbackName(in.Nickname, username), Role: model.RoleMerchant, Status: 1}
|
||||||
|
if err := tx.Create(&user).Error; err != nil {
|
||||||
|
return errors.New("员工用户名已存在")
|
||||||
|
}
|
||||||
|
createdUser = true
|
||||||
}
|
}
|
||||||
|
if _, err := (&TenantService{db: tx}).MerchantRolePermissions(merchantID, in.Role); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
member.UserID = user.ID
|
||||||
|
// 新建员工仅属于当前商户,登录时应直接进入该商户。
|
||||||
|
member.IsDefault = in.IsDefault || createdUser
|
||||||
if err := tx.Clauses(clause.OnConflict{
|
if err := tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}},
|
Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}},
|
||||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||||
@@ -46,12 +208,12 @@ func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUse
|
|||||||
}).Create(member).Error; err != nil {
|
}).Create(member).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, in.UserID), map[string]string{"role": in.Role})
|
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, user.ID), map[string]string{"role": in.Role})
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.db.Where("merchant_id = ? AND user_id = ?", merchantID, in.UserID).First(member).Error; err != nil {
|
if err := s.db.Preload("User").Where("merchant_id = ? AND user_id = ?", merchantID, member.UserID).First(member).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return member, nil
|
return member, nil
|
||||||
|
|||||||
@@ -35,3 +35,97 @@ func TestCreateAPIClientEnforcesPerMerchantLimit(t *testing.T) {
|
|||||||
t.Fatalf("expected per-merchant limit error, got %v", err)
|
t.Fatalf("expected per-merchant limit error, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMerchantCustomRoleIsScopedAndCanCreateEmployee(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
merchantA := model.Merchant{Code: "team-merchant-a", Name: "团队商户 A", Status: model.MerchantStatusActive}
|
||||||
|
merchantB := model.Merchant{Code: "team-merchant-b", Name: "团队商户 B", Status: model.MerchantStatusActive}
|
||||||
|
actor := model.User{Username: "team-owner", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1}
|
||||||
|
if err := db.Create(&merchantA).Error; err != nil {
|
||||||
|
t.Fatalf("create merchant A: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&merchantB).Error; err != nil {
|
||||||
|
t.Fatalf("create merchant B: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&actor).Error; err != nil {
|
||||||
|
t.Fatalf("create actor: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewMerchantService(db, nil, NewTenantService(db))
|
||||||
|
role, err := svc.CreateRole(merchantA.ID, MerchantRoleInput{
|
||||||
|
Code: "after-sales",
|
||||||
|
Name: "客服",
|
||||||
|
Permissions: []string{model.PermissionOrdersManage},
|
||||||
|
}, actor.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create custom role: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
member, err := svc.AddMember(merchantA.ID, AddMemberInput{
|
||||||
|
Username: "merchant-a-support",
|
||||||
|
Password: "password123",
|
||||||
|
Nickname: "客服小李",
|
||||||
|
Role: role.Code,
|
||||||
|
}, actor.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create employee: %v", err)
|
||||||
|
}
|
||||||
|
if member.User == nil || member.User.Username != "merchant-a-support" || member.Role != "after-sales" {
|
||||||
|
t.Fatalf("unexpected member: %+v", member)
|
||||||
|
}
|
||||||
|
if !member.IsDefault {
|
||||||
|
t.Fatal("new employee should default to the merchant that created it")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.AddMember(merchantB.ID, AddMemberInput{
|
||||||
|
Username: "merchant-b-support",
|
||||||
|
Password: "password123",
|
||||||
|
Role: role.Code,
|
||||||
|
}, actor.ID); err == nil {
|
||||||
|
t.Fatal("expected merchant A role to be rejected for merchant B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultMerchantRolesCanBeEdited(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
merchant := model.Merchant{Code: "default-roles", Name: "默认角色商户", Status: model.MerchantStatusActive}
|
||||||
|
actor := model.User{Username: "default-role-owner", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1}
|
||||||
|
if err := db.Create(&merchant).Error; err != nil {
|
||||||
|
t.Fatalf("create merchant: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&actor).Error; err != nil {
|
||||||
|
t.Fatalf("create actor: %v", err)
|
||||||
|
}
|
||||||
|
if err := createDefaultMerchantRoles(db, merchant.ID); err != nil {
|
||||||
|
t.Fatalf("create default roles: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewMerchantService(db, nil, NewTenantService(db))
|
||||||
|
roles, err := svc.ListRoles(merchant.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list roles: %v", err)
|
||||||
|
}
|
||||||
|
var finance model.MerchantRole
|
||||||
|
var hasSupport bool
|
||||||
|
for _, role := range roles {
|
||||||
|
if role.Code == model.MemberRoleFinance {
|
||||||
|
finance = role
|
||||||
|
}
|
||||||
|
if role.Code == "support" && role.Name == "客服" {
|
||||||
|
hasSupport = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if finance.ID == 0 || !hasSupport {
|
||||||
|
t.Fatalf("expected editable finance and support roles, got %+v", roles)
|
||||||
|
}
|
||||||
|
updated, err := svc.UpdateRole(merchant.ID, finance.ID, MerchantRoleInput{
|
||||||
|
Name: "财务主管",
|
||||||
|
Permissions: []string{model.PermissionWalletView},
|
||||||
|
}, actor.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update finance role: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Name != "财务主管" || updated.Permissions != model.PermissionWalletView {
|
||||||
|
t.Fatalf("unexpected updated role: %+v", updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,27 @@ func (s *TenantService) ResolveMember(userID uint, merchantRef string) (*model.M
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveMerchantForAdmin 允许平台管理员显式进入任一启用商户的后台管理上下文。
|
||||||
|
func (s *TenantService) ResolveMerchantForAdmin(merchantRef string) (*model.Merchant, error) {
|
||||||
|
if strings.TrimSpace(merchantRef) == "" {
|
||||||
|
return nil, errors.New("请选择商户")
|
||||||
|
}
|
||||||
|
tx := s.db.Where("status = ?", model.MerchantStatusActive)
|
||||||
|
if id, err := strconv.ParseUint(merchantRef, 10, 64); err == nil {
|
||||||
|
tx = tx.Where("id = ?", uint(id))
|
||||||
|
} else {
|
||||||
|
tx = tx.Where("code = ?", merchantRef)
|
||||||
|
}
|
||||||
|
var merchant model.Merchant
|
||||||
|
if err := tx.First(&merchant).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("商户不存在或已禁用")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &merchant, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *TenantService) EnsureSelfMember(userID uint, role string, status int) error {
|
func (s *TenantService) EnsureSelfMember(userID uint, role string, status int) error {
|
||||||
var merchant model.Merchant
|
var merchant model.Merchant
|
||||||
if err := s.db.Where("code = ?", model.MerchantCodeSelfOperated).First(&merchant).Error; err != nil {
|
if err := s.db.Where("code = ?", model.MerchantCodeSelfOperated).First(&merchant).Error; err != nil {
|
||||||
@@ -79,13 +100,42 @@ func (s *TenantService) EnsureMember(merchantID, userID uint, role string, statu
|
|||||||
|
|
||||||
func isValidMemberRole(role string) bool {
|
func isValidMemberRole(role string) bool {
|
||||||
switch role {
|
switch role {
|
||||||
case model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance, model.MemberRoleViewer:
|
case model.MemberRoleOwner, model.MemberRoleViewer:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MerchantRolePermissions 返回保留角色或商户自定义角色的权限集合。
|
||||||
|
// 负责人始终拥有该商户的全部后台权限,避免被自定义角色配置锁死。
|
||||||
|
func (s *TenantService) MerchantRolePermissions(merchantID uint, role string) (map[string]struct{}, error) {
|
||||||
|
permissions := builtinMerchantRolePermissions(role)
|
||||||
|
if permissions != nil {
|
||||||
|
return permissions, nil
|
||||||
|
}
|
||||||
|
var merchantRole model.MerchantRole
|
||||||
|
if err := s.db.Where("merchant_id = ? AND code = ? AND status = ?", merchantID, role, 1).First(&merchantRole).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("商户角色不存在或已禁用")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ParseScopes(merchantRole.Permissions), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func builtinMerchantRolePermissions(role string) map[string]struct{} {
|
||||||
|
all := func(values ...string) map[string]struct{} { return ParseScopes(strings.Join(values, ",")) }
|
||||||
|
switch role {
|
||||||
|
case model.MemberRoleOwner:
|
||||||
|
return all(model.PermissionMembersManage, model.PermissionProductsManage, model.PermissionOrdersManage, model.PermissionWalletView, model.PermissionWalletLedger, model.PermissionRechargeManage, model.PermissionAPIManage, model.PermissionCallbacksManage)
|
||||||
|
case model.MemberRoleViewer:
|
||||||
|
return map[string]struct{}{}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func MemberCanManage(memberRole string) bool {
|
func MemberCanManage(memberRole string) bool {
|
||||||
return memberRole == model.MemberRoleOwner || memberRole == model.MemberRoleOperator
|
return memberRole == model.MemberRoleOwner || memberRole == model.MemberRoleOperator
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-12
@@ -1,5 +1,5 @@
|
|||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||||
import { ConfigProvider, App as AntdApp } from 'antd'
|
import { ConfigProvider, App as AntdApp, Spin } from 'antd'
|
||||||
import zhCN from 'antd/locale/zh_CN'
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
import { AuthProvider, useAuth } from './store/auth'
|
import { AuthProvider, useAuth } from './store/auth'
|
||||||
import MainLayout from './layouts/MainLayout'
|
import MainLayout from './layouts/MainLayout'
|
||||||
@@ -25,6 +25,35 @@ function AdminRoute({ children }: { children: ReactNode }) {
|
|||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tabPermissions: Record<string, string> = {
|
||||||
|
products: 'products:manage',
|
||||||
|
orders: 'orders:manage',
|
||||||
|
wallet: 'wallet:ledger',
|
||||||
|
api: 'api:manage',
|
||||||
|
callbacks: 'callbacks:manage',
|
||||||
|
members: 'members:manage',
|
||||||
|
}
|
||||||
|
|
||||||
|
function MerchantPermissionRoute({ permission, children }: { permission?: string; children: ReactNode }) {
|
||||||
|
const { isAdmin, merchantLoading, merchantPermissions } = useAuth()
|
||||||
|
if (isAdmin || !permission) return <>{children}</>
|
||||||
|
if (merchantLoading) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: '100px 0' }}><Spin size="large" /></div>
|
||||||
|
}
|
||||||
|
return merchantPermissions.includes(permission) ? <>{children}</> : <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MerchantCenterRoute() {
|
||||||
|
const location = useLocation()
|
||||||
|
const requestedTab = new URLSearchParams(location.search).get('tab') || 'products'
|
||||||
|
const tab = requestedTab in tabPermissions ? requestedTab : 'products'
|
||||||
|
return (
|
||||||
|
<MerchantPermissionRoute permission={tabPermissions[tab]}>
|
||||||
|
<MerchantCenter />
|
||||||
|
</MerchantPermissionRoute>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -39,15 +68,15 @@ function AppRoutes() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="merchant-center" element={<MerchantCenter />} />
|
<Route path="merchant-center" element={<MerchantCenterRoute />} />
|
||||||
<Route path="merchant-products" element={<MerchantCenter fixedTab="products" title="商品列表" />} />
|
<Route path="merchant-products" element={<MerchantPermissionRoute permission="products:manage"><MerchantCenter fixedTab="products" title="商品列表" /></MerchantPermissionRoute>} />
|
||||||
<Route path="merchant-orders" element={<MerchantCenter fixedTab="orders" title="发货订单" />} />
|
<Route path="merchant-orders" element={<MerchantPermissionRoute permission="orders:manage"><MerchantCenter fixedTab="orders" title="发货订单" /></MerchantPermissionRoute>} />
|
||||||
<Route path="merchant-wallet" element={<MerchantCenter fixedTab="wallet" title="积分明细" />} />
|
<Route path="merchant-wallet" element={<MerchantPermissionRoute permission="wallet:ledger"><MerchantCenter fixedTab="wallet" title="积分明细" /></MerchantPermissionRoute>} />
|
||||||
<Route path="merchant-recharge" element={<MerchantRecharge />} />
|
<Route path="merchant-recharge" element={<MerchantPermissionRoute permission="wallet:view"><MerchantRecharge /></MerchantPermissionRoute>} />
|
||||||
<Route path="merchant-alert-settings" element={<AlertSettings />} />
|
<Route path="merchant-alert-settings" element={<MerchantPermissionRoute permission="recharge:manage"><AlertSettings /></MerchantPermissionRoute>} />
|
||||||
<Route path="merchant-members" element={<MerchantCenter fixedTab="members" title="成员" />} />
|
<Route path="merchant-members" element={<MerchantPermissionRoute permission="members:manage"><MerchantCenter fixedTab="members" title="成员" /></MerchantPermissionRoute>} />
|
||||||
<Route path="merchant-callbacks" element={<MerchantCenter fixedTab="callbacks" title="回调" />} />
|
<Route path="merchant-callbacks" element={<MerchantPermissionRoute permission="callbacks:manage"><MerchantCenter fixedTab="callbacks" title="回调" /></MerchantPermissionRoute>} />
|
||||||
<Route path="merchant-api-keys" element={<MerchantCenter fixedTab="api" title="API 密钥" />} />
|
<Route path="merchant-api-keys" element={<MerchantPermissionRoute permission="api:manage"><MerchantCenter fixedTab="api" title="API 密钥" /></MerchantPermissionRoute>} />
|
||||||
<Route
|
<Route
|
||||||
path="platform-merchants"
|
path="platform-merchants"
|
||||||
element={
|
element={
|
||||||
@@ -56,7 +85,7 @@ function AppRoutes() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="open-api" element={<OpenApiDocs />} />
|
<Route path="open-api" element={<MerchantPermissionRoute permission="api:manage"><OpenApiDocs /></MerchantPermissionRoute>} />
|
||||||
<Route
|
<Route
|
||||||
path="api-debug"
|
path="api-debug"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
LowBalanceAlertConfig,
|
LowBalanceAlertConfig,
|
||||||
Merchant,
|
Merchant,
|
||||||
MerchantMember,
|
MerchantMember,
|
||||||
|
MerchantRole,
|
||||||
MerchantProduct,
|
MerchantProduct,
|
||||||
PageResult,
|
PageResult,
|
||||||
ProductCatalogItem,
|
ProductCatalogItem,
|
||||||
@@ -54,7 +55,7 @@ export const userApi = {
|
|||||||
|
|
||||||
export const merchantApi = {
|
export const merchantApi = {
|
||||||
current: () =>
|
current: () =>
|
||||||
request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: MerchantMember['role'] }),
|
request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: string; permissions: string[] }),
|
||||||
products: (params?: Record<string, unknown>) =>
|
products: (params?: Record<string, unknown>) =>
|
||||||
request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult<MerchantProduct>),
|
request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult<MerchantProduct>),
|
||||||
updateProductStatus: (id: number, status: MerchantProduct['status']) =>
|
updateProductStatus: (id: number, status: MerchantProduct['status']) =>
|
||||||
@@ -103,8 +104,13 @@ export const merchantApi = {
|
|||||||
request.post('/merchant/callbacks', data).then((r) => r.data.data as CallbackCredential),
|
request.post('/merchant/callbacks', data).then((r) => r.data.data as CallbackCredential),
|
||||||
members: () =>
|
members: () =>
|
||||||
request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]),
|
request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]),
|
||||||
addMember: (data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }) =>
|
addMember: (data: { user_id?: number; username?: string; password?: string; nickname?: string; role: string; is_default?: boolean }) =>
|
||||||
request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember),
|
request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember),
|
||||||
|
roles: () => request.get('/merchant/roles').then((r) => r.data.data as MerchantRole[]),
|
||||||
|
createRole: (data: { code: string; name: string; permissions: string[] }) =>
|
||||||
|
request.post('/merchant/roles', data).then((r) => r.data.data as MerchantRole),
|
||||||
|
updateRole: (id: number, data: { name: string; permissions: string[] }) =>
|
||||||
|
request.patch(`/merchant/roles/${id}`, data).then((r) => r.data.data as MerchantRole),
|
||||||
rechargeApplications: (params?: Record<string, unknown>) =>
|
rechargeApplications: (params?: Record<string, unknown>) =>
|
||||||
request.get('/merchant/recharge/applications', { params }).then((r) => r.data.data as PageResult<RechargeApplication>),
|
request.get('/merchant/recharge/applications', { params }).then((r) => r.data.data as PageResult<RechargeApplication>),
|
||||||
createRechargeApplication: (data: { amount_cny: number; vouchers: string[]; note?: string }) =>
|
createRechargeApplication: (data: { amount_cny: number; vouchers: string[]; note?: string }) =>
|
||||||
@@ -180,7 +186,7 @@ export const platformApi = {
|
|||||||
request.patch(`/platform/merchants/${id}`, data).then((r) => r.data.data),
|
request.patch(`/platform/merchants/${id}`, data).then((r) => r.data.data),
|
||||||
addMember: (
|
addMember: (
|
||||||
merchantId: number,
|
merchantId: number,
|
||||||
data: { user_id: number; role: MerchantMember['role']; is_default?: boolean },
|
data: { user_id?: number; username?: string; password?: string; nickname?: string; role: string; is_default?: boolean },
|
||||||
) => request.post(`/platform/merchants/${merchantId}/members`, data).then((r) => r.data.data as MerchantMember),
|
) => request.post(`/platform/merchants/${merchantId}/members`, data).then((r) => r.data.data as MerchantMember),
|
||||||
productCatalog: () =>
|
productCatalog: () =>
|
||||||
request.get('/platform/product-catalog').then((r) => r.data.data as ProductCatalogItem[]),
|
request.get('/platform/product-catalog').then((r) => r.data.data as ProductCatalogItem[]),
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ interface SidebarChild {
|
|||||||
label: string
|
label: string
|
||||||
path?: string
|
path?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
permission?: string
|
||||||
|
feature?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarSection {
|
interface SidebarSection {
|
||||||
@@ -112,38 +114,38 @@ const merchantSections: SidebarSection[] = [
|
|||||||
key: 'products',
|
key: 'products',
|
||||||
label: '商品管理',
|
label: '商品管理',
|
||||||
icon: <AppstoreOutlined />,
|
icon: <AppstoreOutlined />,
|
||||||
children: [{ key: 'merchant-products', label: '商品列表', path: '/merchant-products' }],
|
children: [{ key: 'merchant-products', label: '商品列表', path: '/merchant-products', permission: 'products:manage', feature: 'products' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'orders',
|
key: 'orders',
|
||||||
label: '订单管理',
|
label: '订单管理',
|
||||||
icon: <OrderedListOutlined />,
|
icon: <OrderedListOutlined />,
|
||||||
children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders' }],
|
children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders', permission: 'orders:manage', feature: 'orders' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'funds',
|
key: 'funds',
|
||||||
label: '积分管理',
|
label: '积分管理',
|
||||||
icon: <WalletOutlined />,
|
icon: <WalletOutlined />,
|
||||||
children: [
|
children: [
|
||||||
{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' },
|
{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet', permission: 'wallet:ledger', feature: 'wallet' },
|
||||||
{ key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' },
|
{ key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge', permission: 'wallet:view', feature: 'wallet' },
|
||||||
{ key: 'merchant-alert-settings', label: '余额告警', path: '/merchant-alert-settings' },
|
{ key: 'merchant-alert-settings', label: '余额告警', path: '/merchant-alert-settings', permission: 'recharge:manage', feature: 'wallet' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'staff',
|
key: 'staff',
|
||||||
label: '员工管理',
|
label: '员工管理',
|
||||||
icon: <TeamOutlined />,
|
icon: <TeamOutlined />,
|
||||||
children: [{ key: 'merchant-members', label: '成员管理', path: '/merchant-members' }],
|
children: [{ key: 'merchant-members', label: '成员管理', path: '/merchant-members', permission: 'members:manage' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'open-api',
|
key: 'open-api',
|
||||||
label: '开放 API',
|
label: '开放 API',
|
||||||
icon: <ApiOutlined />,
|
icon: <ApiOutlined />,
|
||||||
children: [
|
children: [
|
||||||
{ key: 'api-keys', label: 'API 密钥', path: '/merchant-api-keys' },
|
{ key: 'api-keys', label: 'API 密钥', path: '/merchant-api-keys', permission: 'api:manage', feature: 'api' },
|
||||||
{ key: 'api-callbacks', label: '回调订阅', path: '/merchant-callbacks' },
|
{ key: 'api-callbacks', label: '回调订阅', path: '/merchant-callbacks', permission: 'callbacks:manage', feature: 'callbacks' },
|
||||||
{ key: 'api-docs', label: '对接文档', path: '/open-api' },
|
{ key: 'api-docs', label: '对接文档', path: '/open-api', permission: 'api:manage', feature: 'api' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -204,12 +206,23 @@ export default function MainLayout() {
|
|||||||
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
||||||
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true'
|
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true'
|
||||||
})
|
})
|
||||||
const { user, logout, isAdmin } = useAuth()
|
const { user, logout, isAdmin, merchant, merchantPermissions } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isDocsPage = location.pathname.startsWith('/open-api')
|
const isDocsPage = location.pathname.startsWith('/open-api')
|
||||||
|
|
||||||
const sections = useMemo(() => (isAdmin ? adminSections : merchantSections), [isAdmin])
|
const sections = useMemo(() => {
|
||||||
|
if (isAdmin) return adminSections
|
||||||
|
const features = new Set((merchant?.features || '').split(/[\s,]+/).filter(Boolean))
|
||||||
|
return merchantSections.flatMap((section) => {
|
||||||
|
if (!section.children) return [section]
|
||||||
|
const children = section.children.filter((child) => (
|
||||||
|
(!child.permission || merchantPermissions.includes(child.permission))
|
||||||
|
&& (!child.feature || features.has(child.feature))
|
||||||
|
))
|
||||||
|
return children.length > 0 ? [{ ...section, children }] : []
|
||||||
|
})
|
||||||
|
}, [isAdmin, merchant?.features, merchantPermissions])
|
||||||
const selectedKey = getSelectedKey(location.pathname, location.search)
|
const selectedKey = getSelectedKey(location.pathname, location.search)
|
||||||
const [openKeys, setOpenKeys] = useState<string[]>(() => loadStoredOpenKeys(sections, selectedKey))
|
const [openKeys, setOpenKeys] = useState<string[]>(() => loadStoredOpenKeys(sections, selectedKey))
|
||||||
|
|
||||||
|
|||||||
@@ -103,8 +103,15 @@ function CustomStatusLine({
|
|||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const { user, isAdmin } = useAuth()
|
const { user, isAdmin, merchantPermissions } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const can = (permission: string) => isAdmin || merchantPermissions.includes(permission)
|
||||||
|
const canOrders = can('orders:manage')
|
||||||
|
const canProducts = can('products:manage')
|
||||||
|
const canMembers = can('members:manage')
|
||||||
|
const canWallet = can('wallet:view')
|
||||||
|
const canAPI = can('api:manage')
|
||||||
|
const canCallbacks = can('callbacks:manage')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dashboardApi
|
dashboardApi
|
||||||
@@ -144,12 +151,16 @@ export default function Dashboard() {
|
|||||||
欢迎回到 Skin Hub 游戏皮肤分销管理平台。以下是系统的最新业务状态与发货订单追踪数据。
|
欢迎回到 Skin Hub 游戏皮肤分销管理平台。以下是系统的最新业务状态与发货订单追踪数据。
|
||||||
</p>
|
</p>
|
||||||
<div className="dash-quick-launch">
|
<div className="dash-quick-launch">
|
||||||
<button className="dash-quick-btn" type="button" onClick={() => navigate('/merchant-orders')}>
|
{canOrders && (
|
||||||
<PlusOutlined /> 发货订单管理
|
<button className="dash-quick-btn" type="button" onClick={() => navigate('/merchant-orders')}>
|
||||||
</button>
|
<PlusOutlined /> 发货订单管理
|
||||||
<button className="dash-quick-btn" type="button" onClick={() => navigate('/merchant-wallet')}>
|
</button>
|
||||||
<WalletOutlined /> 积分流水查看
|
)}
|
||||||
</button>
|
{can('wallet:ledger') && (
|
||||||
|
<button className="dash-quick-btn" type="button" onClick={() => navigate('/merchant-wallet')}>
|
||||||
|
<WalletOutlined /> 积分流水查看
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<button className="dash-quick-btn" type="button" onClick={() => navigate('/platform-merchants')}>
|
<button className="dash-quick-btn" type="button" onClick={() => navigate('/platform-merchants')}>
|
||||||
@@ -166,42 +177,49 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 第一组:核心业务指标 */}
|
{(canProducts || canMembers || canOrders) && (
|
||||||
<Typography.Title level={5} style={{ marginBottom: 14, fontWeight: 700, color: '#1e293b' }}>
|
<>
|
||||||
基础数据指标
|
<Typography.Title level={5} style={{ marginBottom: 14, fontWeight: 700, color: '#1e293b' }}>
|
||||||
</Typography.Title>
|
业务概览
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
</Typography.Title>
|
||||||
<Col xs={24} sm={12} lg={8}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
<ModernMetricCard
|
{canProducts && (
|
||||||
title={stats?.scope === 'platform' ? '平台可售商品' : '商户上架商品'}
|
<Col xs={24} sm={12} lg={8}>
|
||||||
value={stats?.active_product_count ?? 0}
|
<ModernMetricCard
|
||||||
icon={<ShoppingOutlined />}
|
title={stats?.scope === 'platform' ? '平台可售商品' : '商户上架商品'}
|
||||||
iconTheme=""
|
value={stats?.active_product_count ?? 0}
|
||||||
footerText="活跃商品库"
|
icon={<ShoppingOutlined />}
|
||||||
footerValue="已上线"
|
footerText="活跃商品库"
|
||||||
/>
|
footerValue="已上线"
|
||||||
</Col>
|
/>
|
||||||
<Col xs={24} sm={12} lg={8}>
|
</Col>
|
||||||
<ModernMetricCard
|
)}
|
||||||
title={stats?.scope === 'platform' ? '平台启用商户' : '当前合作商户'}
|
{isAdmin && (
|
||||||
value={stats?.active_merchant_count ?? 0}
|
<Col xs={24} sm={12} lg={8}>
|
||||||
icon={<ShopOutlined />}
|
<ModernMetricCard
|
||||||
iconTheme="metric-card-icon--purple"
|
title="平台启用商户"
|
||||||
footerText="商户状态"
|
value={stats?.active_merchant_count ?? 0}
|
||||||
footerValue="正常运行"
|
icon={<ShopOutlined />}
|
||||||
/>
|
iconTheme="metric-card-icon--purple"
|
||||||
</Col>
|
footerText="商户状态"
|
||||||
<Col xs={24} sm={12} lg={8}>
|
footerValue="正常运行"
|
||||||
<ModernMetricCard
|
/>
|
||||||
title="平台成员账号"
|
</Col>
|
||||||
value={stats?.user_count ?? 0}
|
)}
|
||||||
icon={<TeamOutlined />}
|
{canMembers && (
|
||||||
iconTheme="metric-card-icon--emerald"
|
<Col xs={24} sm={12} lg={8}>
|
||||||
footerText="团队成员"
|
<ModernMetricCard
|
||||||
footerValue="活跃"
|
title={isAdmin ? '平台成员账号' : '团队成员'}
|
||||||
/>
|
value={stats?.user_count ?? 0}
|
||||||
</Col>
|
icon={<TeamOutlined />}
|
||||||
<Col xs={24} sm={12} lg={8}>
|
iconTheme="metric-card-icon--emerald"
|
||||||
|
footerText="团队成员"
|
||||||
|
footerValue="活跃"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{canOrders && <>
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<ModernMetricCard
|
<ModernMetricCard
|
||||||
title="累计订单总数"
|
title="累计订单总数"
|
||||||
value={stats?.order_count ?? 0}
|
value={stats?.order_count ?? 0}
|
||||||
@@ -211,7 +229,7 @@ export default function Dashboard() {
|
|||||||
footerValue={`${stats?.today_order_count ?? 0} 单`}
|
footerValue={`${stats?.today_order_count ?? 0} 单`}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={8}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<ModernMetricCard
|
<ModernMetricCard
|
||||||
title="今日订单总量"
|
title="今日订单总量"
|
||||||
value={stats?.today_order_count ?? 0}
|
value={stats?.today_order_count ?? 0}
|
||||||
@@ -221,7 +239,7 @@ export default function Dashboard() {
|
|||||||
footerValue={`${ratio(stats?.today_order_count ?? 0, stats?.order_count ?? 0)}%`}
|
footerValue={`${ratio(stats?.today_order_count ?? 0, stats?.order_count ?? 0)}%`}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={8}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<ModernMetricCard
|
<ModernMetricCard
|
||||||
title="待处理/待发货订单"
|
title="待处理/待发货订单"
|
||||||
value={stats?.paid_order_count ?? 0}
|
value={stats?.paid_order_count ?? 0}
|
||||||
@@ -231,14 +249,19 @@ export default function Dashboard() {
|
|||||||
footerValue={(stats?.paid_order_count ?? 0) > 0 ? '需处理' : '无积压'}
|
footerValue={(stats?.paid_order_count ?? 0) > 0 ? '需处理' : '无积压'}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</>}
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 第二组:积分与资金监控 */}
|
{canWallet && (
|
||||||
<Typography.Title level={5} style={{ marginBottom: 14, fontWeight: 700, color: '#1e293b' }}>
|
<>
|
||||||
积分变动与账户额度
|
<Typography.Title level={5} style={{ marginBottom: 14, fontWeight: 700, color: '#1e293b' }}>
|
||||||
</Typography.Title>
|
经营与账户
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
</Typography.Title>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
{canOrders && <>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<ModernMetricCard
|
<ModernMetricCard
|
||||||
title="累计成交积分"
|
title="累计成交积分"
|
||||||
value={stats?.total_sales ?? 0}
|
value={stats?.total_sales ?? 0}
|
||||||
@@ -247,7 +270,7 @@ export default function Dashboard() {
|
|||||||
iconTheme="metric-card-icon--emerald"
|
iconTheme="metric-card-icon--emerald"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<ModernMetricCard
|
<ModernMetricCard
|
||||||
title="今日成交积分"
|
title="今日成交积分"
|
||||||
value={stats?.today_sales ?? 0}
|
value={stats?.today_sales ?? 0}
|
||||||
@@ -256,7 +279,7 @@ export default function Dashboard() {
|
|||||||
iconTheme="metric-card-icon--amber"
|
iconTheme="metric-card-icon--amber"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<ModernMetricCard
|
<ModernMetricCard
|
||||||
title="平台扣费/手续费"
|
title="平台扣费/手续费"
|
||||||
value={stats?.total_fees ?? 0}
|
value={stats?.total_fees ?? 0}
|
||||||
@@ -265,7 +288,8 @@ export default function Dashboard() {
|
|||||||
iconTheme="metric-card-icon--purple"
|
iconTheme="metric-card-icon--purple"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
</>}
|
||||||
|
{canWallet && <Col xs={24} sm={12} lg={6}>
|
||||||
<ModernMetricCard
|
<ModernMetricCard
|
||||||
title="账户可用余额"
|
title="账户可用余额"
|
||||||
value={stats?.wallet_available_balance ?? 0}
|
value={stats?.wallet_available_balance ?? 0}
|
||||||
@@ -273,12 +297,13 @@ export default function Dashboard() {
|
|||||||
icon={<WalletOutlined />}
|
icon={<WalletOutlined />}
|
||||||
iconTheme=""
|
iconTheme=""
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>}
|
||||||
</Row>
|
</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 第三组:订单发货分布与 API 监控 */}
|
{(canOrders || canAPI || canCallbacks) && <Row gutter={[20, 20]}>
|
||||||
<Row gutter={[20, 20]}>
|
{canOrders && <Col xs={24} lg={canAPI || canCallbacks ? 14 : 24}>
|
||||||
<Col xs={24} lg={14}>
|
|
||||||
<Card title="发货订单状态占比分布" style={{ height: '100%' }}>
|
<Card title="发货订单状态占比分布" style={{ height: '100%' }}>
|
||||||
<CustomStatusLine
|
<CustomStatusLine
|
||||||
label="待发货 (paid)"
|
label="待发货 (paid)"
|
||||||
@@ -316,44 +341,44 @@ export default function Dashboard() {
|
|||||||
tagClass="status-tag--gray"
|
tagClass="status-tag--gray"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>}
|
||||||
<Col xs={24} lg={10}>
|
{(canAPI || canCallbacks) && <Col xs={24} lg={canOrders ? 10 : 24}>
|
||||||
<Card title="接口密钥与 Webhook 回调监控" style={{ height: '100%' }}>
|
<Card title="接口密钥与 Webhook 回调监控" style={{ height: '100%' }}>
|
||||||
<Row gutter={[16, 20]}>
|
<Row gutter={[16, 20]}>
|
||||||
<Col span={12}>
|
{canAPI && <Col span={12}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="API 密钥 (活跃/总量)"
|
title="API 密钥 (活跃/总量)"
|
||||||
value={stats?.active_api_client_count ?? 0}
|
value={stats?.active_api_client_count ?? 0}
|
||||||
prefix={<ApiOutlined style={{ color: '#2563eb' }} />}
|
prefix={<ApiOutlined style={{ color: '#2563eb' }} />}
|
||||||
suffix={`/ ${numberText(stats?.api_client_count)}`}
|
suffix={`/ ${numberText(stats?.api_client_count)}`}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>}
|
||||||
<Col span={12}>
|
{canCallbacks && <Col span={12}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="Webhook 回调订阅"
|
title="Webhook 回调订阅"
|
||||||
value={stats?.callback_subscription_count ?? 0}
|
value={stats?.callback_subscription_count ?? 0}
|
||||||
prefix={<CheckCircleOutlined style={{ color: '#16a34a' }} />}
|
prefix={<CheckCircleOutlined style={{ color: '#16a34a' }} />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>}
|
||||||
<Col span={12}>
|
{canCallbacks && <Col span={12}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="待投递回调队列"
|
title="待投递回调队列"
|
||||||
value={stats?.pending_callback_count ?? 0}
|
value={stats?.pending_callback_count ?? 0}
|
||||||
prefix={<ClockCircleOutlined style={{ color: '#d97706' }} />}
|
prefix={<ClockCircleOutlined style={{ color: '#d97706' }} />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>}
|
||||||
<Col span={12}>
|
{canCallbacks && <Col span={12}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="投递失败回调"
|
title="投递失败回调"
|
||||||
value={stats?.failed_callback_count ?? 0}
|
value={stats?.failed_callback_count ?? 0}
|
||||||
prefix={<CloseCircleOutlined style={{ color: '#dc2626' }} />}
|
prefix={<CloseCircleOutlined style={{ color: '#dc2626' }} />}
|
||||||
valueStyle={(stats?.failed_callback_count ?? 0) > 0 ? { color: '#dc2626' } : undefined}
|
valueStyle={(stats?.failed_callback_count ?? 0) > 0 ? { color: '#dc2626' } : undefined}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>}
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>}
|
||||||
</Row>
|
</Row>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
FulfillmentOrder,
|
FulfillmentOrder,
|
||||||
Merchant,
|
Merchant,
|
||||||
MerchantMember,
|
MerchantMember,
|
||||||
|
MerchantRole,
|
||||||
MerchantProduct,
|
MerchantProduct,
|
||||||
PageResult,
|
PageResult,
|
||||||
WalletAccount,
|
WalletAccount,
|
||||||
@@ -33,9 +34,9 @@ import {
|
|||||||
defaultCallbackFormValues,
|
defaultCallbackFormValues,
|
||||||
eventsToValue,
|
eventsToValue,
|
||||||
featuresToList,
|
featuresToList,
|
||||||
memberRoleOptions,
|
|
||||||
productOptionLabel,
|
productOptionLabel,
|
||||||
resolveEnabledTab,
|
resolveEnabledTab,
|
||||||
|
rolePermissionOptions,
|
||||||
roleText,
|
roleText,
|
||||||
scopeOptions,
|
scopeOptions,
|
||||||
tabFromSearch,
|
tabFromSearch,
|
||||||
@@ -58,6 +59,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
const routeTab = useMemo(() => fixedTab ?? tabFromSearch(location.search), [fixedTab, location.search])
|
const routeTab = useMemo(() => fixedTab ?? tabFromSearch(location.search), [fixedTab, location.search])
|
||||||
const [merchant, setMerchant] = useState<Merchant | null>(null)
|
const [merchant, setMerchant] = useState<Merchant | null>(null)
|
||||||
const [merchantRole, setMerchantRole] = useState<MerchantMember['role']>()
|
const [merchantRole, setMerchantRole] = useState<MerchantMember['role']>()
|
||||||
|
const [merchantPermissions, setMerchantPermissions] = useState<string[]>([])
|
||||||
const [activeTab, setActiveTab] = useState<MerchantCenterTab>(routeTab ?? 'products')
|
const [activeTab, setActiveTab] = useState<MerchantCenterTab>(routeTab ?? 'products')
|
||||||
const [products, setProducts] = useState<PageResult<MerchantProduct>>({ list: [], total: 0, page: 1, size: 10 })
|
const [products, setProducts] = useState<PageResult<MerchantProduct>>({ list: [], total: 0, page: 1, size: 10 })
|
||||||
const [orders, setOrders] = useState<PageResult<FulfillmentOrder>>({ list: [], total: 0, page: 1, size: 10 })
|
const [orders, setOrders] = useState<PageResult<FulfillmentOrder>>({ list: [], total: 0, page: 1, size: 10 })
|
||||||
@@ -67,11 +69,14 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
const [apiClients, setApiClients] = useState<ApiClient[]>([])
|
const [apiClients, setApiClients] = useState<ApiClient[]>([])
|
||||||
const [callback, setCallback] = useState<CallbackSubscription | null>(null)
|
const [callback, setCallback] = useState<CallbackSubscription | null>(null)
|
||||||
const [members, setMembers] = useState<MerchantMember[]>([])
|
const [members, setMembers] = useState<MerchantMember[]>([])
|
||||||
|
const [roles, setRoles] = useState<MerchantRole[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [apiClientOpen, setApiClientOpen] = useState(false)
|
const [apiClientOpen, setApiClientOpen] = useState(false)
|
||||||
const [apiCredential, setApiCredential] = useState<ApiCredential | null>(null)
|
const [apiCredential, setApiCredential] = useState<ApiCredential | null>(null)
|
||||||
const [callbackCredential, setCallbackCredential] = useState<CallbackCredential | null>(null)
|
const [callbackCredential, setCallbackCredential] = useState<CallbackCredential | null>(null)
|
||||||
const [memberOpen, setMemberOpen] = useState(false)
|
const [memberOpen, setMemberOpen] = useState(false)
|
||||||
|
const [roleOpen, setRoleOpen] = useState(false)
|
||||||
|
const [editingRole, setEditingRole] = useState<MerchantRole | null>(null)
|
||||||
const [manualOrderOpen, setManualOrderOpen] = useState(false)
|
const [manualOrderOpen, setManualOrderOpen] = useState(false)
|
||||||
const [manualOrderSubmitting, setManualOrderSubmitting] = useState(false)
|
const [manualOrderSubmitting, setManualOrderSubmitting] = useState(false)
|
||||||
const [manualOrderProducts, setManualOrderProducts] = useState<MerchantProduct[]>([])
|
const [manualOrderProducts, setManualOrderProducts] = useState<MerchantProduct[]>([])
|
||||||
@@ -80,10 +85,15 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
const [apiClientForm] = Form.useForm()
|
const [apiClientForm] = Form.useForm()
|
||||||
const [callbackForm] = Form.useForm()
|
const [callbackForm] = Form.useForm()
|
||||||
const [memberForm] = Form.useForm()
|
const [memberForm] = Form.useForm()
|
||||||
|
const [roleForm] = Form.useForm()
|
||||||
const [manualOrderForm] = Form.useForm()
|
const [manualOrderForm] = Form.useForm()
|
||||||
|
|
||||||
const canManage = merchantRole === 'owner' || merchantRole === 'operator'
|
const hasPermission = useCallback((permission: string) => merchantPermissions.includes(permission), [merchantPermissions])
|
||||||
const canFinance = merchantRole === 'owner' || merchantRole === 'finance'
|
const canManage = hasPermission('products:manage')
|
||||||
|
const canOrderManage = hasPermission('orders:manage')
|
||||||
|
const canAPIManage = hasPermission('api:manage')
|
||||||
|
const canCallbacksManage = hasPermission('callbacks:manage')
|
||||||
|
const canFinance = hasPermission('wallet:ledger')
|
||||||
const enabledFeatures = useMemo(() => new Set(featuresToList(merchant?.features)), [merchant?.features])
|
const enabledFeatures = useMemo(() => new Set(featuresToList(merchant?.features)), [merchant?.features])
|
||||||
const hasFeature = useCallback((feature: string) => !merchant || enabledFeatures.has(feature), [enabledFeatures, merchant])
|
const hasFeature = useCallback((feature: string) => !merchant || enabledFeatures.has(feature), [enabledFeatures, merchant])
|
||||||
const manualOrderProductOptions = useMemo(() => manualOrderProducts.map((item) => ({
|
const manualOrderProductOptions = useMemo(() => manualOrderProducts.map((item) => ({
|
||||||
@@ -136,6 +146,12 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
const data = await merchantApi.current()
|
const data = await merchantApi.current()
|
||||||
setMerchant(data.merchant)
|
setMerchant(data.merchant)
|
||||||
setMerchantRole(data.role)
|
setMerchantRole(data.role)
|
||||||
|
const nextPermissions = [...new Set(data.permissions || [])].sort()
|
||||||
|
setMerchantPermissions((current) => (
|
||||||
|
current.length === nextPermissions.length && current.every((value, index) => value === nextPermissions[index])
|
||||||
|
? current
|
||||||
|
: nextPermissions
|
||||||
|
))
|
||||||
return data
|
return data
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -152,26 +168,26 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
const loadWallet = useCallback(async (
|
const loadWallet = useCallback(async (
|
||||||
page = ledger.page,
|
page = ledger.page,
|
||||||
size = ledger.size,
|
size = ledger.size,
|
||||||
role = merchantRole,
|
permissions = merchantPermissions,
|
||||||
filters?: { reference_no?: string; type?: string },
|
filters?: { reference_no?: string; type?: string },
|
||||||
) => {
|
) => {
|
||||||
const walletData = await merchantApi.wallet()
|
const walletData = await merchantApi.wallet()
|
||||||
setWallet(walletData)
|
setWallet(walletData)
|
||||||
if (role === 'owner' || role === 'finance') {
|
if (permissions.includes('wallet:ledger')) {
|
||||||
const ledgerData = await merchantApi.ledger({ page, size, ...filters })
|
const ledgerData = await merchantApi.ledger({ page, size, ...filters })
|
||||||
setLedger(ledgerData)
|
setLedger(ledgerData)
|
||||||
} else {
|
} else {
|
||||||
setLedger({ list: [], total: 0, page, size })
|
setLedger({ list: [], total: 0, page, size })
|
||||||
}
|
}
|
||||||
}, [ledger.page, ledger.size, merchantRole])
|
}, [ledger.page, ledger.size, merchantPermissions])
|
||||||
|
|
||||||
const loadAPIClients = useCallback(async () => {
|
const loadAPIClients = useCallback(async () => {
|
||||||
const clientData = await merchantApi.apiClients()
|
const clientData = await merchantApi.apiClients()
|
||||||
setApiClients(clientData || [])
|
setApiClients(clientData || [])
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadCallbacks = useCallback(async (role = merchantRole) => {
|
const loadCallbacks = useCallback(async (permissions = merchantPermissions) => {
|
||||||
if (role !== 'owner' && role !== 'operator') {
|
if (!permissions.includes('callbacks:manage')) {
|
||||||
setCallback(null)
|
setCallback(null)
|
||||||
callbackForm.resetFields()
|
callbackForm.resetFields()
|
||||||
return
|
return
|
||||||
@@ -183,18 +199,20 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
events: eventsToValue(callbackData.events),
|
events: eventsToValue(callbackData.events),
|
||||||
status: callbackData.status,
|
status: callbackData.status,
|
||||||
} : defaultCallbackFormValues())
|
} : defaultCallbackFormValues())
|
||||||
}, [callbackForm, merchantRole])
|
}, [callbackForm, merchantPermissions])
|
||||||
|
|
||||||
const loadMembers = useCallback(async (role = merchantRole) => {
|
const loadMembers = useCallback(async (permissions = merchantPermissions) => {
|
||||||
if (role !== 'owner' && role !== 'operator') {
|
if (!permissions.includes('members:manage')) {
|
||||||
setMembers([])
|
setMembers([])
|
||||||
|
setRoles([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const memberData = await merchantApi.members()
|
const [memberData, roleData] = await Promise.all([merchantApi.members(), merchantApi.roles()])
|
||||||
setMembers(memberData || [])
|
setMembers(memberData || [])
|
||||||
}, [merchantRole])
|
setRoles(roleData || [])
|
||||||
|
}, [merchantPermissions])
|
||||||
|
|
||||||
const loadActiveTab = useCallback(async (role = merchantRole, tab = activeTab) => {
|
const loadActiveTab = useCallback(async (permissions = merchantPermissions, tab = activeTab) => {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case 'products':
|
case 'products':
|
||||||
await loadProducts()
|
await loadProducts()
|
||||||
@@ -203,19 +221,19 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
await loadOrders()
|
await loadOrders()
|
||||||
return
|
return
|
||||||
case 'wallet':
|
case 'wallet':
|
||||||
await loadWallet(undefined, undefined, role)
|
await loadWallet(undefined, undefined, permissions)
|
||||||
return
|
return
|
||||||
case 'api':
|
case 'api':
|
||||||
await loadAPIClients()
|
await loadAPIClients()
|
||||||
return
|
return
|
||||||
case 'callbacks':
|
case 'callbacks':
|
||||||
await loadCallbacks(role)
|
await loadCallbacks(permissions)
|
||||||
return
|
return
|
||||||
case 'members':
|
case 'members':
|
||||||
await loadMembers(role)
|
await loadMembers(permissions)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [activeTab, loadAPIClients, loadCallbacks, loadMembers, loadOrders, loadProducts, loadWallet, merchantRole])
|
}, [activeTab, loadAPIClients, loadCallbacks, loadMembers, loadOrders, loadProducts, loadWallet, merchantPermissions])
|
||||||
|
|
||||||
const loadAll = useCallback(async () => {
|
const loadAll = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -225,7 +243,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
if (!fixedTab && nextTab !== activeTab) {
|
if (!fixedTab && nextTab !== activeTab) {
|
||||||
setActiveTab(nextTab)
|
setActiveTab(nextTab)
|
||||||
}
|
}
|
||||||
await loadActiveTab(current.role, nextTab)
|
await loadActiveTab(current.permissions || [], nextTab)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '加载失败')
|
message.error(e instanceof Error ? e.message : '加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -254,7 +272,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
|
|
||||||
const handleWalletFilter = async () => {
|
const handleWalletFilter = async () => {
|
||||||
const values = await walletFilterForm.validateFields()
|
const values = await walletFilterForm.validateFields()
|
||||||
loadWallet(1, ledger.size, merchantRole, {
|
loadWallet(1, ledger.size, merchantPermissions, {
|
||||||
reference_no: values.reference_no || undefined,
|
reference_no: values.reference_no || undefined,
|
||||||
type: values.type || undefined,
|
type: values.type || undefined,
|
||||||
})
|
})
|
||||||
@@ -262,16 +280,16 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
|
|
||||||
const handleWalletFilterReset = () => {
|
const handleWalletFilterReset = () => {
|
||||||
walletFilterForm.resetFields()
|
walletFilterForm.resetFields()
|
||||||
loadWallet(1, ledger.size, merchantRole, {})
|
loadWallet(1, ledger.size, merchantPermissions, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLoadLedger = useCallback((page: number, size: number) => {
|
const handleLoadLedger = useCallback((page: number, size: number) => {
|
||||||
const values = walletFilterForm.getFieldsValue()
|
const values = walletFilterForm.getFieldsValue()
|
||||||
loadWallet(page, size, merchantRole, {
|
loadWallet(page, size, merchantPermissions, {
|
||||||
reference_no: values.reference_no || undefined,
|
reference_no: values.reference_no || undefined,
|
||||||
type: values.type || undefined,
|
type: values.type || undefined,
|
||||||
})
|
})
|
||||||
}, [loadWallet, merchantRole, walletFilterForm])
|
}, [loadWallet, merchantPermissions, walletFilterForm])
|
||||||
|
|
||||||
const newManualClientOrderNo = () => {
|
const newManualClientOrderNo = () => {
|
||||||
const suffix = window.crypto?.randomUUID?.().replaceAll('-', '') || `${Date.now()}${Math.random().toString(36).slice(2)}`
|
const suffix = window.crypto?.randomUUID?.().replaceAll('-', '') || `${Date.now()}${Math.random().toString(36).slice(2)}`
|
||||||
@@ -389,9 +407,10 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
const values = await memberForm.validateFields()
|
const values = await memberForm.validateFields()
|
||||||
try {
|
try {
|
||||||
await merchantApi.addMember({
|
await merchantApi.addMember({
|
||||||
user_id: values.user_id,
|
username: values.username,
|
||||||
|
password: values.password,
|
||||||
|
nickname: values.nickname,
|
||||||
role: values.role,
|
role: values.role,
|
||||||
is_default: values.is_default,
|
|
||||||
})
|
})
|
||||||
setMemberOpen(false)
|
setMemberOpen(false)
|
||||||
message.success('成员已添加')
|
message.success('成员已添加')
|
||||||
@@ -401,6 +420,23 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const submitRole = async () => {
|
||||||
|
const values = await roleForm.validateFields()
|
||||||
|
try {
|
||||||
|
if (editingRole) {
|
||||||
|
await merchantApi.updateRole(editingRole.id, { name: values.name, permissions: values.permissions || [] })
|
||||||
|
} else {
|
||||||
|
await merchantApi.createRole({ ...values, permissions: values.permissions || [] })
|
||||||
|
}
|
||||||
|
setRoleOpen(false)
|
||||||
|
setEditingRole(null)
|
||||||
|
message.success(editingRole ? '角色已更新' : '角色已创建')
|
||||||
|
loadMembers()
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '创建失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleAPIClient = async (record: ApiClient) => {
|
const toggleAPIClient = async (record: ApiClient) => {
|
||||||
try {
|
try {
|
||||||
await merchantApi.updateApiClientStatus(record.id, record.status === 'active' ? 'disabled' : 'active')
|
await merchantApi.updateApiClientStatus(record.id, record.status === 'active' ? 'disabled' : 'active')
|
||||||
@@ -426,19 +462,39 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
<ProductsTab loading={loading} products={products} canManage={canManage} onToggleProduct={openProductToggle} onLoadProducts={loadProducts} />
|
<ProductsTab loading={loading} products={products} canManage={canManage} onToggleProduct={openProductToggle} onLoadProducts={loadProducts} />
|
||||||
) },
|
) },
|
||||||
{ key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: (
|
{ key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: (
|
||||||
<OrdersTab loading={loading} orders={orders} orderSource={orderSource} canManage={canManage} onCreateManualOrder={openManualOrderCreate} onOrderSourceChange={changeOrderSource} onLoadOrders={loadOrders} onCopyLink={copyDeliveryLink} onOpenLink={openDeliveryLink} onRevokeLink={revokeDeliveryLink} onRestoreLink={restoreDeliveryLink} />
|
<OrdersTab loading={loading} orders={orders} orderSource={orderSource} canManage={canOrderManage} onCreateManualOrder={openManualOrderCreate} onOrderSourceChange={changeOrderSource} onLoadOrders={loadOrders} onCopyLink={copyDeliveryLink} onOpenLink={openDeliveryLink} onRevokeLink={revokeDeliveryLink} onRestoreLink={restoreDeliveryLink} />
|
||||||
) },
|
) },
|
||||||
{ key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: (
|
{ key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: (
|
||||||
<WalletTab loading={loading} wallet={wallet} canFinance={canFinance} ledger={ledger} form={walletFilterForm} onFilter={handleWalletFilter} onResetFilter={handleWalletFilterReset} onLoadLedger={handleLoadLedger} />
|
<WalletTab loading={loading} wallet={wallet} canFinance={canFinance} ledger={ledger} form={walletFilterForm} onFilter={handleWalletFilter} onResetFilter={handleWalletFilterReset} onLoadLedger={handleLoadLedger} />
|
||||||
) },
|
) },
|
||||||
{ key: 'api', label: 'API 密钥', disabled: !hasFeature('api'), children: (
|
{ key: 'api', label: 'API 密钥', disabled: !hasFeature('api'), children: (
|
||||||
<ApiKeysTab loading={loading} clients={apiClients} clientMax={apiClientMax} isAdmin={isAdmin} canManage={canManage} form={apiClientForm} onOpenCreate={() => setApiClientOpen(true)} onNavigateDocs={() => navigate('/open-api')} onNavigateDebug={() => navigate('/api-debug')} onToggleClient={toggleAPIClient} onDeleteClient={deleteAPIClient} />
|
<ApiKeysTab loading={loading} clients={apiClients} clientMax={apiClientMax} isAdmin={isAdmin} canManage={canAPIManage} form={apiClientForm} onOpenCreate={() => setApiClientOpen(true)} onNavigateDocs={() => navigate('/open-api')} onNavigateDebug={() => navigate('/api-debug')} onToggleClient={toggleAPIClient} onDeleteClient={deleteAPIClient} />
|
||||||
) },
|
) },
|
||||||
{ key: 'callbacks', label: '回调', disabled: !hasFeature('callbacks'), children: (
|
{ key: 'callbacks', label: '回调', disabled: !hasFeature('callbacks'), children: (
|
||||||
<CallbacksTab canManage={canManage} callback={callback} form={callbackForm} onSubmit={submitCallback} onRotateSecret={rotateCallbackSecret} />
|
<CallbacksTab canManage={canCallbacksManage} callback={callback} form={callbackForm} onSubmit={submitCallback} onRotateSecret={rotateCallbackSecret} />
|
||||||
) },
|
) },
|
||||||
{ key: 'members', label: '成员', children: (
|
{ key: 'members', label: '成员', children: (
|
||||||
<MembersTab loading={loading} members={members} isOwner={merchantRole === 'owner'} form={memberForm} onOpenAdd={() => setMemberOpen(true)} />
|
<MembersTab
|
||||||
|
loading={loading}
|
||||||
|
members={members}
|
||||||
|
roles={roles}
|
||||||
|
canManage={hasPermission('members:manage')}
|
||||||
|
form={memberForm}
|
||||||
|
onOpenAdd={() => setMemberOpen(true)}
|
||||||
|
onOpenCreateRole={() => {
|
||||||
|
setEditingRole(null)
|
||||||
|
setRoleOpen(true)
|
||||||
|
}}
|
||||||
|
onEditRole={(role) => {
|
||||||
|
setEditingRole(role)
|
||||||
|
roleForm.setFieldsValue({
|
||||||
|
name: role.name,
|
||||||
|
code: role.code,
|
||||||
|
permissions: role.permissions.split(',').filter(Boolean),
|
||||||
|
})
|
||||||
|
setRoleOpen(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) },
|
) },
|
||||||
]
|
]
|
||||||
const fixedTabContent = tabItems.find((item) => item.key === fixedTab)?.children
|
const fixedTabContent = tabItems.find((item) => item.key === fixedTab)?.children
|
||||||
@@ -541,17 +597,28 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
{callbackCredential && <SecretBlock secret={callbackCredential.secret} />}
|
{callbackCredential && <SecretBlock secret={callbackCredential.secret} />}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal title="添加成员" open={memberOpen} onOk={submitMember} onCancel={() => setMemberOpen(false)} destroyOnClose>
|
<Modal title="添加员工" open={memberOpen} onOk={submitMember} onCancel={() => setMemberOpen(false)} destroyOnClose>
|
||||||
<Form form={memberForm} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={memberForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
<Form.Item name="user_id" label="用户 ID" rules={[{ required: true }]}>
|
<Form.Item name="username" label="员工用户名" rules={[{ required: true, min: 3, message: '请输入至少 3 位用户名' }]}>
|
||||||
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
<Input maxLength={64} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
|
<Form.Item name="password" label="初始密码" rules={[{ required: true, min: 6, message: '密码至少 6 位' }]}>
|
||||||
<Select options={memberRoleOptions} />
|
<Input.Password maxLength={128} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="is_default" label="默认商户">
|
<Form.Item name="nickname" label="员工姓名">
|
||||||
<Select options={[{ value: true, label: '是' }, { value: false, label: '否' }]} />
|
<Input maxLength={64} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="role" label="团队角色" rules={[{ required: true }]}>
|
||||||
|
<Select options={roles.map((item) => ({ value: item.code, label: item.name }))} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal title={editingRole ? `编辑角色:${editingRole.name}` : '新建团队角色'} open={roleOpen} onOk={submitRole} onCancel={() => { setRoleOpen(false); setEditingRole(null) }} destroyOnClose>
|
||||||
|
<Form form={roleForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item name="name" label="角色名称" rules={[{ required: true }]}><Input maxLength={64} placeholder="例如:客服" /></Form.Item>
|
||||||
|
{!editingRole && <Form.Item name="code" label="角色编码" rules={[{ required: true, pattern: /^[a-z0-9][a-z0-9-]{1,63}$/, message: '使用 2-64 位小写字母、数字或连字符' }]}><Input maxLength={64} placeholder="例如:support" /></Form.Item>}
|
||||||
|
<Form.Item name="permissions" label="权限"><Select mode="multiple" options={rolePermissionOptions} /></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -30,6 +31,7 @@ const featureOptions = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function PlatformMerchants() {
|
export default function PlatformMerchants() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [data, setData] = useState<PageResult<Merchant>>({ list: [], total: 0, page: 1, size: 10 })
|
const [data, setData] = useState<PageResult<Merchant>>({ list: [], total: 0, page: 1, size: 10 })
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
@@ -272,7 +274,7 @@ export default function PlatformMerchants() {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: 180,
|
width: 240,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Space size={0}>
|
<Space size={0}>
|
||||||
@@ -284,6 +286,16 @@ export default function PlatformMerchants() {
|
|||||||
>
|
>
|
||||||
分配商品
|
分配商品
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.setItem('merchant_id', String(record.id))
|
||||||
|
navigate('/merchant-members')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
成员
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
CallbackSubscription,
|
CallbackSubscription,
|
||||||
FulfillmentOrder,
|
FulfillmentOrder,
|
||||||
MerchantMember,
|
MerchantMember,
|
||||||
|
MerchantRole,
|
||||||
MerchantProduct,
|
MerchantProduct,
|
||||||
PageResult,
|
PageResult,
|
||||||
WalletAccount,
|
WalletAccount,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
money,
|
money,
|
||||||
orderStatusMap,
|
orderStatusMap,
|
||||||
pageConfig,
|
pageConfig,
|
||||||
|
rolePermissionText,
|
||||||
roleText,
|
roleText,
|
||||||
scopeLabel,
|
scopeLabel,
|
||||||
} from './merchantCenterUtils'
|
} from './merchantCenterUtils'
|
||||||
@@ -660,20 +662,27 @@ export function CallbacksTab({
|
|||||||
export function MembersTab({
|
export function MembersTab({
|
||||||
loading,
|
loading,
|
||||||
members,
|
members,
|
||||||
isOwner,
|
roles,
|
||||||
|
canManage,
|
||||||
form,
|
form,
|
||||||
onOpenAdd,
|
onOpenAdd,
|
||||||
|
onOpenCreateRole,
|
||||||
|
onEditRole,
|
||||||
}: {
|
}: {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
members: MerchantMember[]
|
members: MerchantMember[]
|
||||||
isOwner: boolean
|
roles: MerchantRole[]
|
||||||
|
canManage: boolean
|
||||||
form: FormInstance
|
form: FormInstance
|
||||||
onOpenAdd: () => void
|
onOpenAdd: () => void
|
||||||
|
onOpenCreateRole: () => void
|
||||||
|
onEditRole: (role: MerchantRole) => void
|
||||||
}) {
|
}) {
|
||||||
|
const roleNames = new Map(roles.map((item) => [item.code, item.name]))
|
||||||
const memberColumns: ColumnsType<MerchantMember> = [
|
const memberColumns: ColumnsType<MerchantMember> = [
|
||||||
{ title: '用户', dataIndex: ['user', 'username'], render: (_, r) => r.user?.username || `#${r.user_id}` },
|
{ title: '用户', dataIndex: ['user', 'username'], render: (_, r) => r.user?.username || `#${r.user_id}` },
|
||||||
{ title: '昵称', dataIndex: ['user', 'nickname'], render: (_, r) => r.user?.nickname || '-' },
|
{ title: '昵称', dataIndex: ['user', 'nickname'], render: (_, r) => r.user?.nickname || '-' },
|
||||||
{ title: '角色', dataIndex: 'role', width: 120, render: memberRoleTag },
|
{ title: '角色', dataIndex: 'role', width: 140, render: (value) => <Tag color="blue">{roleNames.get(value) || roleText(value)}</Tag> },
|
||||||
{ title: '默认', dataIndex: 'is_default', width: 80, render: (v) => (v ? <Tag color="blue">默认</Tag> : '-') },
|
{ title: '默认', dataIndex: 'is_default', width: 80, render: (v) => (v ? <Tag color="blue">默认</Tag> : '-') },
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>) },
|
{ title: '状态', dataIndex: 'status', width: 90, render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>) },
|
||||||
]
|
]
|
||||||
@@ -681,14 +690,32 @@ export function MembersTab({
|
|||||||
return (
|
return (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Typography.Text type="secondary">成员角色独立于平台账号角色。</Typography.Text>
|
<Typography.Text type="secondary">成员仅属于当前商户;角色和权限也在当前商户内独立配置。</Typography.Text>
|
||||||
{isOwner && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
{canManage && <Space>
|
||||||
|
<Button onClick={() => {
|
||||||
|
form.resetFields()
|
||||||
|
onOpenCreateRole()
|
||||||
|
}}>新建角色</Button>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
form.setFieldsValue({ role: 'operator' })
|
|
||||||
onOpenAdd()
|
onOpenAdd()
|
||||||
}}>添加成员</Button>}
|
}}>添加员工</Button>
|
||||||
|
</Space>}
|
||||||
</Space>
|
</Space>
|
||||||
<Table rowKey="id" loading={loading} columns={memberColumns} dataSource={members} tableLayout="fixed" />
|
<Table rowKey="id" loading={loading} columns={memberColumns} dataSource={members} tableLayout="fixed" />
|
||||||
|
<Card size="small" title="团队角色">
|
||||||
|
<Table
|
||||||
|
rowKey={(role) => role.id || role.code}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={roles}
|
||||||
|
columns={[
|
||||||
|
{ title: '角色', dataIndex: 'name', width: 160, render: (name, role) => <Space><Tag color={role.code === 'owner' ? 'gold' : 'blue'}>{name}</Tag>{role.code === 'owner' && <Typography.Text type="secondary">固定</Typography.Text>}</Space> },
|
||||||
|
{ title: '权限', dataIndex: 'permissions', render: (permissions) => permissions ? permissions.split(',').map((item: string) => <Tag key={item}>{rolePermissionText(item)}</Tag>) : '-' },
|
||||||
|
{ title: '操作', width: 90, render: (_, role) => role.code === 'owner' ? '-' : <Button type="link" size="small" disabled={!canManage} onClick={() => onEditRole(role)}>编辑</Button> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -761,10 +788,6 @@ function ledgerTypeTag(value: string) {
|
|||||||
return <Tag color={item.color}>{item.text}</Tag>
|
return <Tag color={item.color}>{item.text}</Tag>
|
||||||
}
|
}
|
||||||
|
|
||||||
function memberRoleTag(value: MerchantMember['role']) {
|
|
||||||
return <Tag color={value === 'owner' ? 'gold' : 'blue'}>{roleText(value)}</Tag>
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopesTag(value: string) {
|
function scopesTag(value: string) {
|
||||||
const scopes = (value || '').split(',').filter(Boolean)
|
const scopes = (value || '').split(',').filter(Boolean)
|
||||||
if (scopes.length === 0) {
|
if (scopes.length === 0) {
|
||||||
|
|||||||
@@ -12,9 +12,24 @@ export const memberRoleOptions = [
|
|||||||
{ value: 'owner', label: '负责人' },
|
{ value: 'owner', label: '负责人' },
|
||||||
{ value: 'operator', label: '运营' },
|
{ value: 'operator', label: '运营' },
|
||||||
{ value: 'finance', label: '财务' },
|
{ value: 'finance', label: '财务' },
|
||||||
{ value: 'viewer', label: '只读' },
|
{ value: 'support', label: '客服' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const rolePermissionOptions = [
|
||||||
|
{ value: 'products:manage', label: '商品管理' },
|
||||||
|
{ value: 'orders:manage', label: '订单与发货管理' },
|
||||||
|
{ value: 'wallet:view', label: '查看钱包与充值记录' },
|
||||||
|
{ value: 'wallet:ledger', label: '查看积分明细' },
|
||||||
|
{ value: 'recharge:manage', label: '申请充值与管理余额告警' },
|
||||||
|
{ value: 'api:manage', label: '管理 API 密钥' },
|
||||||
|
{ value: 'callbacks:manage', label: '管理回调' },
|
||||||
|
{ value: 'members:manage', label: '管理团队成员与角色' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function rolePermissionText(permission: string) {
|
||||||
|
return rolePermissionOptions.find((item) => item.value === permission)?.label || permission
|
||||||
|
}
|
||||||
|
|
||||||
export const apiClientMax = 5
|
export const apiClientMax = 5
|
||||||
|
|
||||||
export const scopeOptions = [
|
export const scopeOptions = [
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import type { User } from '../types'
|
import type { Merchant, User } from '../types'
|
||||||
import { authApi } from '../api'
|
import { authApi, merchantApi } from '../api'
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
token: string | null
|
token: string | null
|
||||||
@@ -15,6 +16,11 @@ interface AuthState {
|
|||||||
login: (username: string, password: string) => Promise<void>
|
login: (username: string, password: string) => Promise<void>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
refreshProfile: () => Promise<void>
|
refreshProfile: () => Promise<void>
|
||||||
|
merchant: Merchant | null
|
||||||
|
merchantRole: string | null
|
||||||
|
merchantPermissions: string[]
|
||||||
|
merchantLoading: boolean
|
||||||
|
refreshMerchantContext: () => Promise<void>
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,20 +38,63 @@ function loadUser(): User | null {
|
|||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'))
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'))
|
||||||
const [user, setUser] = useState<User | null>(() => loadUser())
|
const [user, setUser] = useState<User | null>(() => loadUser())
|
||||||
|
const [merchant, setMerchant] = useState<Merchant | null>(null)
|
||||||
|
const [merchantRole, setMerchantRole] = useState<string | null>(null)
|
||||||
|
const [merchantPermissions, setMerchantPermissions] = useState<string[]>([])
|
||||||
|
const [merchantLoading, setMerchantLoading] = useState(() => Boolean(localStorage.getItem('token')))
|
||||||
|
|
||||||
|
const refreshMerchantContext = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
setMerchant(null)
|
||||||
|
setMerchantRole(null)
|
||||||
|
setMerchantPermissions([])
|
||||||
|
setMerchantLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMerchantLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await merchantApi.current()
|
||||||
|
setMerchant(data.merchant)
|
||||||
|
setMerchantRole(data.role)
|
||||||
|
const nextPermissions = [...new Set(data.permissions || [])].sort()
|
||||||
|
setMerchantPermissions((current) => (
|
||||||
|
current.length === nextPermissions.length && current.every((value, index) => value === nextPermissions[index])
|
||||||
|
? current
|
||||||
|
: nextPermissions
|
||||||
|
))
|
||||||
|
} catch {
|
||||||
|
setMerchant(null)
|
||||||
|
setMerchantRole(null)
|
||||||
|
setMerchantPermissions([])
|
||||||
|
} finally {
|
||||||
|
setMerchantLoading(false)
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshMerchantContext()
|
||||||
|
}, [refreshMerchantContext])
|
||||||
|
|
||||||
const login = useCallback(async (username: string, password: string) => {
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
const result = await authApi.login(username, password)
|
const result = await authApi.login(username, password)
|
||||||
|
localStorage.removeItem('merchant_id')
|
||||||
localStorage.setItem('token', result.token)
|
localStorage.setItem('token', result.token)
|
||||||
localStorage.setItem('user', JSON.stringify(result.user))
|
localStorage.setItem('user', JSON.stringify(result.user))
|
||||||
setToken(result.token)
|
setToken(result.token)
|
||||||
setUser(result.user)
|
setUser(result.user)
|
||||||
|
setMerchantLoading(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
|
localStorage.removeItem('merchant_id')
|
||||||
setToken(null)
|
setToken(null)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
|
setMerchant(null)
|
||||||
|
setMerchantRole(null)
|
||||||
|
setMerchantPermissions([])
|
||||||
|
setMerchantLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refreshProfile = useCallback(async () => {
|
const refreshProfile = useCallback(async () => {
|
||||||
@@ -61,9 +110,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
refreshProfile,
|
refreshProfile,
|
||||||
|
merchant,
|
||||||
|
merchantRole,
|
||||||
|
merchantPermissions,
|
||||||
|
merchantLoading,
|
||||||
|
refreshMerchantContext,
|
||||||
isAdmin: user?.role === 'admin',
|
isAdmin: user?.role === 'admin',
|
||||||
}),
|
}),
|
||||||
[token, user, login, logout, refreshProfile],
|
[token, user, login, logout, refreshProfile, merchant, merchantRole, merchantPermissions, merchantLoading, refreshMerchantContext],
|
||||||
)
|
)
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface MerchantMember {
|
|||||||
id: number
|
id: number
|
||||||
merchant_id: number
|
merchant_id: number
|
||||||
user_id: number
|
user_id: number
|
||||||
role: 'owner' | 'operator' | 'finance' | 'viewer'
|
role: string
|
||||||
status: number
|
status: number
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
user?: User
|
user?: User
|
||||||
@@ -33,6 +33,16 @@ export interface MerchantMember {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MerchantRole {
|
||||||
|
id: number
|
||||||
|
merchant_id: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
permissions: string
|
||||||
|
status: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: number
|
id: number
|
||||||
code: string
|
code: string
|
||||||
|
|||||||
Reference in New Issue
Block a user