feat: add superadmin member review and coupon ops

This commit is contained in:
2026-01-15 11:53:52 +08:00
parent 56082bad4f
commit 8419ddede7
11 changed files with 1254 additions and 96 deletions

View File

@@ -2,8 +2,10 @@ package v1
import ( import (
dto "quyun/v2/app/http/super/v1/dto" dto "quyun/v2/app/http/super/v1/dto"
v1_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/app/requests" "quyun/v2/app/requests"
"quyun/v2/app/services" "quyun/v2/app/services"
"quyun/v2/database/models"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
@@ -26,3 +28,81 @@ type coupons struct{}
func (c *coupons) List(ctx fiber.Ctx, filter *dto.SuperCouponListFilter) (*requests.Pager, error) { func (c *coupons) List(ctx fiber.Ctx, filter *dto.SuperCouponListFilter) (*requests.Pager, error) {
return services.Super.ListCoupons(ctx, filter) return services.Super.ListCoupons(ctx, filter)
} }
// Create coupon
//
// @Router /super/v1/tenants/:tenantID<int>/coupons [post]
// @Summary Create coupon
// @Description Create coupon for tenant
// @Tags Coupon
// @Accept json
// @Produce json
// @Param tenantID path int64 true "Tenant ID"
// @Param form body v1_dto.CouponCreateForm true "Create form"
// @Success 200 {object} v1_dto.CouponItem
// @Bind tenantID path
// @Bind form body
// @Bind user local key(__ctx_user)
func (c *coupons) Create(ctx fiber.Ctx, user *models.User, tenantID int64, form *v1_dto.CouponCreateForm) (*v1_dto.CouponItem, error) {
return services.Coupon.Create(ctx, tenantID, user.ID, form)
}
// Get coupon
//
// @Router /super/v1/tenants/:tenantID<int>/coupons/:id<int> [get]
// @Summary Get coupon
// @Description Get coupon detail
// @Tags Coupon
// @Accept json
// @Produce json
// @Param tenantID path int64 true "Tenant ID"
// @Param id path int64 true "Coupon ID"
// @Success 200 {object} v1_dto.CouponItem
// @Bind tenantID path
// @Bind id path
func (c *coupons) Get(ctx fiber.Ctx, tenantID, id int64) (*v1_dto.CouponItem, error) {
return services.Coupon.Get(ctx, tenantID, id)
}
// Update coupon
//
// @Router /super/v1/tenants/:tenantID<int>/coupons/:id<int> [put]
// @Summary Update coupon
// @Description Update coupon for tenant
// @Tags Coupon
// @Accept json
// @Produce json
// @Param tenantID path int64 true "Tenant ID"
// @Param id path int64 true "Coupon ID"
// @Param form body v1_dto.CouponUpdateForm true "Update form"
// @Success 200 {object} v1_dto.CouponItem
// @Bind tenantID path
// @Bind id path
// @Bind form body
// @Bind user local key(__ctx_user)
func (c *coupons) Update(ctx fiber.Ctx, user *models.User, tenantID, id int64, form *v1_dto.CouponUpdateForm) (*v1_dto.CouponItem, error) {
return services.Coupon.Update(ctx, tenantID, user.ID, id, form)
}
// Grant coupon
//
// @Router /super/v1/tenants/:tenantID<int>/coupons/:id<int>/grant [post]
// @Summary Grant coupon
// @Description Grant coupon to users
// @Tags Coupon
// @Accept json
// @Produce json
// @Param tenantID path int64 true "Tenant ID"
// @Param id path int64 true "Coupon ID"
// @Param form body v1_dto.CouponGrantForm true "Grant form"
// @Success 200 {object} dto.SuperCouponGrantResponse
// @Bind tenantID path
// @Bind id path
// @Bind form body
func (c *coupons) Grant(ctx fiber.Ctx, tenantID, id int64, form *v1_dto.CouponGrantForm) (*dto.SuperCouponGrantResponse, error) {
granted, err := services.Coupon.Grant(ctx, tenantID, id, form.UserIDs)
if err != nil {
return nil, err
}
return &dto.SuperCouponGrantResponse{Granted: granted}, nil
}

View File

@@ -454,6 +454,59 @@ type TenantUser struct {
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
// SuperTenantJoinRequestListFilter 超管成员申请列表过滤条件。
type SuperTenantJoinRequestListFilter struct {
requests.Pagination
// TenantID 租户ID精确匹配。
TenantID *int64 `query:"tenant_id"`
// TenantCode 租户编码,模糊匹配。
TenantCode *string `query:"tenant_code"`
// TenantName 租户名称,模糊匹配。
TenantName *string `query:"tenant_name"`
// UserID 申请用户ID精确匹配。
UserID *int64 `query:"user_id"`
// Username 申请用户用户名/昵称,模糊匹配。
Username *string `query:"username"`
// Status 申请状态pending/approved/rejected
Status *consts.TenantJoinRequestStatus `query:"status"`
// CreatedAtFrom 申请时间起始RFC3339
CreatedAtFrom *string `query:"created_at_from"`
// CreatedAtTo 申请时间结束RFC3339
CreatedAtTo *string `query:"created_at_to"`
}
// SuperTenantJoinRequestItem 超管成员申请列表项。
type SuperTenantJoinRequestItem struct {
// ID 申请记录ID。
ID int64 `json:"id"`
// TenantID 租户ID。
TenantID int64 `json:"tenant_id"`
// TenantCode 租户编码。
TenantCode string `json:"tenant_code"`
// TenantName 租户名称。
TenantName string `json:"tenant_name"`
// UserID 申请用户ID。
UserID int64 `json:"user_id"`
// Username 申请用户名称。
Username string `json:"username"`
// Status 申请状态。
Status string `json:"status"`
// StatusDescription 状态描述(用于展示)。
StatusDescription string `json:"status_description"`
// Reason 申请说明。
Reason string `json:"reason"`
// DecidedAt 审核时间RFC3339
DecidedAt string `json:"decided_at"`
// DecidedOperatorUserID 审核操作者ID。
DecidedOperatorUserID int64 `json:"decided_operator_user_id"`
// DecidedReason 审核备注/原因。
DecidedReason string `json:"decided_reason"`
// CreatedAt 申请时间RFC3339
CreatedAt string `json:"created_at"`
// UpdatedAt 更新时间RFC3339
UpdatedAt string `json:"updated_at"`
}
// Order Related // Order Related
type SuperOrderItem struct { type SuperOrderItem struct {
// ID 订单ID。 // ID 订单ID。

View File

@@ -73,3 +73,9 @@ type SuperCouponItem struct {
// UpdatedAt 更新时间RFC3339 // UpdatedAt 更新时间RFC3339
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
// SuperCouponGrantResponse 超管优惠券发放结果。
type SuperCouponGrantResponse struct {
// Granted 实际发放数量。
Granted int `json:"granted"`
}

View File

@@ -6,6 +6,7 @@ package v1
import ( import (
dto "quyun/v2/app/http/super/v1/dto" dto "quyun/v2/app/http/super/v1/dto"
v1_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/app/middlewares" "quyun/v2/app/middlewares"
"quyun/v2/database/models" "quyun/v2/database/models"
@@ -87,6 +88,34 @@ func (r *Routes) Register(router fiber.Router) {
r.coupons.List, r.coupons.List,
Query[dto.SuperCouponListFilter]("filter"), Query[dto.SuperCouponListFilter]("filter"),
)) ))
r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID<int>/coupons/:id<int> -> coupons.Get")
router.Get("/super/v1/tenants/:tenantID<int>/coupons/:id<int>"[len(r.Path()):], DataFunc2(
r.coupons.Get,
PathParam[int64]("tenantID"),
PathParam[int64]("id"),
))
r.log.Debugf("Registering route: Post /super/v1/tenants/:tenantID<int>/coupons -> coupons.Create")
router.Post("/super/v1/tenants/:tenantID<int>/coupons"[len(r.Path()):], DataFunc3(
r.coupons.Create,
Local[*models.User]("__ctx_user"),
PathParam[int64]("tenantID"),
Body[v1_dto.CouponCreateForm]("form"),
))
r.log.Debugf("Registering route: Post /super/v1/tenants/:tenantID<int>/coupons/:id<int>/grant -> coupons.Grant")
router.Post("/super/v1/tenants/:tenantID<int>/coupons/:id<int>/grant"[len(r.Path()):], DataFunc3(
r.coupons.Grant,
PathParam[int64]("tenantID"),
PathParam[int64]("id"),
Body[v1_dto.CouponGrantForm]("form"),
))
r.log.Debugf("Registering route: Put /super/v1/tenants/:tenantID<int>/coupons/:id<int> -> coupons.Update")
router.Put("/super/v1/tenants/:tenantID<int>/coupons/:id<int>"[len(r.Path()):], DataFunc4(
r.coupons.Update,
Local[*models.User]("__ctx_user"),
PathParam[int64]("tenantID"),
PathParam[int64]("id"),
Body[v1_dto.CouponUpdateForm]("form"),
))
// Register routes for controller: creators // Register routes for controller: creators
r.log.Debugf("Registering route: Get /super/v1/creators -> creators.List") r.log.Debugf("Registering route: Get /super/v1/creators -> creators.List")
router.Get("/super/v1/creators"[len(r.Path()):], DataFunc1( router.Get("/super/v1/creators"[len(r.Path()):], DataFunc1(
@@ -126,6 +155,11 @@ func (r *Routes) Register(router fiber.Router) {
Body[dto.SuperReportExportForm]("form"), Body[dto.SuperReportExportForm]("form"),
)) ))
// Register routes for controller: tenants // Register routes for controller: tenants
r.log.Debugf("Registering route: Get /super/v1/tenant-join-requests -> tenants.ListJoinRequests")
router.Get("/super/v1/tenant-join-requests"[len(r.Path()):], DataFunc1(
r.tenants.ListJoinRequests,
Query[dto.SuperTenantJoinRequestListFilter]("filter"),
))
r.log.Debugf("Registering route: Get /super/v1/tenants -> tenants.List") r.log.Debugf("Registering route: Get /super/v1/tenants -> tenants.List")
router.Get("/super/v1/tenants"[len(r.Path()):], DataFunc1( router.Get("/super/v1/tenants"[len(r.Path()):], DataFunc1(
r.tenants.List, r.tenants.List,
@@ -163,11 +197,24 @@ func (r *Routes) Register(router fiber.Router) {
PathParam[int64]("id"), PathParam[int64]("id"),
Body[dto.TenantStatusUpdateForm]("form"), Body[dto.TenantStatusUpdateForm]("form"),
)) ))
r.log.Debugf("Registering route: Post /super/v1/tenant-join-requests/:id<int>/review -> tenants.ReviewJoinRequest")
router.Post("/super/v1/tenant-join-requests/:id<int>/review"[len(r.Path()):], Func3(
r.tenants.ReviewJoinRequest,
Local[*models.User]("__ctx_user"),
PathParam[int64]("id"),
Body[v1_dto.TenantJoinReviewForm]("form"),
))
r.log.Debugf("Registering route: Post /super/v1/tenants -> tenants.Create") r.log.Debugf("Registering route: Post /super/v1/tenants -> tenants.Create")
router.Post("/super/v1/tenants"[len(r.Path()):], Func1( router.Post("/super/v1/tenants"[len(r.Path()):], Func1(
r.tenants.Create, r.tenants.Create,
Body[dto.TenantCreateForm]("form"), Body[dto.TenantCreateForm]("form"),
)) ))
r.log.Debugf("Registering route: Post /super/v1/tenants/:tenantID<int>/invites -> tenants.CreateInvite")
router.Post("/super/v1/tenants/:tenantID<int>/invites"[len(r.Path()):], DataFunc2(
r.tenants.CreateInvite,
PathParam[int64]("tenantID"),
Body[v1_dto.TenantInviteCreateForm]("form"),
))
// Register routes for controller: users // Register routes for controller: users
r.log.Debugf("Registering route: Get /super/v1/users -> users.List") r.log.Debugf("Registering route: Get /super/v1/users -> users.List")
router.Get("/super/v1/users"[len(r.Path()):], DataFunc1( router.Get("/super/v1/users"[len(r.Path()):], DataFunc1(

View File

@@ -2,8 +2,10 @@ package v1
import ( import (
dto "quyun/v2/app/http/super/v1/dto" dto "quyun/v2/app/http/super/v1/dto"
v1_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/app/requests" "quyun/v2/app/requests"
"quyun/v2/app/services" "quyun/v2/app/services"
"quyun/v2/database/models"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
@@ -62,6 +64,57 @@ func (c *tenants) ListUsers(ctx fiber.Ctx, tenantID int64, filter *dto.SuperTena
return services.Super.ListTenantUsers(ctx, tenantID, filter) return services.Super.ListTenantUsers(ctx, tenantID, filter)
} }
// List tenant join requests
//
// @Router /super/v1/tenant-join-requests [get]
// @Summary List tenant join requests
// @Description List tenant join requests across tenants
// @Tags Tenant
// @Accept json
// @Produce json
// @Param page query int false "Page number"
// @Param limit query int false "Page size"
// @Success 200 {object} requests.Pager{items=[]dto.SuperTenantJoinRequestItem}
// @Bind filter query
func (c *tenants) ListJoinRequests(ctx fiber.Ctx, filter *dto.SuperTenantJoinRequestListFilter) (*requests.Pager, error) {
return services.Super.ListTenantJoinRequests(ctx, filter)
}
// Review tenant join request
//
// @Router /super/v1/tenant-join-requests/:id<int>/review [post]
// @Summary Review tenant join request
// @Description Approve or reject a tenant join request
// @Tags Tenant
// @Accept json
// @Produce json
// @Param id path int64 true "Join request ID"
// @Param form body v1_dto.TenantJoinReviewForm true "Review form"
// @Success 200 {string} string "Reviewed"
// @Bind user local key(__ctx_user)
// @Bind id path
// @Bind form body
func (c *tenants) ReviewJoinRequest(ctx fiber.Ctx, user *models.User, id int64, form *v1_dto.TenantJoinReviewForm) error {
return services.Super.ReviewTenantJoinRequest(ctx, user.ID, id, form)
}
// Create tenant invite
//
// @Router /super/v1/tenants/:tenantID<int>/invites [post]
// @Summary Create tenant invite
// @Description Create tenant invite code
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenantID path int64 true "Tenant ID"
// @Param form body v1_dto.TenantInviteCreateForm true "Invite form"
// @Success 200 {object} v1_dto.TenantInviteItem
// @Bind tenantID path
// @Bind form body
func (c *tenants) CreateInvite(ctx fiber.Ctx, tenantID int64, form *v1_dto.TenantInviteCreateForm) (*v1_dto.TenantInviteItem, error) {
return services.Super.CreateTenantInvite(ctx, tenantID, form)
}
// Create tenant // Create tenant
// //
// @Router /super/v1/tenants [post] // @Router /super/v1/tenants [post]

View File

@@ -768,6 +768,273 @@ func (s *super) ListTenantUsers(ctx context.Context, tenantID int64, filter *sup
}, nil }, nil
} }
func (s *super) ListTenantJoinRequests(ctx context.Context, filter *super_dto.SuperTenantJoinRequestListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.SuperTenantJoinRequestListFilter{}
}
tbl, q := models.TenantJoinRequestQuery.QueryContext(ctx)
if filter.TenantID != nil && *filter.TenantID > 0 {
q = q.Where(tbl.TenantID.Eq(*filter.TenantID))
}
if filter.UserID != nil && *filter.UserID > 0 {
q = q.Where(tbl.UserID.Eq(*filter.UserID))
}
if filter.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(string(*filter.Status)))
}
tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName)
if err != nil {
return nil, err
}
if tenantFilter {
if len(tenantIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.TenantID.In(tenantIDs...))
}
}
userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username)
if err != nil {
return nil, err
}
if userFilter {
if len(userIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.UserID.In(userIDs...))
}
}
if filter.CreatedAtFrom != nil {
from, err := s.parseFilterTime(filter.CreatedAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.CreatedAt.Gte(*from))
}
}
if filter.CreatedAtTo != nil {
to, err := s.parseFilterTime(filter.CreatedAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.CreatedAt.Lte(*to))
}
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Order(tbl.CreatedAt.Desc()).
Offset(int(filter.Pagination.Offset())).
Limit(int(filter.Pagination.Limit)).
Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
// 补齐租户与用户信息,便于前端展示。
tenantMap := make(map[int64]*models.Tenant)
userMap := make(map[int64]*models.User)
if len(list) > 0 {
tenantIDSet := make(map[int64]struct{})
userIDSet := make(map[int64]struct{})
for _, req := range list {
if req.TenantID > 0 {
tenantIDSet[req.TenantID] = struct{}{}
}
if req.UserID > 0 {
userIDSet[req.UserID] = struct{}{}
}
}
tenantIDs := make([]int64, 0, len(tenantIDSet))
for id := range tenantIDSet {
tenantIDs = append(tenantIDs, id)
}
if len(tenantIDs) > 0 {
tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx)
tenants, err := tenantQuery.Where(tenantTbl.ID.In(tenantIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, tenant := range tenants {
tenantMap[tenant.ID] = tenant
}
}
userIDs := make([]int64, 0, len(userIDSet))
for id := range userIDSet {
userIDs = append(userIDs, id)
}
if len(userIDs) > 0 {
userTbl, userQuery := models.UserQuery.QueryContext(ctx)
users, err := userQuery.Where(userTbl.ID.In(userIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, user := range users {
userMap[user.ID] = user
}
}
}
items := make([]super_dto.SuperTenantJoinRequestItem, 0, len(list))
for _, req := range list {
tenant := tenantMap[req.TenantID]
user := userMap[req.UserID]
status := consts.TenantJoinRequestStatus(req.Status)
statusDescription := status.Description()
if statusDescription == "" {
statusDescription = req.Status
}
tenantCode := ""
tenantName := ""
if tenant != nil {
tenantCode = tenant.Code
tenantName = tenant.Name
}
username := ""
if user != nil {
username = user.Username
}
if username == "" && req.UserID > 0 {
username = "ID:" + strconv.FormatInt(req.UserID, 10)
}
items = append(items, super_dto.SuperTenantJoinRequestItem{
ID: req.ID,
TenantID: req.TenantID,
TenantCode: tenantCode,
TenantName: tenantName,
UserID: req.UserID,
Username: username,
Status: req.Status,
StatusDescription: statusDescription,
Reason: req.Reason,
DecidedAt: s.formatTime(req.DecidedAt),
DecidedOperatorUserID: req.DecidedOperatorUserID,
DecidedReason: req.DecidedReason,
CreatedAt: s.formatTime(req.CreatedAt),
UpdatedAt: s.formatTime(req.UpdatedAt),
})
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) ReviewTenantJoinRequest(ctx context.Context, operatorID, requestID int64, form *v1_dto.TenantJoinReviewForm) error {
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
if form == nil {
return errorx.ErrBadRequest.WithMsg("审核参数不能为空")
}
action := strings.ToLower(strings.TrimSpace(form.Action))
if action != "approve" && action != "reject" {
return errorx.ErrBadRequest.WithMsg("审核动作无效")
}
tblReq, qReq := models.TenantJoinRequestQuery.QueryContext(ctx)
req, err := qReq.Where(tblReq.ID.Eq(requestID)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("申请不存在")
}
return errorx.ErrDatabaseError.WithCause(err)
}
if req.Status != string(consts.TenantJoinRequestStatusPending) {
return errorx.ErrBadRequest.WithMsg("申请已处理")
}
reason := strings.TrimSpace(form.Reason)
now := time.Now()
if action == "reject" {
_, err = qReq.Where(
tblReq.ID.Eq(req.ID),
tblReq.Status.Eq(string(consts.TenantJoinRequestStatusPending)),
).UpdateSimple(
tblReq.Status.Value(string(consts.TenantJoinRequestStatusRejected)),
tblReq.DecidedAt.Value(now),
tblReq.DecidedOperatorUserID.Value(operatorID),
tblReq.DecidedReason.Value(reason),
)
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
}
// 审核通过需在事务内写入成员并更新申请状态。
return models.Q.Transaction(func(tx *models.Query) error {
tblMember, qMember := tx.TenantUser.QueryContext(ctx)
exists, err := qMember.Where(
tblMember.TenantID.Eq(req.TenantID),
tblMember.UserID.Eq(req.UserID),
).Exists()
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
if exists {
return errorx.ErrBadRequest.WithMsg("用户已是成员")
}
member := &models.TenantUser{
TenantID: req.TenantID,
UserID: req.UserID,
Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember},
Status: consts.UserStatusVerified,
}
if err := qMember.Create(member); err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
tblReqTx, qReqTx := tx.TenantJoinRequest.QueryContext(ctx)
_, err = qReqTx.Where(
tblReqTx.ID.Eq(req.ID),
tblReqTx.Status.Eq(string(consts.TenantJoinRequestStatusPending)),
).UpdateSimple(
tblReqTx.Status.Value(string(consts.TenantJoinRequestStatusApproved)),
tblReqTx.DecidedAt.Value(now),
tblReqTx.DecidedOperatorUserID.Value(operatorID),
tblReqTx.DecidedReason.Value(reason),
)
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
})
}
func (s *super) CreateTenantInvite(ctx context.Context, tenantID int64, form *v1_dto.TenantInviteCreateForm) (*v1_dto.TenantInviteItem, error) {
if tenantID == 0 {
return nil, errorx.ErrRecordNotFound.WithMsg("租户不存在")
}
// 使用租户主账号执行创建邀请码逻辑,复用既有校验流程。
tbl, q := models.TenantQuery.QueryContext(ctx)
tenant, err := q.Where(tbl.ID.Eq(tenantID)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound.WithMsg("租户不存在")
}
return nil, errorx.ErrDatabaseError.WithCause(err)
}
return Tenant.CreateInvite(ctx, tenantID, tenant.UserID, form)
}
func (s *super) ListUserTenants(ctx context.Context, userID int64, filter *super_dto.SuperUserTenantListFilter) (*requests.Pager, error) { func (s *super) ListUserTenants(ctx context.Context, userID int64, filter *super_dto.SuperUserTenantListFilter) (*requests.Pager, error) {
tbl, q := models.TenantUserQuery.QueryContext(ctx) tbl, q := models.TenantUserQuery.QueryContext(ctx)
q = q.Where(tbl.UserID.Eq(userID)) q = q.Where(tbl.UserID.Eq(userID))

View File

@@ -4,8 +4,8 @@
## 1) 总体结论 ## 1) 总体结论
- **已落地**:登录、租户/用户/订单/内容的基础管理、提现审核、报表概览与导出、优惠券与创作者基础列表 - **已落地**:登录、租户/用户/订单/内容的基础管理、内容审核(含批量)、提现审核、报表概览与导出、用户钱包查看、创作者成员审核/邀请、优惠券创建/编辑/发放
- **部分落地**:平台概览(缺少内容统计与运营趋势)、内容治理(缺审核流)、创作者/优惠券/财务(缺跨租户深度操作与明细)。 - **部分落地**:平台概览(缺少内容统计与运营趋势)、租户详情(缺财务/报表聚合)、用户管理/详情(缺通知/优惠券/实名/充值/收藏等明细)、创作者/优惠券(缺结算账户审核/发放记录/冻结等深度治理)。
- **未落地**:资产治理、通知中心、审计与系统配置类能力。 - **未落地**:资产治理、通知中心、审计与系统配置类能力。
## 2) 按页面完成度(对照 2.x ## 2) 按页面完成度(对照 2.x
@@ -28,7 +28,7 @@
### 2.4 租户详情 `/superadmin/tenants/:tenantID` ### 2.4 租户详情 `/superadmin/tenants/:tenantID`
- 状态:**部分完成** - 状态:**部分完成**
- 已有:租户信息、状态/续期、成员列表、内容与订单查询。 - 已有:租户信息、状态/续期、成员列表、内容与订单查询。
- 缺口:成员审核/邀请、租户级财务与报表聚合入口。 - 缺口:租户级财务与报表聚合入口(成员审核/邀请由超管入口完成)
### 2.5 用户管理 `/superadmin/users` ### 2.5 用户管理 `/superadmin/users`
- 状态:**部分完成** - 状态:**部分完成**
@@ -38,7 +38,7 @@
### 2.6 用户详情 `/superadmin/users/:userID` ### 2.6 用户详情 `/superadmin/users/:userID`
- 状态:**部分完成** - 状态:**部分完成**
- 已有:用户资料、租户关系、订单查询、钱包余额与流水。 - 已有:用户资料、租户关系、订单查询、钱包余额与流水。
- 缺口:通知、优惠券、收藏/点赞等详情 - 缺口:通知、优惠券、收藏/点赞、实名认证详情等明细
### 2.7 内容治理 `/superadmin/contents` ### 2.7 内容治理 `/superadmin/contents`
- 状态:**部分完成** - 状态:**部分完成**
@@ -52,13 +52,13 @@
### 2.9 创作者与成员审核 `/superadmin/creators` ### 2.9 创作者与成员审核 `/superadmin/creators`
- 状态:**部分完成** - 状态:**部分完成**
- 已有:创作者(租户)列表、状态更新。 - 已有:创作者(租户)列表、状态更新、成员申请列表/审核、成员邀请创建
- 缺口:成员审核/邀请、结算账户审核、跨租户创作者审核流 - 缺口:跨租户创作者申请审核、结算账户审核、提现审核入口(与财务联动)
### 2.10 优惠券 `/superadmin/coupons` ### 2.10 优惠券 `/superadmin/coupons`
- 状态:**部分完成** - 状态:**部分完成**
- 已有:跨租户优惠券列表。 - 已有:跨租户优惠券列表、创建/编辑、发放
- 缺口:创建/编辑/冻结、发放记录与异常核查。 - 缺口:冻结/归档、发放记录与异常核查。
### 2.11 财务与钱包 `/superadmin/finance` ### 2.11 财务与钱包 `/superadmin/finance`
- 状态:**部分完成** - 状态:**部分完成**
@@ -82,12 +82,12 @@
## 3) `/super/v1` 接口覆盖度概览 ## 3) `/super/v1` 接口覆盖度概览
- **已具备**Auth、Tenants、Users含钱包、Contents、Orders、Withdrawals、Reports、Coupons列表、Creators列表 - **已具备**Auth、Tenants(含成员审核/邀请)、Users含钱包、Contents、Orders、Withdrawals、Reports、Coupons列表/创建/编辑/发放、Creators列表
- **缺失/待补**:资产治理、通知中心、用户优惠券/通知明细、创作者成员审核、优惠券发放与冻结 - **缺失/待补**:资产治理、通知中心、用户优惠券/通知明细、创作者申请/结算账户审核、优惠券冻结与发放记录
## 4) 建议的下一步(按优先级) ## 4) 建议的下一步(按优先级)
1. **补齐内容审核与违规治理**:落地 `/super/v1/contents/:id/review` 前端入口与批量操作 1. **完善用户与通知/优惠券明细**:补齐用户通知、优惠券、实名详情、充值记录等超管视图接口与页面
2. **完善用户与钱包视图**:增加钱包流水、充值/退款/通知/优惠券详情接口及页面 2. **创作者/优惠券深度治理**:补齐创作者申请/结算账户审核、优惠券冻结/发放记录
3. **创作者与优惠券深度操作**:新增成员审核/邀请、优惠券冻结/发放记录 3. **平台概览增强**:补齐内容总量与趋势、退款率、订单漏斗等核心指标
4. **资产与通知中心**:补齐接口页面,形成治理闭环。 4. **资产与通知中心**:补齐资产治理与通知中心接口/页面,形成治理闭环。

View File

@@ -40,5 +40,55 @@ export const CouponService = {
total: data?.total ?? 0, total: data?.total ?? 0,
items: normalizeItems(data?.items) items: normalizeItems(data?.items)
}; };
},
async createCoupon(tenantID, form = {}) {
if (!tenantID) throw new Error('tenantID is required');
return requestJson(`/super/v1/tenants/${tenantID}/coupons`, {
method: 'POST',
body: normalizeCouponForm(form)
});
},
async getCoupon(tenantID, couponID) {
if (!tenantID) throw new Error('tenantID is required');
if (!couponID) throw new Error('couponID is required');
return requestJson(`/super/v1/tenants/${tenantID}/coupons/${couponID}`);
},
async updateCoupon(tenantID, couponID, form = {}) {
if (!tenantID) throw new Error('tenantID is required');
if (!couponID) throw new Error('couponID is required');
return requestJson(`/super/v1/tenants/${tenantID}/coupons/${couponID}`, {
method: 'PUT',
body: normalizeCouponForm(form)
});
},
async grantCoupon(tenantID, couponID, userIDs = []) {
if (!tenantID) throw new Error('tenantID is required');
if (!couponID) throw new Error('couponID is required');
if (!Array.isArray(userIDs) || userIDs.length === 0) throw new Error('userIDs is required');
return requestJson(`/super/v1/tenants/${tenantID}/coupons/${couponID}/grant`, {
method: 'POST',
body: { user_ids: userIDs }
});
} }
}; };
function normalizeCouponForm(form) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
return {
title: form.title,
description: form.description,
type: form.type,
value: form.value,
min_order_amount: form.min_order_amount,
max_discount: form.max_discount,
total_quantity: form.total_quantity,
start_at: iso(form.start_at),
end_at: iso(form.end_at)
};
}

View File

@@ -38,5 +38,52 @@ export const CreatorService = {
total: data?.total ?? 0, total: data?.total ?? 0,
items: normalizeItems(data?.items) items: normalizeItems(data?.items)
}; };
},
async listJoinRequests({ page, limit, tenant_id, tenant_code, tenant_name, user_id, username, status, created_at_from, created_at_to } = {}) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
const query = {
page,
limit,
tenant_id,
tenant_code,
tenant_name,
user_id,
username,
status,
created_at_from: iso(created_at_from),
created_at_to: iso(created_at_to)
};
const data = await requestJson('/super/v1/tenant-join-requests', { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async reviewJoinRequest(requestID, { action, reason } = {}) {
if (!requestID) throw new Error('requestID is required');
return requestJson(`/super/v1/tenant-join-requests/${requestID}/review`, {
method: 'POST',
body: { action, reason }
});
},
async createInvite(tenantID, { max_uses, expires_at, remark } = {}) {
if (!tenantID) throw new Error('tenantID is required');
return requestJson(`/super/v1/tenants/${tenantID}/invites`, {
method: 'POST',
body: {
max_uses,
expires_at,
remark
}
});
} }
}; };

View File

@@ -39,6 +39,25 @@ const statusOptions = [
{ label: '已过期', value: 'expired' } { label: '已过期', value: 'expired' }
]; ];
const editDialogVisible = ref(false);
const couponSubmitting = ref(false);
const editingCoupon = ref(null);
const formTenantID = ref(null);
const formTitle = ref('');
const formDescription = ref('');
const formType = ref('fix_amount');
const formValue = ref(0);
const formMinOrderAmount = ref(0);
const formMaxDiscount = ref(0);
const formTotalQuantity = ref(0);
const formStartAt = ref(null);
const formEndAt = ref(null);
const grantDialogVisible = ref(false);
const grantSubmitting = ref(false);
const grantCoupon = ref(null);
const grantUserIDsText = ref('');
function formatDate(value) { function formatDate(value) {
if (!value) return '-'; if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-'; if (String(value).startsWith('0001-01-01')) return '-';
@@ -47,6 +66,13 @@ function formatDate(value) {
return date.toLocaleString(); return date.toLocaleString();
} }
function parseDateValue(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function formatCny(amountInCents) { function formatCny(amountInCents) {
const amount = Number(amountInCents) / 100; const amount = Number(amountInCents) / 100;
if (!Number.isFinite(amount)) return '-'; if (!Number.isFinite(amount)) return '-';
@@ -66,6 +92,111 @@ function getStatusSeverity(value) {
} }
} }
function resetCouponForm() {
formTenantID.value = null;
formTitle.value = '';
formDescription.value = '';
formType.value = 'fix_amount';
formValue.value = 0;
formMinOrderAmount.value = 0;
formMaxDiscount.value = 0;
formTotalQuantity.value = 0;
formStartAt.value = null;
formEndAt.value = null;
}
function openCreateDialog() {
editingCoupon.value = null;
resetCouponForm();
editDialogVisible.value = true;
}
function openEditDialog(row) {
editingCoupon.value = row;
formTenantID.value = row?.tenant_id ?? null;
formTitle.value = row?.title ?? '';
formDescription.value = row?.description ?? '';
formType.value = row?.type ?? 'fix_amount';
formValue.value = row?.value ?? 0;
formMinOrderAmount.value = row?.min_order_amount ?? 0;
formMaxDiscount.value = row?.max_discount ?? 0;
formTotalQuantity.value = row?.total_quantity ?? 0;
formStartAt.value = parseDateValue(row?.start_at);
formEndAt.value = parseDateValue(row?.end_at);
editDialogVisible.value = true;
}
async function confirmSaveCoupon() {
const tenantValue = formTenantID.value;
if (!tenantValue) {
toast.add({ severity: 'warn', summary: '缺少租户', detail: '请填写 TenantID', life: 3000 });
return;
}
if (!formTitle.value.trim()) {
toast.add({ severity: 'warn', summary: '缺少标题', detail: '优惠券标题不能为空', life: 3000 });
return;
}
couponSubmitting.value = true;
try {
const payload = {
title: formTitle.value,
description: formDescription.value,
type: formType.value,
value: formValue.value,
min_order_amount: formMinOrderAmount.value,
max_discount: formMaxDiscount.value,
total_quantity: formTotalQuantity.value,
start_at: formStartAt.value,
end_at: formEndAt.value
};
if (editingCoupon.value?.id) {
await CouponService.updateCoupon(tenantValue, editingCoupon.value.id, payload);
toast.add({ severity: 'success', summary: '更新成功', detail: `CouponID: ${editingCoupon.value.id}`, life: 3000 });
} else {
await CouponService.createCoupon(tenantValue, payload);
toast.add({ severity: 'success', summary: '创建成功', detail: `TenantID: ${tenantValue}`, life: 3000 });
}
editDialogVisible.value = false;
await loadCoupons();
} catch (error) {
toast.add({ severity: 'error', summary: '保存失败', detail: error?.message || '无法保存优惠券', life: 4000 });
} finally {
couponSubmitting.value = false;
}
}
function openGrantDialog(row) {
grantCoupon.value = row;
grantUserIDsText.value = '';
grantDialogVisible.value = true;
}
async function confirmGrant() {
const coupon = grantCoupon.value;
if (!coupon?.id || !coupon?.tenant_id) return;
const ids = grantUserIDsText.value
.split(/[\s,]+/g)
.map((item) => Number(item))
.filter((value) => Number.isFinite(value) && value > 0);
if (ids.length === 0) {
toast.add({ severity: 'warn', summary: '请输入用户ID', detail: '至少填写 1 个用户ID', life: 3000 });
return;
}
grantSubmitting.value = true;
try {
const res = await CouponService.grantCoupon(coupon.tenant_id, coupon.id, ids);
toast.add({ severity: 'success', summary: '发放成功', detail: `已发放 ${res?.granted ?? ids.length}`, life: 3000 });
grantDialogVisible.value = false;
await loadCoupons();
} catch (error) {
toast.add({ severity: 'error', summary: '发放失败', detail: error?.message || '无法发放优惠券', life: 4000 });
} finally {
grantSubmitting.value = false;
}
}
async function loadCoupons() { async function loadCoupons() {
loading.value = true; loading.value = true;
try { try {
@@ -136,6 +267,7 @@ onMounted(() => {
<div class="card"> <div class="card">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h4 class="m-0">优惠券</h4> <h4 class="m-0">优惠券</h4>
<Button label="新建优惠券" icon="pi pi-plus" @click="openCreateDialog" />
</div> </div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset"> <SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
@@ -246,6 +378,96 @@ onMounted(() => {
{{ formatDate(data.created_at) }} {{ formatDate(data.created_at) }}
</template> </template>
</Column> </Column>
<Column header="操作" style="min-width: 12rem">
<template #body="{ data }">
<Button label="编辑" icon="pi pi-pencil" text size="small" class="p-0 mr-3" @click="openEditDialog(data)" />
<Button label="发放" icon="pi pi-send" text size="small" class="p-0" @click="openGrantDialog(data)" />
</template>
</Column>
</DataTable> </DataTable>
</div> </div>
<Dialog v-model:visible="editDialogVisible" :modal="true" :style="{ width: '600px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">{{ editingCoupon ? '编辑优惠券' : '新建优惠券' }}</span>
<span v-if="editingCoupon?.id" class="text-muted-color">ID: {{ editingCoupon.id }}</span>
</div>
</template>
<div class="flex flex-col gap-4">
<div>
<label class="block font-medium mb-2">TenantID</label>
<InputNumber v-model="formTenantID" :min="1" placeholder="必填" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">标题</label>
<InputText v-model="formTitle" placeholder="请输入标题" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">描述</label>
<InputText v-model="formDescription" placeholder="可选" class="w-full" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block font-medium mb-2">类型</label>
<Select v-model="formType" :options="typeOptions" optionLabel="label" optionValue="value" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">面额/折扣</label>
<InputNumber v-model="formValue" :min="0" class="w-full" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block font-medium mb-2">最低门槛()</label>
<InputNumber v-model="formMinOrderAmount" :min="0" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">最高折扣()</label>
<InputNumber v-model="formMaxDiscount" :min="0" class="w-full" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block font-medium mb-2">发行量</label>
<InputNumber v-model="formTotalQuantity" :min="0" class="w-full" />
</div>
<div class="text-sm text-muted-color flex items-center">0 表示不限量</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block font-medium mb-2">开始时间</label>
<DatePicker v-model="formStartAt" showIcon showButtonBar placeholder="可选" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">结束时间</label>
<DatePicker v-model="formEndAt" showIcon showButtonBar placeholder="可选" class="w-full" />
</div>
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="editDialogVisible = false" :disabled="couponSubmitting" />
<Button label="确认保存" icon="pi pi-check" @click="confirmSaveCoupon" :loading="couponSubmitting" />
</template>
</Dialog>
<Dialog v-model:visible="grantDialogVisible" :modal="true" :style="{ width: '520px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">发放优惠券</span>
<span class="text-muted-color">ID: {{ grantCoupon?.id ?? '-' }}</span>
</div>
</template>
<div class="flex flex-col gap-4">
<div class="text-sm text-muted-color">输入用户ID使用逗号或空格分隔</div>
<div>
<label class="block font-medium mb-2">用户ID列表</label>
<InputText v-model="grantUserIDsText" placeholder="例如1,2,3" class="w-full" />
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="grantDialogVisible = false" :disabled="grantSubmitting" />
<Button label="确认发放" icon="pi pi-send" @click="confirmGrant" :loading="grantSubmitting" />
</template>
</Dialog>
</template> </template>

View File

@@ -8,6 +8,8 @@ import { onMounted, ref } from 'vue';
const toast = useToast(); const toast = useToast();
const tabValue = ref('creators');
const creators = ref([]); const creators = ref([]);
const loading = ref(false); const loading = ref(false);
@@ -32,6 +34,39 @@ const statusUpdating = ref(false);
const statusTenant = ref(null); const statusTenant = ref(null);
const statusValue = ref(null); const statusValue = ref(null);
const joinRequests = ref([]);
const joinRequestsLoading = ref(false);
const joinRequestsTotal = ref(0);
const joinRequestsPage = ref(1);
const joinRequestsRows = ref(10);
const joinTenantID = ref(null);
const joinTenantCode = ref('');
const joinTenantName = ref('');
const joinUserID = ref(null);
const joinUsername = ref('');
const joinStatus = ref('pending');
const joinCreatedAtFrom = ref(null);
const joinCreatedAtTo = ref(null);
const joinStatusOptions = [
{ label: '全部', value: '' },
{ label: '待审核', value: 'pending' },
{ label: '已通过', value: 'approved' },
{ label: '已驳回', value: 'rejected' }
];
const reviewDialogVisible = ref(false);
const reviewSubmitting = ref(false);
const reviewAction = ref('approve');
const reviewReason = ref('');
const reviewTarget = ref(null);
const inviteDialogVisible = ref(false);
const inviteSubmitting = ref(false);
const inviteTenantID = ref(null);
const inviteMaxUses = ref(1);
const inviteExpiresAt = ref(null);
const inviteRemark = ref('');
function formatDate(value) { function formatDate(value) {
if (!value) return '-'; if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-'; if (String(value).startsWith('0001-01-01')) return '-';
@@ -53,6 +88,19 @@ function getStatusSeverity(value) {
} }
} }
function getJoinStatusSeverity(value) {
switch (value) {
case 'pending':
return 'warn';
case 'approved':
return 'success';
case 'rejected':
return 'danger';
default:
return 'secondary';
}
}
async function ensureStatusOptionsLoaded() { async function ensureStatusOptionsLoaded() {
if (statusOptions.value.length > 0) return; if (statusOptions.value.length > 0) return;
statusOptionsLoading.value = true; statusOptionsLoading.value = true;
@@ -94,6 +142,30 @@ async function loadCreators() {
} }
} }
async function loadJoinRequests() {
joinRequestsLoading.value = true;
try {
const result = await CreatorService.listJoinRequests({
page: joinRequestsPage.value,
limit: joinRequestsRows.value,
tenant_id: joinTenantID.value || undefined,
tenant_code: joinTenantCode.value,
tenant_name: joinTenantName.value,
user_id: joinUserID.value || undefined,
username: joinUsername.value,
status: joinStatus.value || undefined,
created_at_from: joinCreatedAtFrom.value || undefined,
created_at_to: joinCreatedAtTo.value || undefined
});
joinRequests.value = result.items;
joinRequestsTotal.value = result.total;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载成员申请', life: 4000 });
} finally {
joinRequestsLoading.value = false;
}
}
function onSearch() { function onSearch() {
page.value = 1; page.value = 1;
loadCreators(); loadCreators();
@@ -126,6 +198,31 @@ function onSort(event) {
loadCreators(); loadCreators();
} }
function onJoinSearch() {
joinRequestsPage.value = 1;
loadJoinRequests();
}
function onJoinReset() {
joinTenantID.value = null;
joinTenantCode.value = '';
joinTenantName.value = '';
joinUserID.value = null;
joinUsername.value = '';
joinStatus.value = 'pending';
joinCreatedAtFrom.value = null;
joinCreatedAtTo.value = null;
joinRequestsPage.value = 1;
joinRequestsRows.value = 10;
loadJoinRequests();
}
function onJoinPage(event) {
joinRequestsPage.value = (event.page ?? 0) + 1;
joinRequestsRows.value = event.rows ?? joinRequestsRows.value;
loadJoinRequests();
}
async function openStatusDialog(tenant) { async function openStatusDialog(tenant) {
statusTenant.value = tenant; statusTenant.value = tenant;
statusValue.value = tenant?.status ?? null; statusValue.value = tenant?.status ?? null;
@@ -154,14 +251,82 @@ async function confirmUpdateStatus() {
} }
} }
function openReviewDialog(row, action) {
reviewTarget.value = row;
reviewAction.value = action || 'approve';
reviewReason.value = '';
reviewDialogVisible.value = true;
}
async function confirmReview() {
const targetID = reviewTarget.value?.id;
if (!targetID) return;
const reason = reviewReason.value.trim();
if (reviewAction.value === 'reject' && !reason) {
toast.add({ severity: 'warn', summary: '请输入原因', detail: '驳回时需填写原因', life: 3000 });
return;
}
reviewSubmitting.value = true;
try {
await CreatorService.reviewJoinRequest(targetID, { action: reviewAction.value, reason });
toast.add({ severity: 'success', summary: '审核完成', detail: `申请ID: ${targetID}`, life: 3000 });
reviewDialogVisible.value = false;
await loadJoinRequests();
} catch (error) {
toast.add({ severity: 'error', summary: '审核失败', detail: error?.message || '无法审核申请', life: 4000 });
} finally {
reviewSubmitting.value = false;
}
}
function openInviteDialog(row) {
inviteTenantID.value = Number(row?.tenant_id ?? row?.tenant?.id ?? 0) || null;
inviteMaxUses.value = 1;
inviteExpiresAt.value = null;
inviteRemark.value = '';
inviteDialogVisible.value = true;
}
async function confirmInvite() {
const tenantIDValue = inviteTenantID.value;
if (!tenantIDValue) {
toast.add({ severity: 'warn', summary: '请输入租户ID', detail: '租户ID不能为空', life: 3000 });
return;
}
inviteSubmitting.value = true;
try {
const result = await CreatorService.createInvite(tenantIDValue, {
max_uses: inviteMaxUses.value,
expires_at: inviteExpiresAt.value instanceof Date ? inviteExpiresAt.value.toISOString() : inviteExpiresAt.value || undefined,
remark: inviteRemark.value
});
toast.add({ severity: 'success', summary: '邀请已创建', detail: `邀请码: ${result?.code || '-'}`, life: 4000 });
inviteDialogVisible.value = false;
} catch (error) {
toast.add({ severity: 'error', summary: '创建失败', detail: error?.message || '无法创建邀请', life: 4000 });
} finally {
inviteSubmitting.value = false;
}
}
onMounted(() => { onMounted(() => {
loadCreators(); loadCreators();
ensureStatusOptionsLoaded().catch(() => {}); ensureStatusOptionsLoaded().catch(() => {});
loadJoinRequests();
}); });
</script> </script>
<template> <template>
<div class="card"> <div class="card">
<Tabs v-model:value="tabValue" value="creators">
<TabList>
<Tab value="creators">创作者列表</Tab>
<Tab value="members">成员审核</Tab>
</TabList>
<TabPanels>
<TabPanel value="creators">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h4 class="m-0">创作者列表</h4> <h4 class="m-0">创作者列表</h4>
</div> </div>
@@ -246,6 +411,111 @@ onMounted(() => {
</template> </template>
</Column> </Column>
</DataTable> </DataTable>
</TabPanel>
<TabPanel value="members">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h4 class="m-0">成员申请</h4>
<span class="text-muted-color">跨租户审核与邀请</span>
</div>
<Button label="创建邀请" icon="pi pi-link" severity="secondary" @click="openInviteDialog()" />
</div>
<SearchPanel :loading="joinRequestsLoading" @search="onJoinSearch" @reset="onJoinReset">
<SearchField label="TenantID">
<InputNumber v-model="joinTenantID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="TenantCode">
<InputText v-model="joinTenantCode" placeholder="模糊匹配" class="w-full" @keyup.enter="onJoinSearch" />
</SearchField>
<SearchField label="TenantName">
<InputText v-model="joinTenantName" placeholder="模糊匹配" class="w-full" @keyup.enter="onJoinSearch" />
</SearchField>
<SearchField label="UserID">
<InputNumber v-model="joinUserID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="Username">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="joinUsername" placeholder="模糊匹配" class="w-full" @keyup.enter="onJoinSearch" />
</IconField>
</SearchField>
<SearchField label="状态">
<Select v-model="joinStatus" :options="joinStatusOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
</SearchField>
<SearchField label="申请时间 From">
<DatePicker v-model="joinCreatedAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="申请时间 To">
<DatePicker v-model="joinCreatedAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
</SearchPanel>
<DataTable
:value="joinRequests"
dataKey="id"
:loading="joinRequestsLoading"
lazy
:paginator="true"
:rows="joinRequestsRows"
:totalRecords="joinRequestsTotal"
:first="(joinRequestsPage - 1) * joinRequestsRows"
:rowsPerPageOptions="[10, 20, 50, 100]"
@page="onJoinPage"
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
scrollable
scrollHeight="420px"
responsiveLayout="scroll"
>
<Column field="id" header="申请ID" style="min-width: 8rem" />
<Column header="租户" style="min-width: 16rem">
<template #body="{ data }">
<router-link v-if="data.tenant_id" class="inline-flex items-center gap-1 font-medium text-primary hover:underline" :to="`/superadmin/tenants/${data.tenant_id}`">
<span class="truncate max-w-[200px]">{{ data.tenant_name || data.tenant_code || '-' }}</span>
<i class="pi pi-external-link text-xs" />
</router-link>
<div class="text-xs text-muted-color">Code: {{ data.tenant_code || '-' }} / ID: {{ data.tenant_id ?? '-' }}</div>
</template>
</Column>
<Column header="申请用户" style="min-width: 14rem">
<template #body="{ data }">
<router-link v-if="data.user_id" class="inline-flex items-center gap-1 font-medium text-primary hover:underline" :to="`/superadmin/users/${data.user_id}`">
<span class="truncate max-w-[200px]">{{ data.username || `ID:${data.user_id}` }}</span>
<i class="pi pi-external-link text-xs" />
</router-link>
<div class="text-xs text-muted-color">ID: {{ data.user_id ?? '-' }}</div>
</template>
</Column>
<Column field="status" header="状态" style="min-width: 10rem">
<template #body="{ data }">
<Tag :value="data.status_description || data.status || '-'" :severity="getJoinStatusSeverity(data.status)" />
</template>
</Column>
<Column field="reason" header="申请说明" style="min-width: 16rem" />
<Column field="created_at" header="申请时间" style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column field="decided_at" header="处理时间" style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.decided_at) }}
</template>
</Column>
<Column header="操作" style="min-width: 12rem">
<template #body="{ data }">
<Button v-if="data.status === 'pending'" label="通过" icon="pi pi-check" text size="small" class="p-0 mr-3" @click="openReviewDialog(data, 'approve')" />
<Button v-if="data.status === 'pending'" label="驳回" icon="pi pi-times" severity="danger" text size="small" class="p-0 mr-3" @click="openReviewDialog(data, 'reject')" />
<Button label="邀请" icon="pi pi-link" text size="small" class="p-0" @click="openInviteDialog(data)" />
</template>
</Column>
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
</div> </div>
<Dialog v-model:visible="statusDialogVisible" :modal="true" :style="{ width: '420px' }"> <Dialog v-model:visible="statusDialogVisible" :modal="true" :style="{ width: '420px' }">
@@ -266,4 +536,67 @@ onMounted(() => {
<Button label="确认" icon="pi pi-check" @click="confirmUpdateStatus" :loading="statusUpdating" :disabled="!statusValue" /> <Button label="确认" icon="pi pi-check" @click="confirmUpdateStatus" :loading="statusUpdating" :disabled="!statusValue" />
</template> </template>
</Dialog> </Dialog>
<Dialog v-model:visible="reviewDialogVisible" :modal="true" :style="{ width: '520px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">成员申请审核</span>
<span class="text-muted-color">申请ID: {{ reviewTarget?.id ?? '-' }}</span>
</div>
</template>
<div class="flex flex-col gap-4">
<div class="text-sm text-muted-color">审核租户成员申请请确认处理动作与备注</div>
<div>
<label class="block font-medium mb-2">审核动作</label>
<Select
v-model="reviewAction"
:options="[
{ label: '通过', value: 'approve' },
{ label: '驳回', value: 'reject' }
]"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div>
<label class="block font-medium mb-2">审核说明</label>
<InputText v-model="reviewReason" placeholder="驳回时建议填写原因" class="w-full" />
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="reviewDialogVisible = false" :disabled="reviewSubmitting" />
<Button label="确认审核" icon="pi pi-check" severity="success" @click="confirmReview" :loading="reviewSubmitting" :disabled="reviewSubmitting || (reviewAction === 'reject' && !reviewReason.trim())" />
</template>
</Dialog>
<Dialog v-model:visible="inviteDialogVisible" :modal="true" :style="{ width: '520px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">创建成员邀请</span>
</div>
</template>
<div class="flex flex-col gap-4">
<div>
<label class="block font-medium mb-2">TenantID</label>
<InputNumber v-model="inviteTenantID" :min="1" placeholder="请输入租户ID" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">最大使用次数</label>
<InputNumber v-model="inviteMaxUses" :min="1" placeholder="默认 1 次" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">过期时间</label>
<DatePicker v-model="inviteExpiresAt" showIcon showButtonBar placeholder="默认 7 天" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">备注</label>
<InputText v-model="inviteRemark" placeholder="可选备注" class="w-full" />
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="inviteDialogVisible = false" :disabled="inviteSubmitting" />
<Button label="确认创建" icon="pi pi-check" @click="confirmInvite" :loading="inviteSubmitting" />
</template>
</Dialog>
</template> </template>