feat: add TenantDetail and UserDetail views with comprehensive functionality
- Implemented TenantDetail.vue to display tenant information, manage tenant status, and handle tenant renewals. - Added user management features in UserDetail.vue, including user status updates and role management. - Integrated data loading for tenant users and orders in TenantDetail.vue. - Included search and pagination functionalities for owned and joined tenants in UserDetail.vue. - Enhanced user experience with toast notifications for success and error messages.
This commit is contained in:
@@ -82,6 +82,11 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
r.tenant.list,
|
||||
Query[dto.TenantFilter]("filter"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID<int> -> tenant.detail")
|
||||
router.Get("/super/v1/tenants/:tenantID<int>"[len(r.Path()):], DataFunc1(
|
||||
r.tenant.detail,
|
||||
PathParam[int64]("tenantID"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID<int>/users -> tenant.users")
|
||||
router.Get("/super/v1/tenants/:tenantID<int>/users"[len(r.Path()):], DataFunc2(
|
||||
r.tenant.users,
|
||||
@@ -115,6 +120,11 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
r.user.list,
|
||||
Query[dto.UserPageFilter]("filter"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /super/v1/users/:userID<int> -> user.detail")
|
||||
router.Get("/super/v1/users/:userID<int>"[len(r.Path()):], DataFunc1(
|
||||
r.user.detail,
|
||||
PathParam[int64]("userID"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /super/v1/users/:userID<int>/tenants -> user.tenants")
|
||||
router.Get("/super/v1/users/:userID<int>/tenants"[len(r.Path()):], DataFunc2(
|
||||
r.user.tenants,
|
||||
|
||||
@@ -15,6 +15,21 @@ import (
|
||||
// @provider
|
||||
type tenant struct{}
|
||||
|
||||
// detail
|
||||
//
|
||||
// @Summary 租户详情
|
||||
// @Tags Super
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param tenantID path int64 true "TenantID"
|
||||
// @Success 200 {object} dto.TenantItem
|
||||
//
|
||||
// @Router /super/v1/tenants/:tenantID<int> [get]
|
||||
// @Bind tenantID path
|
||||
func (*tenant) detail(ctx fiber.Ctx, tenantID int64) (*dto.TenantItem, error) {
|
||||
return services.Tenant.SuperDetail(ctx, tenantID)
|
||||
}
|
||||
|
||||
// list
|
||||
//
|
||||
// @Summary 租户列表
|
||||
|
||||
@@ -13,6 +13,21 @@ import (
|
||||
// @provider
|
||||
type user struct{}
|
||||
|
||||
// detail
|
||||
//
|
||||
// @Summary 用户详情
|
||||
// @Tags Super
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param userID path int64 true "UserID"
|
||||
// @Success 200 {object} dto.UserItem
|
||||
//
|
||||
// @Router /super/v1/users/:userID<int> [get]
|
||||
// @Bind userID path
|
||||
func (*user) detail(ctx fiber.Ctx, userID int64) (*dto.UserItem, error) {
|
||||
return services.User.Detail(ctx, userID)
|
||||
}
|
||||
|
||||
// list
|
||||
//
|
||||
// @Summary 用户列表
|
||||
|
||||
@@ -26,6 +26,49 @@ import (
|
||||
// @provider
|
||||
type tenant struct{}
|
||||
|
||||
// SuperDetail 查询单个租户详情(平台侧)。
|
||||
func (t *tenant) SuperDetail(ctx context.Context, tenantID int64) (*superdto.TenantItem, error) {
|
||||
if tenantID <= 0 {
|
||||
return nil, errors.New("tenant_id must be > 0")
|
||||
}
|
||||
|
||||
tbl, query := models.TenantQuery.QueryContext(ctx)
|
||||
m, err := query.Where(tbl.ID.Eq(tenantID)).First()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userCountMapping, err := t.TenantUserCountMapping(ctx, []int64{m.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
incomeMapping, err := t.TenantIncomePaidMapping(ctx, []int64{m.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
item := &superdto.TenantItem{
|
||||
Tenant: m,
|
||||
UserCount: lo.ValueOr(userCountMapping, m.ID, 0),
|
||||
IncomeAmountPaidSum: lo.ValueOr(incomeMapping, m.ID, 0),
|
||||
StatusDescription: m.Status.Description(),
|
||||
}
|
||||
|
||||
ownerMapping, err := t.TenantOwnerUserMapping(ctx, []*models.Tenant{m})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Owner = ownerMapping[m.ID]
|
||||
|
||||
adminMapping, err := t.TenantAdminUsersMapping(ctx, []int64{m.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.AdminUsers = adminMapping[m.ID]
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// SuperCreateTenant 超级管理员创建租户,并将指定用户设为租户管理员。
|
||||
func (t *tenant) SuperCreateTenant(ctx context.Context, form *superdto.TenantCreateForm) (*models.Tenant, error) {
|
||||
if form == nil {
|
||||
|
||||
@@ -41,6 +41,42 @@ func (t *user) FindByUsername(ctx context.Context, username string) (*models.Use
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// Detail 查询用户详情(超级管理员侧,返回脱敏后的 DTO)。
|
||||
func (t *user) Detail(ctx context.Context, userID int64) (*dto.UserItem, error) {
|
||||
if userID <= 0 {
|
||||
return nil, errors.New("user_id must be > 0")
|
||||
}
|
||||
|
||||
model, err := t.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ownedTenantCounts, err := t.UserOwnedTenantCountMapping(ctx, []int64{model.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
joinedTenantCounts, err := t.UserJoinedTenantCountMapping(ctx, []int64{model.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.UserItem{
|
||||
ID: model.ID,
|
||||
Username: model.Username,
|
||||
Roles: model.Roles,
|
||||
Status: model.Status,
|
||||
StatusDescription: model.Status.Description(),
|
||||
Balance: model.Balance,
|
||||
BalanceFrozen: model.BalanceFrozen,
|
||||
VerifiedAt: model.VerifiedAt,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
OwnedTenantCount: ownedTenantCounts[model.ID],
|
||||
JoinedTenantCount: joinedTenantCounts[model.ID],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *user) Create(ctx context.Context, user *models.User) (*models.User, error) {
|
||||
if err := user.Create(ctx); err != nil {
|
||||
return nil, errors.Wrapf(err, "Create user failed, %s", user.Username)
|
||||
|
||||
1003
backend/docs/docs.go
1003
backend/docs/docs.go
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -585,6 +585,44 @@ definitions:
|
||||
description: TypeDescription 流水类型中文说明(用于前端展示)。
|
||||
type: string
|
||||
type: object
|
||||
dto.OrderBuyerLite:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
dto.OrderStatisticsResponse:
|
||||
properties:
|
||||
by_status:
|
||||
items:
|
||||
$ref: '#/definitions/dto.OrderStatisticsRow'
|
||||
type: array
|
||||
total_amount_paid_sum:
|
||||
type: integer
|
||||
total_count:
|
||||
type: integer
|
||||
type: object
|
||||
dto.OrderStatisticsRow:
|
||||
properties:
|
||||
amount_paid_sum:
|
||||
type: integer
|
||||
count:
|
||||
type: integer
|
||||
status:
|
||||
$ref: '#/definitions/consts.OrderStatus'
|
||||
status_description:
|
||||
type: string
|
||||
type: object
|
||||
dto.OrderTenantLite:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
dto.PurchaseContentForm:
|
||||
properties:
|
||||
idempotency_key:
|
||||
@@ -613,6 +651,119 @@ definitions:
|
||||
description: Order is the created or existing order record (may be nil for
|
||||
owner/free-path without order).
|
||||
type: object
|
||||
dto.SuperOrderDetail:
|
||||
properties:
|
||||
buyer:
|
||||
$ref: '#/definitions/dto.OrderBuyerLite'
|
||||
order:
|
||||
$ref: '#/definitions/models.Order'
|
||||
tenant:
|
||||
$ref: '#/definitions/dto.OrderTenantLite'
|
||||
type: object
|
||||
dto.SuperOrderItem:
|
||||
properties:
|
||||
amount_discount:
|
||||
type: integer
|
||||
amount_original:
|
||||
type: integer
|
||||
amount_paid:
|
||||
type: integer
|
||||
buyer:
|
||||
$ref: '#/definitions/dto.OrderBuyerLite'
|
||||
created_at:
|
||||
type: string
|
||||
currency:
|
||||
$ref: '#/definitions/consts.Currency'
|
||||
id:
|
||||
type: integer
|
||||
paid_at:
|
||||
type: string
|
||||
refunded_at:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/definitions/consts.OrderStatus'
|
||||
status_description:
|
||||
type: string
|
||||
tenant:
|
||||
$ref: '#/definitions/dto.OrderTenantLite'
|
||||
type:
|
||||
$ref: '#/definitions/consts.OrderType'
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
dto.SuperOrderRefundForm:
|
||||
properties:
|
||||
force:
|
||||
description: Force indicates bypassing the default refund window check (paid_at
|
||||
+ 24h).
|
||||
type: boolean
|
||||
idempotency_key:
|
||||
description: IdempotencyKey ensures refund request is processed at most once.
|
||||
type: string
|
||||
reason:
|
||||
description: Reason is the human-readable refund reason used for audit.
|
||||
type: string
|
||||
type: object
|
||||
dto.SuperTenantUserItem:
|
||||
properties:
|
||||
tenant_user:
|
||||
$ref: '#/definitions/models.TenantUser'
|
||||
user:
|
||||
$ref: '#/definitions/dto.SuperUserLite'
|
||||
type: object
|
||||
dto.SuperUserLite:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
roles:
|
||||
items:
|
||||
$ref: '#/definitions/consts.Role'
|
||||
type: array
|
||||
status:
|
||||
$ref: '#/definitions/consts.UserStatus'
|
||||
status_description:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
verified_at:
|
||||
type: string
|
||||
type: object
|
||||
dto.TenantAdminUserLite:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
dto.TenantCreateForm:
|
||||
properties:
|
||||
admin_user_id:
|
||||
type: integer
|
||||
code:
|
||||
maxLength: 64
|
||||
type: string
|
||||
duration:
|
||||
description: Duration 租户有效期(天),从“创建时刻”起算;与续期接口保持一致。
|
||||
enum:
|
||||
- 7
|
||||
- 30
|
||||
- 90
|
||||
- 180
|
||||
- 365
|
||||
type: integer
|
||||
name:
|
||||
maxLength: 128
|
||||
type: string
|
||||
required:
|
||||
- admin_user_id
|
||||
- code
|
||||
- duration
|
||||
- name
|
||||
type: object
|
||||
dto.TenantExpireUpdateForm:
|
||||
properties:
|
||||
duration:
|
||||
@@ -628,6 +779,10 @@ definitions:
|
||||
type: object
|
||||
dto.TenantItem:
|
||||
properties:
|
||||
admin_users:
|
||||
items:
|
||||
$ref: '#/definitions/dto.TenantAdminUserLite'
|
||||
type: array
|
||||
code:
|
||||
type: string
|
||||
config:
|
||||
@@ -640,16 +795,19 @@ definitions:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
income_amount_paid_sum:
|
||||
description: IncomeAmountPaidSum 累计收入金额(单位:分,CNY):按 orders 聚合得到的已支付净收入(不含退款中/已退款订单)。
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
owner:
|
||||
$ref: '#/definitions/dto.TenantOwnerUserLite'
|
||||
status:
|
||||
$ref: '#/definitions/consts.TenantStatus'
|
||||
status_description:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
user_balance:
|
||||
type: integer
|
||||
user_count:
|
||||
type: integer
|
||||
user_id:
|
||||
@@ -661,6 +819,13 @@ definitions:
|
||||
uuid:
|
||||
type: string
|
||||
type: object
|
||||
dto.TenantOwnerUserLite:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
dto.TenantStatusUpdateForm:
|
||||
properties:
|
||||
status:
|
||||
@@ -675,25 +840,17 @@ definitions:
|
||||
dto.UserItem:
|
||||
properties:
|
||||
balance:
|
||||
description: 全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0
|
||||
type: integer
|
||||
balance_frozen:
|
||||
description: 全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
deleted_at:
|
||||
$ref: '#/definitions/gorm.DeletedAt'
|
||||
id:
|
||||
type: integer
|
||||
metas:
|
||||
items:
|
||||
joined_tenant_count:
|
||||
type: integer
|
||||
owned_tenant_count:
|
||||
type: integer
|
||||
type: array
|
||||
owned:
|
||||
$ref: '#/definitions/models.Tenant'
|
||||
password:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
$ref: '#/definitions/consts.Role'
|
||||
@@ -702,10 +859,6 @@ definitions:
|
||||
$ref: '#/definitions/consts.UserStatus'
|
||||
status_description:
|
||||
type: string
|
||||
tenants:
|
||||
items:
|
||||
$ref: '#/definitions/models.Tenant'
|
||||
type: array
|
||||
updated_at:
|
||||
type: string
|
||||
username:
|
||||
@@ -713,6 +866,16 @@ definitions:
|
||||
verified_at:
|
||||
type: string
|
||||
type: object
|
||||
dto.UserRolesUpdateForm:
|
||||
properties:
|
||||
roles:
|
||||
items:
|
||||
$ref: '#/definitions/consts.Role'
|
||||
minItems: 1
|
||||
type: array
|
||||
required:
|
||||
- roles
|
||||
type: object
|
||||
dto.UserStatistics:
|
||||
properties:
|
||||
count:
|
||||
@@ -733,6 +896,33 @@ definitions:
|
||||
required:
|
||||
- status
|
||||
type: object
|
||||
dto.UserTenantItem:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
expired_at:
|
||||
type: string
|
||||
joined_at:
|
||||
type: string
|
||||
member_status:
|
||||
$ref: '#/definitions/consts.UserStatus'
|
||||
member_status_description:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
owner:
|
||||
$ref: '#/definitions/dto.TenantOwnerUserLite'
|
||||
role:
|
||||
items:
|
||||
$ref: '#/definitions/consts.TenantUserRole'
|
||||
type: array
|
||||
tenant_id:
|
||||
type: integer
|
||||
tenant_status:
|
||||
$ref: '#/definitions/consts.TenantStatus'
|
||||
tenant_status_description:
|
||||
type: string
|
||||
type: object
|
||||
gorm.DeletedAt:
|
||||
properties:
|
||||
time:
|
||||
@@ -1342,6 +1532,173 @@ paths:
|
||||
$ref: '#/definitions/dto.LoginResponse'
|
||||
tags:
|
||||
- Super
|
||||
/super/v1/orders:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- in: query
|
||||
name: amount_paid_max
|
||||
type: integer
|
||||
- in: query
|
||||
name: amount_paid_min
|
||||
type: integer
|
||||
- description: Asc specifies comma-separated field names to sort ascending by.
|
||||
in: query
|
||||
name: asc
|
||||
type: string
|
||||
- in: query
|
||||
name: content_id
|
||||
type: integer
|
||||
- in: query
|
||||
name: content_title
|
||||
type: string
|
||||
- in: query
|
||||
name: created_at_from
|
||||
type: string
|
||||
- in: query
|
||||
name: created_at_to
|
||||
type: string
|
||||
- description: Desc specifies comma-separated field names to sort descending
|
||||
by.
|
||||
in: query
|
||||
name: desc
|
||||
type: string
|
||||
- in: query
|
||||
name: id
|
||||
type: integer
|
||||
- description: Limit is page size; only values in {10,20,50,100} are accepted
|
||||
(otherwise defaults to 10).
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
- description: Page is 1-based page index; values <= 0 are normalized to 1.
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- in: query
|
||||
name: paid_at_from
|
||||
type: string
|
||||
- in: query
|
||||
name: paid_at_to
|
||||
type: string
|
||||
- enum:
|
||||
- created
|
||||
- paid
|
||||
- refunding
|
||||
- refunded
|
||||
- canceled
|
||||
- failed
|
||||
in: query
|
||||
name: status
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- OrderStatusCreated
|
||||
- OrderStatusPaid
|
||||
- OrderStatusRefunding
|
||||
- OrderStatusRefunded
|
||||
- OrderStatusCanceled
|
||||
- OrderStatusFailed
|
||||
- in: query
|
||||
name: tenant_code
|
||||
type: string
|
||||
- in: query
|
||||
name: tenant_id
|
||||
type: integer
|
||||
- in: query
|
||||
name: tenant_name
|
||||
type: string
|
||||
- enum:
|
||||
- content_purchase
|
||||
in: query
|
||||
name: type
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- OrderTypeContentPurchase
|
||||
- in: query
|
||||
name: user_id
|
||||
type: integer
|
||||
- in: query
|
||||
name: username
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/requests.Pager'
|
||||
- properties:
|
||||
items:
|
||||
$ref: '#/definitions/dto.SuperOrderItem'
|
||||
type: object
|
||||
summary: 订单列表
|
||||
tags:
|
||||
- Super
|
||||
/super/v1/orders/{orderID}:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: OrderID
|
||||
format: int64
|
||||
in: path
|
||||
name: orderID
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/dto.SuperOrderDetail'
|
||||
summary: 订单详情
|
||||
tags:
|
||||
- Super
|
||||
/super/v1/orders/{orderID}/refund:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 该接口只负责将订单从 paid 推进到 refunding,并提交异步退款任务;退款入账与权益回收由 worker 异步完成。
|
||||
parameters:
|
||||
- description: OrderID
|
||||
format: int64
|
||||
in: path
|
||||
name: orderID
|
||||
required: true
|
||||
type: integer
|
||||
- description: Form
|
||||
in: body
|
||||
name: form
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/dto.SuperOrderRefundForm'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.Order'
|
||||
summary: 订单退款(平台)
|
||||
tags:
|
||||
- Super
|
||||
/super/v1/orders/statistics:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/dto.OrderStatisticsResponse'
|
||||
summary: 订单统计信息
|
||||
tags:
|
||||
- Super
|
||||
/super/v1/tenants:
|
||||
get:
|
||||
consumes:
|
||||
@@ -1351,11 +1708,29 @@ paths:
|
||||
in: query
|
||||
name: asc
|
||||
type: string
|
||||
- in: query
|
||||
name: code
|
||||
type: string
|
||||
- in: query
|
||||
name: created_at_from
|
||||
type: string
|
||||
- in: query
|
||||
name: created_at_to
|
||||
type: string
|
||||
- description: Desc specifies comma-separated field names to sort descending
|
||||
by.
|
||||
in: query
|
||||
name: desc
|
||||
type: string
|
||||
- in: query
|
||||
name: expired_at_from
|
||||
type: string
|
||||
- in: query
|
||||
name: expired_at_to
|
||||
type: string
|
||||
- in: query
|
||||
name: id
|
||||
type: integer
|
||||
- description: Limit is page size; only values in {10,20,50,100} are accepted
|
||||
(otherwise defaults to 10).
|
||||
in: query
|
||||
@@ -1379,6 +1754,9 @@ paths:
|
||||
- TenantStatusPendingVerify
|
||||
- TenantStatusVerified
|
||||
- TenantStatusBanned
|
||||
- in: query
|
||||
name: user_id
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1394,7 +1772,47 @@ paths:
|
||||
summary: 租户列表
|
||||
tags:
|
||||
- Super
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Form
|
||||
in: body
|
||||
name: form
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/dto.TenantCreateForm'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.Tenant'
|
||||
summary: 创建租户并设置租户管理员
|
||||
tags:
|
||||
- Super
|
||||
/super/v1/tenants/{tenantID}:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: TenantID
|
||||
format: int64
|
||||
in: path
|
||||
name: tenantID
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/dto.TenantItem'
|
||||
summary: 租户详情
|
||||
tags:
|
||||
- Super
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
@@ -1440,6 +1858,71 @@ paths:
|
||||
summary: 更新租户状态
|
||||
tags:
|
||||
- Super
|
||||
/super/v1/tenants/{tenantID}/users:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: TenantID
|
||||
format: int64
|
||||
in: path
|
||||
name: tenantID
|
||||
required: true
|
||||
type: integer
|
||||
- description: Limit is page size; only values in {10,20,50,100} are accepted
|
||||
(otherwise defaults to 10).
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
- description: Page is 1-based page index; values <= 0 are normalized to 1.
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- description: Role 按角色过滤(可选):member/tenant_admin。
|
||||
enum:
|
||||
- member
|
||||
- tenant_admin
|
||||
in: query
|
||||
name: role
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- TenantUserRoleMember
|
||||
- TenantUserRoleTenantAdmin
|
||||
- description: Status 按成员状态过滤(可选):pending_verify/verified/banned。
|
||||
enum:
|
||||
- pending_verify
|
||||
- verified
|
||||
- banned
|
||||
in: query
|
||||
name: status
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- UserStatusPendingVerify
|
||||
- UserStatusVerified
|
||||
- UserStatusBanned
|
||||
- description: UserID 按用户ID过滤(可选)。
|
||||
in: query
|
||||
name: user_id
|
||||
type: integer
|
||||
- description: Username 按用户名模糊查询(可选,支持包含匹配)。
|
||||
in: query
|
||||
name: username
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/requests.Pager'
|
||||
- properties:
|
||||
items:
|
||||
$ref: '#/definitions/dto.SuperTenantUserItem'
|
||||
type: object
|
||||
summary: 租户成员列表(平台侧)
|
||||
tags:
|
||||
- Super
|
||||
/super/v1/tenants/statuses:
|
||||
get:
|
||||
consumes:
|
||||
@@ -1465,11 +1948,20 @@ paths:
|
||||
in: query
|
||||
name: asc
|
||||
type: string
|
||||
- in: query
|
||||
name: created_at_from
|
||||
type: string
|
||||
- in: query
|
||||
name: created_at_to
|
||||
type: string
|
||||
- description: Desc specifies comma-separated field names to sort descending
|
||||
by.
|
||||
in: query
|
||||
name: desc
|
||||
type: string
|
||||
- in: query
|
||||
name: id
|
||||
type: integer
|
||||
- description: Limit is page size; only values in {10,20,50,100} are accepted
|
||||
(otherwise defaults to 10).
|
||||
in: query
|
||||
@@ -1479,6 +1971,16 @@ paths:
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- description: Role filters users containing a role (user/super_admin).
|
||||
enum:
|
||||
- user
|
||||
- super_admin
|
||||
in: query
|
||||
name: role
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- RoleUser
|
||||
- RoleSuperAdmin
|
||||
- enum:
|
||||
- pending_verify
|
||||
- verified
|
||||
@@ -1490,12 +1992,19 @@ paths:
|
||||
- UserStatusPendingVerify
|
||||
- UserStatusVerified
|
||||
- UserStatusBanned
|
||||
- in: query
|
||||
name: tenantID
|
||||
- description: TenantID filters users by membership in the given tenant.
|
||||
in: query
|
||||
name: tenant_id
|
||||
type: integer
|
||||
- in: query
|
||||
name: username
|
||||
type: string
|
||||
- in: query
|
||||
name: verified_at_from
|
||||
type: string
|
||||
- in: query
|
||||
name: verified_at_to
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1508,7 +2017,51 @@ paths:
|
||||
items:
|
||||
$ref: '#/definitions/dto.UserItem'
|
||||
type: object
|
||||
summary: 租户列表
|
||||
summary: 用户列表
|
||||
tags:
|
||||
- Super
|
||||
/super/v1/users/{userID}:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: UserID
|
||||
format: int64
|
||||
in: path
|
||||
name: userID
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/dto.UserItem'
|
||||
summary: 用户详情
|
||||
tags:
|
||||
- Super
|
||||
/super/v1/users/{userID}/roles:
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: UserID
|
||||
format: int64
|
||||
in: path
|
||||
name: userID
|
||||
required: true
|
||||
type: integer
|
||||
- description: Form
|
||||
in: body
|
||||
name: form
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/dto.UserRolesUpdateForm'
|
||||
produces:
|
||||
- application/json
|
||||
responses: {}
|
||||
summary: 更新用户角色
|
||||
tags:
|
||||
- Super
|
||||
/super/v1/users/{userID}/status:
|
||||
@@ -1534,6 +2087,78 @@ paths:
|
||||
summary: 更新用户状态
|
||||
tags:
|
||||
- Super
|
||||
/super/v1/users/{userID}/tenants:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: UserID
|
||||
format: int64
|
||||
in: path
|
||||
name: userID
|
||||
required: true
|
||||
type: integer
|
||||
- in: query
|
||||
name: code
|
||||
type: string
|
||||
- in: query
|
||||
name: created_at_from
|
||||
type: string
|
||||
- in: query
|
||||
name: created_at_to
|
||||
type: string
|
||||
- description: Limit is page size; only values in {10,20,50,100} are accepted
|
||||
(otherwise defaults to 10).
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
- in: query
|
||||
name: name
|
||||
type: string
|
||||
- description: Page is 1-based page index; values <= 0 are normalized to 1.
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- description: Role filters tenant_users.role containing a role (tenant_admin/member).
|
||||
enum:
|
||||
- member
|
||||
- tenant_admin
|
||||
in: query
|
||||
name: role
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- TenantUserRoleMember
|
||||
- TenantUserRoleTenantAdmin
|
||||
- description: Status filters tenant_users.status.
|
||||
enum:
|
||||
- pending_verify
|
||||
- verified
|
||||
- banned
|
||||
in: query
|
||||
name: status
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- UserStatusPendingVerify
|
||||
- UserStatusVerified
|
||||
- UserStatusBanned
|
||||
- in: query
|
||||
name: tenant_id
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/requests.Pager'
|
||||
- properties:
|
||||
items:
|
||||
$ref: '#/definitions/dto.UserTenantItem'
|
||||
type: object
|
||||
summary: 用户加入的租户列表
|
||||
tags:
|
||||
- Super
|
||||
/super/v1/users/statistics:
|
||||
get:
|
||||
consumes:
|
||||
|
||||
4
frontend/superadmin/dist/index.html
vendored
4
frontend/superadmin/dist/index.html
vendored
@@ -7,8 +7,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sakai Vue</title>
|
||||
<link href="https://fonts.cdnfonts.com/css/lato" rel="stylesheet">
|
||||
<script type="module" crossorigin src="./assets/index-D_TMcKRC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-gxWMay_c.css">
|
||||
<script type="module" crossorigin src="./assets/index-0nqd4PcY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DMfJH3M_.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -119,15 +119,30 @@ const router = createRouter({
|
||||
name: 'superadmin-tenants',
|
||||
component: () => import('@/views/superadmin/Tenants.vue')
|
||||
},
|
||||
{
|
||||
path: '/superadmin/tenants/:tenantID',
|
||||
name: 'superadmin-tenant-detail',
|
||||
component: () => import('@/views/superadmin/TenantDetail.vue')
|
||||
},
|
||||
{
|
||||
path: '/superadmin/users',
|
||||
name: 'superadmin-users',
|
||||
component: () => import('@/views/superadmin/Users.vue')
|
||||
},
|
||||
{
|
||||
path: '/superadmin/users/:userID',
|
||||
name: 'superadmin-user-detail',
|
||||
component: () => import('@/views/superadmin/UserDetail.vue')
|
||||
},
|
||||
{
|
||||
path: '/superadmin/orders',
|
||||
name: 'superadmin-orders',
|
||||
component: () => import('@/views/superadmin/Orders.vue')
|
||||
},
|
||||
{
|
||||
path: '/superadmin/orders/:orderID',
|
||||
name: 'superadmin-order-detail',
|
||||
component: () => import('@/views/superadmin/OrderDetail.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -86,5 +86,9 @@ export const TenantService = {
|
||||
method: 'PATCH',
|
||||
body: { status }
|
||||
});
|
||||
},
|
||||
async getTenantDetail(tenantID) {
|
||||
if (!tenantID) throw new Error('tenantID is required');
|
||||
return requestJson(`/super/v1/tenants/${tenantID}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,6 +94,10 @@ export const UserService = {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async getUserDetail(userID) {
|
||||
if (!userID) throw new Error('userID is required');
|
||||
return requestJson(`/super/v1/users/${userID}`);
|
||||
},
|
||||
async listUserTenants(
|
||||
userID,
|
||||
{ page, limit, tenant_id, code, name, role, status, created_at_from, created_at_to } = {}
|
||||
|
||||
197
frontend/superadmin/src/views/superadmin/OrderDetail.vue
Normal file
197
frontend/superadmin/src/views/superadmin/OrderDetail.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script setup>
|
||||
import { OrderService } from '@/service/OrderService';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
|
||||
const orderID = computed(() => Number(route.params.orderID));
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = ref(null);
|
||||
|
||||
const refundDialogVisible = ref(false);
|
||||
const refundLoading = ref(false);
|
||||
const refundForce = ref(false);
|
||||
const refundReason = ref('');
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
if (String(value).startsWith('0001-01-01')) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatCny(amountInCents) {
|
||||
const amount = Number(amountInCents) / 100;
|
||||
if (!Number.isFinite(amount)) return '-';
|
||||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
|
||||
}
|
||||
|
||||
function getOrderStatusSeverity(value) {
|
||||
switch (value) {
|
||||
case 'paid':
|
||||
return 'success';
|
||||
case 'created':
|
||||
case 'refunding':
|
||||
return 'warn';
|
||||
case 'failed':
|
||||
case 'canceled':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function safeJson(value) {
|
||||
try {
|
||||
return JSON.stringify(value ?? null, null, 2);
|
||||
} catch {
|
||||
return String(value ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
const id = orderID.value;
|
||||
if (!id || Number.isNaN(id)) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await OrderService.getOrderDetail(id);
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载订单详情', life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openRefundDialog() {
|
||||
refundDialogVisible.value = true;
|
||||
refundForce.value = false;
|
||||
refundReason.value = '';
|
||||
}
|
||||
|
||||
async function confirmRefund() {
|
||||
const id = orderID.value;
|
||||
if (!id) return;
|
||||
refundLoading.value = true;
|
||||
try {
|
||||
await OrderService.refundOrder(id, { force: refundForce.value, reason: refundReason.value });
|
||||
toast.add({ severity: 'success', summary: '已提交退款', detail: `订单ID: ${id}`, life: 3000 });
|
||||
refundDialogVisible.value = false;
|
||||
await loadDetail();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '退款失败', detail: error?.message || '无法发起退款', life: 4000 });
|
||||
} finally {
|
||||
refundLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => orderID.value,
|
||||
() => loadDetail(),
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex flex-col">
|
||||
<h4 class="m-0">订单详情</h4>
|
||||
<span class="text-muted-color">OrderID: {{ orderID || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button label="退款" icon="pi pi-replay" severity="danger" @click="openRefundDialog" :disabled="detail?.order?.status !== 'paid' || loading" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-10">
|
||||
<ProgressSpinner style="width: 36px; height: 36px" strokeWidth="6" />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-sm text-muted-color">租户</div>
|
||||
<div class="font-medium">{{ detail?.tenant?.name ?? '-' }}</div>
|
||||
<div class="text-sm text-muted-color">{{ detail?.tenant?.code ?? '-' }} / {{ detail?.tenant?.id ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-sm text-muted-color">买家</div>
|
||||
<div class="font-medium">{{ detail?.buyer?.username ?? '-' }}</div>
|
||||
<div class="text-sm text-muted-color">ID: {{ detail?.buyer?.id ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div class="text-sm text-muted-color">状态</div>
|
||||
<Tag :value="detail?.order?.status_description || detail?.order?.status || '-'" :severity="getOrderStatusSeverity(detail?.order?.status)" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div class="text-sm text-muted-color">实付</div>
|
||||
<div class="font-medium">{{ formatCny(detail?.order?.amount_paid) }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div class="text-sm text-muted-color">创建时间</div>
|
||||
<div class="font-medium">{{ formatDate(detail?.order?.created_at) }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div class="text-sm text-muted-color">支付时间</div>
|
||||
<div class="font-medium">{{ formatDate(detail?.order?.paid_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-sm text-muted-color mb-2">订单快照(snapshot)</div>
|
||||
<pre class="text-xs whitespace-pre-wrap bg-surface-50 border border-surface-200 rounded-md p-3 max-h-[420px] overflow-auto">{{ safeJson(detail?.order?.snapshot) }}</pre>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-sm text-muted-color mb-2">订单明细(items)</div>
|
||||
<DataTable :value="detail?.order?.items || []" dataKey="id" responsiveLayout="scroll" scrollable scrollHeight="420px">
|
||||
<Column field="id" header="ItemID" style="min-width: 7rem" />
|
||||
<Column field="content_id" header="ContentID" style="min-width: 8rem" />
|
||||
<Column field="amount_paid" header="金额" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatCny(data.amount_paid) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="snapshot" header="内容快照" style="min-width: 24rem">
|
||||
<template #body="{ data }">
|
||||
<pre class="text-xs whitespace-pre-wrap bg-surface-50 border border-surface-200 rounded-md p-2 max-h-[220px] overflow-auto">{{ safeJson(data.snapshot) }}</pre>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="refundDialogVisible" :modal="true" :style="{ width: '520px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">发起退款</span>
|
||||
<span class="text-muted-color truncate max-w-[280px]">{{ orderID ? `#${orderID}` : '' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-sm text-muted-color">
|
||||
该操作会将订单从 <span class="font-medium">paid</span> 推进到 <span class="font-medium">refunding</span> 并提交异步退款任务。
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="refundForce" inputId="refundForce" binary />
|
||||
<label for="refundForce" class="cursor-pointer">强制退款(绕过默认时间窗)</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-medium mb-2">退款原因</label>
|
||||
<InputText v-model="refundReason" placeholder="可选,用于审计" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="取消" icon="pi pi-times" text @click="refundDialogVisible = false" :disabled="refundLoading" />
|
||||
<Button label="确认退款" icon="pi pi-check" severity="danger" @click="confirmRefund" :loading="refundLoading" :disabled="detail?.order?.status !== 'paid'" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -10,10 +10,6 @@ const toast = useToast();
|
||||
const orders = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const detailDialogVisible = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detail = ref(null);
|
||||
|
||||
const refundDialogVisible = ref(false);
|
||||
const refundLoading = ref(false);
|
||||
const refundOrder = ref(null);
|
||||
@@ -87,14 +83,6 @@ function getOrderStatusSeverity(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function safeJson(value) {
|
||||
try {
|
||||
return JSON.stringify(value ?? null, null, 2);
|
||||
} catch {
|
||||
return String(value ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -129,24 +117,6 @@ async function loadOrders() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetailDialog(order) {
|
||||
const id = order?.id;
|
||||
if (!id) return;
|
||||
|
||||
detailDialogVisible.value = true;
|
||||
detailLoading.value = true;
|
||||
detail.value = null;
|
||||
|
||||
try {
|
||||
detail.value = await OrderService.getOrderDetail(id);
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载订单详情', life: 4000 });
|
||||
detailDialogVisible.value = false;
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openRefundDialog(order) {
|
||||
refundOrder.value = order;
|
||||
refundDialogVisible.value = true;
|
||||
@@ -164,9 +134,6 @@ async function confirmRefund() {
|
||||
toast.add({ severity: 'success', summary: '已提交退款', detail: `订单ID: ${id}`, life: 3000 });
|
||||
refundDialogVisible.value = false;
|
||||
await loadOrders();
|
||||
if (detailDialogVisible.value && detail.value?.order?.id === id) {
|
||||
detail.value = await OrderService.getOrderDetail(id);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '退款失败', detail: error?.message || '无法发起退款', life: 4000 });
|
||||
} finally {
|
||||
@@ -303,8 +270,8 @@ loadOrders();
|
||||
>
|
||||
<Column field="id" header="ID" sortable style="min-width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<Button label="详情" icon="pi pi-search" text size="small" class="p-0 mr-2" @click="openDetailDialog(data)" />
|
||||
<span>{{ data.id }}</span>
|
||||
<Button label="详情" icon="pi pi-search" text size="small" class="p-0 mr-2" as="router-link" :to="`/superadmin/orders/${data.id}`" />
|
||||
<span class="text-muted-color">{{ data.id }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="租户" style="min-width: 16rem">
|
||||
@@ -375,84 +342,6 @@ loadOrders();
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="detailDialogVisible" :modal="true" :style="{ width: '1180px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">订单详情</span>
|
||||
<span class="text-muted-color truncate max-w-[520px]">
|
||||
{{ detail?.order?.id ? `#${detail.order.id}` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="detailLoading" class="flex items-center justify-center py-10">
|
||||
<ProgressSpinner style="width: 36px; height: 36px" strokeWidth="6" />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-sm text-muted-color">租户</div>
|
||||
<div class="font-medium">{{ detail?.tenant?.name ?? '-' }}</div>
|
||||
<div class="text-sm text-muted-color">{{ detail?.tenant?.code ?? '-' }} / {{ detail?.tenant?.id ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-sm text-muted-color">买家</div>
|
||||
<div class="font-medium">{{ detail?.buyer?.username ?? '-' }}</div>
|
||||
<div class="text-sm text-muted-color">ID: {{ detail?.buyer?.id ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div class="text-sm text-muted-color">状态</div>
|
||||
<Tag :value="detail?.order?.status_description || detail?.order?.status || '-'" :severity="getOrderStatusSeverity(detail?.order?.status)" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div class="text-sm text-muted-color">实付</div>
|
||||
<div class="font-medium">{{ formatCny(detail?.order?.amount_paid) }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div class="text-sm text-muted-color">创建时间</div>
|
||||
<div class="font-medium">{{ formatDate(detail?.order?.created_at) }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div class="text-sm text-muted-color">支付时间</div>
|
||||
<div class="font-medium">{{ formatDate(detail?.order?.paid_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-sm text-muted-color mb-2">订单快照(snapshot)</div>
|
||||
<pre class="text-xs whitespace-pre-wrap bg-surface-50 border border-surface-200 rounded-md p-3 max-h-[360px] overflow-auto">{{ safeJson(detail?.order?.snapshot) }}</pre>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-sm text-muted-color mb-2">订单明细(items)</div>
|
||||
<DataTable :value="detail?.order?.items || []" dataKey="id" responsiveLayout="scroll" scrollable scrollHeight="360px">
|
||||
<Column field="id" header="ItemID" style="min-width: 7rem" />
|
||||
<Column field="content_id" header="ContentID" style="min-width: 8rem" />
|
||||
<Column field="amount_paid" header="金额" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatCny(data.amount_paid) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="snapshot" header="内容快照" style="min-width: 24rem">
|
||||
<template #body="{ data }">
|
||||
<pre class="text-xs whitespace-pre-wrap bg-surface-50 border border-surface-200 rounded-md p-2 max-h-[180px] overflow-auto">{{ safeJson(data.snapshot) }}</pre>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="关闭" icon="pi pi-times" text @click="detailDialogVisible = false" />
|
||||
<Button
|
||||
label="退款"
|
||||
icon="pi pi-replay"
|
||||
severity="danger"
|
||||
@click="openRefundDialog(detail?.order)"
|
||||
:disabled="detail?.order?.status !== 'paid'"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="refundDialogVisible" :modal="true" :style="{ width: '520px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
609
frontend/superadmin/src/views/superadmin/TenantDetail.vue
Normal file
609
frontend/superadmin/src/views/superadmin/TenantDetail.vue
Normal file
@@ -0,0 +1,609 @@
|
||||
<script setup>
|
||||
import SearchField from '@/components/SearchField.vue';
|
||||
import SearchPanel from '@/components/SearchPanel.vue';
|
||||
import { OrderService } from '@/service/OrderService';
|
||||
import { TenantService } from '@/service/TenantService';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
|
||||
const tenantID = computed(() => Number(route.params.tenantID));
|
||||
|
||||
const loading = ref(false);
|
||||
const tenant = ref(null);
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
if (String(value).startsWith('0001-01-01')) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatCny(amountInCents) {
|
||||
const amount = Number(amountInCents) / 100;
|
||||
if (!Number.isFinite(amount)) return '-';
|
||||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
|
||||
}
|
||||
|
||||
function getStatusSeverity(status) {
|
||||
switch (status) {
|
||||
case 'verified':
|
||||
return 'success';
|
||||
case 'pending_verify':
|
||||
return 'warn';
|
||||
case 'banned':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function getOrderStatusSeverity(value) {
|
||||
switch (value) {
|
||||
case 'paid':
|
||||
return 'success';
|
||||
case 'created':
|
||||
case 'refunding':
|
||||
return 'warn';
|
||||
case 'failed':
|
||||
case 'canceled':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTenant() {
|
||||
const id = tenantID.value;
|
||||
if (!id || Number.isNaN(id)) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
tenant.value = await TenantService.getTenantDetail(id);
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载租户详情', life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const statusDialogVisible = ref(false);
|
||||
const statusLoading = ref(false);
|
||||
const statusOptionsLoading = ref(false);
|
||||
const statusOptions = ref([]);
|
||||
const statusValue = ref('');
|
||||
|
||||
async function ensureStatusOptionsLoaded() {
|
||||
if (statusOptions.value.length > 0) return;
|
||||
statusOptionsLoading.value = true;
|
||||
try {
|
||||
const list = await TenantService.getTenantStatuses();
|
||||
statusOptions.value = (list || [])
|
||||
.map((kv) => ({ label: kv?.value ?? kv?.key ?? '-', value: kv?.key ?? '' }))
|
||||
.filter((item) => item.value);
|
||||
} finally {
|
||||
statusOptionsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openStatusDialog() {
|
||||
statusValue.value = tenant.value?.status ?? '';
|
||||
statusDialogVisible.value = true;
|
||||
await ensureStatusOptionsLoaded();
|
||||
}
|
||||
|
||||
async function confirmUpdateStatus() {
|
||||
const id = tenantID.value;
|
||||
if (!id || !statusValue.value) return;
|
||||
statusLoading.value = true;
|
||||
try {
|
||||
await TenantService.updateTenantStatus({ tenantID: id, status: statusValue.value });
|
||||
toast.add({ severity: 'success', summary: '更新成功', detail: `租户ID: ${id}`, life: 3000 });
|
||||
statusDialogVisible.value = false;
|
||||
await loadTenant();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '更新失败', detail: error?.message || '无法更新租户状态', life: 4000 });
|
||||
} finally {
|
||||
statusLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const renewDialogVisible = ref(false);
|
||||
const renewing = ref(false);
|
||||
const renewDuration = ref(30);
|
||||
const durationOptions = [
|
||||
{ label: '7 天', value: 7 },
|
||||
{ label: '30 天', value: 30 },
|
||||
{ label: '90 天', value: 90 },
|
||||
{ label: '180 天', value: 180 },
|
||||
{ label: '365 天', value: 365 }
|
||||
];
|
||||
|
||||
async function confirmRenew() {
|
||||
const id = tenantID.value;
|
||||
if (!id) return;
|
||||
renewing.value = true;
|
||||
try {
|
||||
await TenantService.renewTenantExpire({ tenantID: id, duration: renewDuration.value });
|
||||
toast.add({ severity: 'success', summary: '续期成功', detail: `+${renewDuration.value} 天`, life: 3000 });
|
||||
renewDialogVisible.value = false;
|
||||
await loadTenant();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '续期失败', detail: error?.message || '无法续期', life: 4000 });
|
||||
} finally {
|
||||
renewing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const tabValue = ref('users');
|
||||
|
||||
const tenantUsersLoading = ref(false);
|
||||
const tenantUsers = ref([]);
|
||||
const tenantUsersTotal = ref(0);
|
||||
const tenantUsersPage = ref(1);
|
||||
const tenantUsersRows = ref(10);
|
||||
const tenantUsersUsername = ref('');
|
||||
const tenantUsersUserID = ref(null);
|
||||
const tenantUsersRole = ref('');
|
||||
const tenantUsersStatus = ref('');
|
||||
|
||||
async function loadTenantUsers() {
|
||||
const id = tenantID.value;
|
||||
if (!id) return;
|
||||
|
||||
tenantUsersLoading.value = true;
|
||||
try {
|
||||
const data = await TenantService.listTenantUsers(id, {
|
||||
page: tenantUsersPage.value,
|
||||
limit: tenantUsersRows.value,
|
||||
username: tenantUsersUsername.value,
|
||||
user_id: tenantUsersUserID.value || undefined,
|
||||
role: tenantUsersRole.value || undefined,
|
||||
status: tenantUsersStatus.value || undefined
|
||||
});
|
||||
|
||||
const normalizeItems = (items) => {
|
||||
if (Array.isArray(items)) return items;
|
||||
if (items && typeof items === 'object') return [items];
|
||||
return [];
|
||||
};
|
||||
tenantUsers.value = normalizeItems(data?.items);
|
||||
tenantUsersTotal.value = data?.total ?? 0;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载租户成员列表', life: 4000 });
|
||||
} finally {
|
||||
tenantUsersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTenantUsersSearch() {
|
||||
tenantUsersPage.value = 1;
|
||||
loadTenantUsers();
|
||||
}
|
||||
|
||||
function onTenantUsersReset() {
|
||||
tenantUsersUsername.value = '';
|
||||
tenantUsersUserID.value = null;
|
||||
tenantUsersRole.value = '';
|
||||
tenantUsersStatus.value = '';
|
||||
tenantUsersPage.value = 1;
|
||||
tenantUsersRows.value = 10;
|
||||
loadTenantUsers();
|
||||
}
|
||||
|
||||
function onTenantUsersPageChange(event) {
|
||||
tenantUsersPage.value = (event.page ?? 0) + 1;
|
||||
tenantUsersRows.value = event.rows ?? tenantUsersRows.value;
|
||||
loadTenantUsers();
|
||||
}
|
||||
|
||||
const ordersLoading = ref(false);
|
||||
const orders = ref([]);
|
||||
const ordersTotal = ref(0);
|
||||
const ordersPage = ref(1);
|
||||
const ordersRows = ref(10);
|
||||
const orderIDFilter = ref(null);
|
||||
const buyerUserIDFilter = ref(null);
|
||||
const buyerUsernameFilter = ref('');
|
||||
const orderStatusFilter = ref('');
|
||||
const createdAtFromFilter = ref(null);
|
||||
const createdAtToFilter = ref(null);
|
||||
const paidAtFromFilter = ref(null);
|
||||
const paidAtToFilter = ref(null);
|
||||
const orderSortField = ref('id');
|
||||
const orderSortOrder = ref(-1);
|
||||
|
||||
const orderStatusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'created', value: 'created' },
|
||||
{ label: 'paid', value: 'paid' },
|
||||
{ label: 'refunding', value: 'refunding' },
|
||||
{ label: 'refunded', value: 'refunded' },
|
||||
{ label: 'canceled', value: 'canceled' },
|
||||
{ label: 'failed', value: 'failed' }
|
||||
];
|
||||
|
||||
async function loadOrders() {
|
||||
const id = tenantID.value;
|
||||
if (!id) return;
|
||||
|
||||
ordersLoading.value = true;
|
||||
try {
|
||||
const result = await OrderService.listOrders({
|
||||
page: ordersPage.value,
|
||||
limit: ordersRows.value,
|
||||
tenant_id: id,
|
||||
id: orderIDFilter.value || undefined,
|
||||
user_id: buyerUserIDFilter.value || undefined,
|
||||
username: buyerUsernameFilter.value,
|
||||
status: orderStatusFilter.value,
|
||||
created_at_from: createdAtFromFilter.value || undefined,
|
||||
created_at_to: createdAtToFilter.value || undefined,
|
||||
paid_at_from: paidAtFromFilter.value || undefined,
|
||||
paid_at_to: paidAtToFilter.value || undefined,
|
||||
sortField: orderSortField.value,
|
||||
sortOrder: orderSortOrder.value
|
||||
});
|
||||
orders.value = result.items;
|
||||
ordersTotal.value = result.total;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载租户订单列表', life: 4000 });
|
||||
} finally {
|
||||
ordersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onOrdersSearch() {
|
||||
ordersPage.value = 1;
|
||||
loadOrders();
|
||||
}
|
||||
|
||||
function onOrdersReset() {
|
||||
orderIDFilter.value = null;
|
||||
buyerUserIDFilter.value = null;
|
||||
buyerUsernameFilter.value = '';
|
||||
orderStatusFilter.value = '';
|
||||
createdAtFromFilter.value = null;
|
||||
createdAtToFilter.value = null;
|
||||
paidAtFromFilter.value = null;
|
||||
paidAtToFilter.value = null;
|
||||
orderSortField.value = 'id';
|
||||
orderSortOrder.value = -1;
|
||||
ordersPage.value = 1;
|
||||
ordersRows.value = 10;
|
||||
loadOrders();
|
||||
}
|
||||
|
||||
function onOrdersPage(event) {
|
||||
ordersPage.value = (event.page ?? 0) + 1;
|
||||
ordersRows.value = event.rows ?? ordersRows.value;
|
||||
loadOrders();
|
||||
}
|
||||
|
||||
function onOrdersSort(event) {
|
||||
orderSortField.value = event.sortField ?? orderSortField.value;
|
||||
orderSortOrder.value = event.sortOrder ?? orderSortOrder.value;
|
||||
loadOrders();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => tenantID.value,
|
||||
() => {
|
||||
tenantUsersPage.value = 1;
|
||||
tenantUsersRows.value = 10;
|
||||
ordersPage.value = 1;
|
||||
ordersRows.value = 10;
|
||||
loadTenant();
|
||||
loadTenantUsers();
|
||||
loadOrders();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
ensureStatusOptionsLoaded().catch(() => {});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex flex-col">
|
||||
<h4 class="m-0">租户详情</h4>
|
||||
<span class="text-muted-color">TenantID: {{ tenantID || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button label="刷新" icon="pi pi-refresh" severity="secondary" @click="loadTenant" :disabled="loading" />
|
||||
<Button label="续期" icon="pi pi-calendar-plus" @click="renewDialogVisible = true" :disabled="loading" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-10">
|
||||
<ProgressSpinner style="width: 36px; height: 36px" strokeWidth="6" />
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-sm text-muted-color">名称</div>
|
||||
<div class="font-medium">{{ tenant?.name ?? '-' }}</div>
|
||||
<div class="text-sm text-muted-color">Code: {{ tenant?.code ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-sm text-muted-color">状态</div>
|
||||
<Tag :value="tenant?.status_description || tenant?.status || '-'" :severity="getStatusSeverity(tenant?.status)" class="cursor-pointer" @click="openStatusDialog" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">Owner</div>
|
||||
<div class="font-medium">{{ tenant?.owner?.username ?? tenant?.user_id ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">管理员</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Tag v-for="u in tenant?.admin_users || []" :key="u.id" :value="u.username" severity="secondary" />
|
||||
<span v-if="!tenant?.admin_users || tenant.admin_users.length === 0" class="text-muted-color">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">累计收入</div>
|
||||
<div class="font-medium">{{ formatCny(tenant?.income_amount_paid_sum) }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">成员数</div>
|
||||
<div class="font-medium">{{ tenant?.user_count ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">过期时间</div>
|
||||
<div class="font-medium">{{ formatDate(tenant?.expired_at) }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">创建时间</div>
|
||||
<div class="font-medium">{{ formatDate(tenant?.created_at) }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">更新时间</div>
|
||||
<div class="font-medium">{{ formatDate(tenant?.updated_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<Tabs v-model:value="tabValue" value="users">
|
||||
<TabList>
|
||||
<Tab value="users">成员</Tab>
|
||||
<Tab value="orders">订单</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel value="users">
|
||||
<div class="flex flex-col gap-4">
|
||||
<SearchPanel :loading="tenantUsersLoading" @search="onTenantUsersSearch" @reset="onTenantUsersReset">
|
||||
<SearchField label="UserID">
|
||||
<InputNumber v-model="tenantUsersUserID" :min="1" placeholder="精确匹配" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="用户名">
|
||||
<InputText v-model="tenantUsersUsername" placeholder="请输入" class="w-full" @keyup.enter="onTenantUsersSearch" />
|
||||
</SearchField>
|
||||
<SearchField label="角色">
|
||||
<Select
|
||||
v-model="tenantUsersRole"
|
||||
:options="[
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'tenant_admin', value: 'tenant_admin' },
|
||||
{ label: 'member', value: 'member' }
|
||||
]"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="请选择"
|
||||
class="w-full"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="状态">
|
||||
<Select
|
||||
v-model="tenantUsersStatus"
|
||||
:options="[
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'verified', value: 'verified' },
|
||||
{ label: 'pending_verify', value: 'pending_verify' },
|
||||
{ label: 'banned', value: 'banned' }
|
||||
]"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="请选择"
|
||||
class="w-full"
|
||||
/>
|
||||
</SearchField>
|
||||
</SearchPanel>
|
||||
|
||||
<DataTable
|
||||
:value="tenantUsers"
|
||||
dataKey="id"
|
||||
:loading="tenantUsersLoading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="tenantUsersRows"
|
||||
:totalRecords="tenantUsersTotal"
|
||||
:first="(tenantUsersPage - 1) * tenantUsersRows"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
@page="onTenantUsersPageChange"
|
||||
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
scrollable
|
||||
scrollHeight="520px"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="user.id" header="UserID" style="min-width: 8rem">
|
||||
<template #body="{ data }">
|
||||
{{ data?.user?.id ?? data?.user_id ?? '-' }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="user.username" header="用户名" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
:label="data?.user?.username ?? '-'"
|
||||
icon="pi pi-external-link"
|
||||
text
|
||||
size="small"
|
||||
class="p-0"
|
||||
as="router-link"
|
||||
:to="data?.user?.id ? `/superadmin/users/${data.user.id}` : undefined"
|
||||
:disabled="!data?.user?.id"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="角色" style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Tag v-for="r in data.role || []" :key="r" :value="r" severity="secondary" />
|
||||
<span v-if="!data.role || data.role.length === 0" class="text-muted-color">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="状态" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status_description || data.status || '-'" :severity="getStatusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="created_at" header="加入时间" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="orders">
|
||||
<div class="flex flex-col gap-4">
|
||||
<SearchPanel :loading="ordersLoading" @search="onOrdersSearch" @reset="onOrdersReset">
|
||||
<SearchField label="OrderID">
|
||||
<InputNumber v-model="orderIDFilter" :min="1" placeholder="精确匹配" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="BuyerUserID">
|
||||
<InputNumber v-model="buyerUserIDFilter" :min="1" placeholder="精确匹配" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="BuyerUsername">
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<i class="pi pi-search" />
|
||||
</InputIcon>
|
||||
<InputText v-model="buyerUsernameFilter" placeholder="请输入" class="w-full" @keyup.enter="onOrdersSearch" />
|
||||
</IconField>
|
||||
</SearchField>
|
||||
<SearchField label="状态">
|
||||
<Select v-model="orderStatusFilter" :options="orderStatusOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="创建时间 From">
|
||||
<DatePicker v-model="createdAtFromFilter" showIcon showButtonBar placeholder="开始时间" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="创建时间 To">
|
||||
<DatePicker v-model="createdAtToFilter" showIcon showButtonBar placeholder="结束时间" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="支付时间 From">
|
||||
<DatePicker v-model="paidAtFromFilter" showIcon showButtonBar placeholder="开始时间" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="支付时间 To">
|
||||
<DatePicker v-model="paidAtToFilter" showIcon showButtonBar placeholder="结束时间" class="w-full" />
|
||||
</SearchField>
|
||||
</SearchPanel>
|
||||
|
||||
<DataTable
|
||||
:value="orders"
|
||||
dataKey="id"
|
||||
:loading="ordersLoading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="ordersRows"
|
||||
:totalRecords="ordersTotal"
|
||||
:first="(ordersPage - 1) * ordersRows"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
sortMode="single"
|
||||
:sortField="orderSortField"
|
||||
:sortOrder="orderSortOrder"
|
||||
@page="onOrdersPage"
|
||||
@sort="onOrdersSort"
|
||||
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
scrollable
|
||||
scrollHeight="520px"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="id" header="ID" sortable style="min-width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<Button label="详情" icon="pi pi-search" text size="small" class="p-0 mr-2" as="router-link" :to="`/superadmin/orders/${data.id}`" />
|
||||
<span class="text-muted-color">{{ data.id }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="买家" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ data?.buyer?.username ?? '-' }}</span>
|
||||
<span class="text-muted-color">ID: {{ data?.buyer?.id ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="状态" sortable style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data?.status_description || data?.status || '-'" :severity="getOrderStatusSeverity(data?.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="amount_paid" header="实付" sortable style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatCny(data.amount_paid) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="created_at" header="创建时间" sortable style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="paid_at" header="支付时间" sortable style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.paid_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="statusDialogVisible" :modal="true" :style="{ width: '420px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">更新租户状态</span>
|
||||
<span class="text-muted-color truncate max-w-[240px]">{{ tenant?.name ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="block font-medium mb-2">租户状态</label>
|
||||
<Select v-model="statusValue" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="选择状态" :disabled="statusLoading" :loading="statusOptionsLoading" fluid />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="取消" icon="pi pi-times" text @click="statusDialogVisible = false" :disabled="statusLoading" />
|
||||
<Button label="确认" icon="pi pi-check" @click="confirmUpdateStatus" :loading="statusLoading" :disabled="!statusValue" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="renewDialogVisible" :modal="true" :style="{ width: '420px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">租户续期</span>
|
||||
<span class="text-muted-color truncate max-w-[240px]">{{ tenant?.name ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="block font-medium mb-2">续期时长</label>
|
||||
<Select v-model="renewDuration" :options="durationOptions" optionLabel="label" optionValue="value" placeholder="选择时长" :disabled="renewing" fluid />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="取消" icon="pi pi-times" text @click="renewDialogVisible = false" :disabled="renewing" />
|
||||
<Button label="确认" icon="pi pi-check" @click="confirmRenew" :loading="renewing" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -446,7 +446,19 @@ onMounted(() => {
|
||||
>
|
||||
<Column field="id" header="ID" sortable style="min-width: 6rem" />
|
||||
<Column field="code" header="Code" style="min-width: 10rem" />
|
||||
<Column field="name" header="名称" sortable style="min-width: 14rem" />
|
||||
<Column field="name" header="名称" sortable style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
:label="data.name || '-'"
|
||||
icon="pi pi-external-link"
|
||||
text
|
||||
size="small"
|
||||
class="p-0"
|
||||
as="router-link"
|
||||
:to="`/superadmin/tenants/${data.id}`"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="user_id" header="Owner" sortable style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span v-if="data.owner?.username">{{ data.owner.username }}</span>
|
||||
|
||||
524
frontend/superadmin/src/views/superadmin/UserDetail.vue
Normal file
524
frontend/superadmin/src/views/superadmin/UserDetail.vue
Normal file
@@ -0,0 +1,524 @@
|
||||
<script setup>
|
||||
import SearchField from '@/components/SearchField.vue';
|
||||
import SearchPanel from '@/components/SearchPanel.vue';
|
||||
import { TenantService } from '@/service/TenantService';
|
||||
import { UserService } from '@/service/UserService';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
|
||||
const userID = computed(() => Number(route.params.userID));
|
||||
|
||||
const loading = ref(false);
|
||||
const user = ref(null);
|
||||
|
||||
const tabValue = ref('owned');
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
if (String(value).startsWith('0001-01-01')) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatCny(amountInCents) {
|
||||
const amount = Number(amountInCents) / 100;
|
||||
if (!Number.isFinite(amount)) return '-';
|
||||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
|
||||
}
|
||||
|
||||
function getStatusSeverity(status) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
case 'verified':
|
||||
return 'success';
|
||||
case 'pending_verify':
|
||||
case 'pending':
|
||||
return 'warn';
|
||||
case 'banned':
|
||||
case 'disabled':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function hasRole(role) {
|
||||
const roles = user.value?.roles || [];
|
||||
return Array.isArray(roles) && roles.includes(role);
|
||||
}
|
||||
|
||||
async function loadUser() {
|
||||
const id = userID.value;
|
||||
if (!id || Number.isNaN(id)) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
user.value = await UserService.getUserDetail(id);
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载用户详情', life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const statusDialogVisible = ref(false);
|
||||
const statusLoading = ref(false);
|
||||
const statusOptionsLoading = ref(false);
|
||||
const statusOptions = ref([]);
|
||||
const statusValue = ref(null);
|
||||
|
||||
const statusFilterOptions = computed(() => [{ label: '全部', value: '' }, ...(statusOptions.value || [])]);
|
||||
|
||||
async function ensureStatusOptionsLoaded() {
|
||||
if (statusOptions.value.length > 0) return;
|
||||
statusOptionsLoading.value = true;
|
||||
try {
|
||||
const list = await UserService.getUserStatuses();
|
||||
statusOptions.value = (list || [])
|
||||
.map((kv) => ({ label: kv?.value ?? kv?.key ?? '-', value: kv?.key ?? '' }))
|
||||
.filter((item) => item.value);
|
||||
} finally {
|
||||
statusOptionsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openStatusDialog() {
|
||||
statusValue.value = user.value?.status ?? null;
|
||||
statusDialogVisible.value = true;
|
||||
await ensureStatusOptionsLoaded();
|
||||
}
|
||||
|
||||
async function confirmUpdateStatus() {
|
||||
const id = userID.value;
|
||||
if (!id || !statusValue.value) return;
|
||||
statusLoading.value = true;
|
||||
try {
|
||||
await UserService.updateUserStatus({ userID: id, status: statusValue.value });
|
||||
toast.add({ severity: 'success', summary: '更新成功', detail: `用户ID: ${id}`, life: 3000 });
|
||||
statusDialogVisible.value = false;
|
||||
await loadUser();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '更新失败', detail: error?.message || '无法更新用户状态', life: 4000 });
|
||||
} finally {
|
||||
statusLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const rolesDialogVisible = ref(false);
|
||||
const rolesLoading = ref(false);
|
||||
const rolesSuperAdmin = ref(false);
|
||||
|
||||
function openRolesDialog() {
|
||||
rolesSuperAdmin.value = hasRole('super_admin');
|
||||
rolesDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmUpdateRoles() {
|
||||
const id = userID.value;
|
||||
if (!id) return;
|
||||
|
||||
const roles = ['user'];
|
||||
if (rolesSuperAdmin.value) roles.push('super_admin');
|
||||
|
||||
rolesLoading.value = true;
|
||||
try {
|
||||
await UserService.updateUserRoles({ userID: id, roles });
|
||||
toast.add({ severity: 'success', summary: '更新成功', detail: `用户ID: ${id}`, life: 3000 });
|
||||
rolesDialogVisible.value = false;
|
||||
await loadUser();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '更新失败', detail: error?.message || '无法更新用户角色', life: 4000 });
|
||||
} finally {
|
||||
rolesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const ownedTenantsLoading = ref(false);
|
||||
const ownedTenants = ref([]);
|
||||
const ownedTenantsTotal = ref(0);
|
||||
const ownedTenantsPage = ref(1);
|
||||
const ownedTenantsRows = ref(10);
|
||||
|
||||
async function loadOwnedTenants() {
|
||||
const uid = userID.value;
|
||||
if (!uid) return;
|
||||
ownedTenantsLoading.value = true;
|
||||
try {
|
||||
const result = await TenantService.listTenants({
|
||||
page: ownedTenantsPage.value,
|
||||
limit: ownedTenantsRows.value,
|
||||
user_id: uid,
|
||||
sortField: 'id',
|
||||
sortOrder: -1
|
||||
});
|
||||
ownedTenants.value = result.items;
|
||||
ownedTenantsTotal.value = result.total;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载该用户拥有的租户', life: 4000 });
|
||||
} finally {
|
||||
ownedTenantsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onOwnedTenantsPage(event) {
|
||||
ownedTenantsPage.value = (event.page ?? 0) + 1;
|
||||
ownedTenantsRows.value = event.rows ?? ownedTenantsRows.value;
|
||||
loadOwnedTenants();
|
||||
}
|
||||
|
||||
const joinedTenantsLoading = ref(false);
|
||||
const joinedTenants = ref([]);
|
||||
const joinedTenantsTotal = ref(0);
|
||||
const joinedTenantsPage = ref(1);
|
||||
const joinedTenantsRows = ref(10);
|
||||
const joinedTenantsTenantID = ref(null);
|
||||
const joinedTenantsCode = ref('');
|
||||
const joinedTenantsName = ref('');
|
||||
const joinedTenantsRole = ref('');
|
||||
const joinedTenantsStatus = ref('');
|
||||
const joinedTenantsJoinedAtFrom = ref(null);
|
||||
const joinedTenantsJoinedAtTo = ref(null);
|
||||
|
||||
async function loadJoinedTenants() {
|
||||
const uid = userID.value;
|
||||
if (!uid) return;
|
||||
|
||||
joinedTenantsLoading.value = true;
|
||||
try {
|
||||
const result = await UserService.listUserTenants(uid, {
|
||||
page: joinedTenantsPage.value,
|
||||
limit: joinedTenantsRows.value,
|
||||
tenant_id: joinedTenantsTenantID.value || undefined,
|
||||
code: joinedTenantsCode.value,
|
||||
name: joinedTenantsName.value,
|
||||
role: joinedTenantsRole.value || undefined,
|
||||
status: joinedTenantsStatus.value || undefined,
|
||||
created_at_from: joinedTenantsJoinedAtFrom.value || undefined,
|
||||
created_at_to: joinedTenantsJoinedAtTo.value || undefined
|
||||
});
|
||||
joinedTenants.value = result.items;
|
||||
joinedTenantsTotal.value = result.total;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载该用户加入的租户', life: 4000 });
|
||||
} finally {
|
||||
joinedTenantsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onJoinedTenantsSearch() {
|
||||
joinedTenantsPage.value = 1;
|
||||
loadJoinedTenants();
|
||||
}
|
||||
|
||||
function onJoinedTenantsReset() {
|
||||
joinedTenantsTenantID.value = null;
|
||||
joinedTenantsCode.value = '';
|
||||
joinedTenantsName.value = '';
|
||||
joinedTenantsRole.value = '';
|
||||
joinedTenantsStatus.value = '';
|
||||
joinedTenantsJoinedAtFrom.value = null;
|
||||
joinedTenantsJoinedAtTo.value = null;
|
||||
joinedTenantsPage.value = 1;
|
||||
joinedTenantsRows.value = 10;
|
||||
loadJoinedTenants();
|
||||
}
|
||||
|
||||
function onJoinedTenantsPage(event) {
|
||||
joinedTenantsPage.value = (event.page ?? 0) + 1;
|
||||
joinedTenantsRows.value = event.rows ?? joinedTenantsRows.value;
|
||||
loadJoinedTenants();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => userID.value,
|
||||
() => {
|
||||
ownedTenantsPage.value = 1;
|
||||
joinedTenantsPage.value = 1;
|
||||
loadUser();
|
||||
loadOwnedTenants();
|
||||
loadJoinedTenants();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
ensureStatusOptionsLoaded().catch(() => {});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex flex-col">
|
||||
<h4 class="m-0">用户详情</h4>
|
||||
<span class="text-muted-color">UserID: {{ userID || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button label="角色" icon="pi pi-user-edit" severity="secondary" @click="openRolesDialog" :disabled="loading" />
|
||||
<Button label="状态" icon="pi pi-tag" @click="openStatusDialog" :disabled="loading" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-10">
|
||||
<ProgressSpinner style="width: 36px; height: 36px" strokeWidth="6" />
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-sm text-muted-color">用户名</div>
|
||||
<div class="font-medium">{{ user?.username ?? '-' }}</div>
|
||||
<div class="text-sm text-muted-color">Roles: {{ (user?.roles || []).join(', ') || '-' }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-sm text-muted-color">状态</div>
|
||||
<Tag :value="user?.status_description || user?.status || '-'" :severity="getStatusSeverity(user?.status)" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">余额</div>
|
||||
<div class="font-medium">{{ formatCny(user?.balance) }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">冻结</div>
|
||||
<div class="font-medium">{{ formatCny(user?.balance_frozen) }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">超管</div>
|
||||
<div class="font-medium">{{ hasRole('super_admin') ? '是' : '否' }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">认证时间</div>
|
||||
<div class="font-medium">{{ formatDate(user?.verified_at) }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">创建时间</div>
|
||||
<div class="font-medium">{{ formatDate(user?.created_at) }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">更新时间</div>
|
||||
<div class="font-medium">{{ formatDate(user?.updated_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<Tabs v-model:value="tabValue" value="owned">
|
||||
<TabList>
|
||||
<Tab value="owned">拥有的租户</Tab>
|
||||
<Tab value="joined">加入的租户</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel value="owned">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-color">共 {{ ownedTenantsTotal }} 条</span>
|
||||
</div>
|
||||
<DataTable
|
||||
:value="ownedTenants"
|
||||
dataKey="id"
|
||||
:loading="ownedTenantsLoading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="ownedTenantsRows"
|
||||
:totalRecords="ownedTenantsTotal"
|
||||
:first="(ownedTenantsPage - 1) * ownedTenantsRows"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
@page="onOwnedTenantsPage"
|
||||
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: 6rem" />
|
||||
<Column field="code" header="Code" style="min-width: 10rem" />
|
||||
<Column field="name" header="名称" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
:label="data.name || '-'"
|
||||
icon="pi pi-external-link"
|
||||
text
|
||||
size="small"
|
||||
class="p-0"
|
||||
as="router-link"
|
||||
:to="`/superadmin/tenants/${data.id}`"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status_description" header="状态" style="min-width: 10rem" />
|
||||
<Column field="user_count" header="用户数" style="min-width: 8rem" />
|
||||
<Column field="income_amount_paid_sum" header="累计收入" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatCny(data.income_amount_paid_sum) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="expired_at" header="过期时间" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.expired_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="joined">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-color">共 {{ joinedTenantsTotal }} 条</span>
|
||||
</div>
|
||||
|
||||
<SearchPanel :loading="joinedTenantsLoading" @search="onJoinedTenantsSearch" @reset="onJoinedTenantsReset">
|
||||
<SearchField label="TenantID">
|
||||
<InputNumber v-model="joinedTenantsTenantID" :min="1" placeholder="精确匹配" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="Code">
|
||||
<InputText v-model="joinedTenantsCode" placeholder="请输入" class="w-full" @keyup.enter="onJoinedTenantsSearch" />
|
||||
</SearchField>
|
||||
<SearchField label="名称">
|
||||
<InputText v-model="joinedTenantsName" placeholder="请输入" class="w-full" @keyup.enter="onJoinedTenantsSearch" />
|
||||
</SearchField>
|
||||
<SearchField label="成员角色">
|
||||
<Select
|
||||
v-model="joinedTenantsRole"
|
||||
:options="[
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'tenant_admin', value: 'tenant_admin' },
|
||||
{ label: 'member', value: 'member' }
|
||||
]"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="请选择"
|
||||
class="w-full"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="成员状态">
|
||||
<Select
|
||||
v-model="joinedTenantsStatus"
|
||||
:options="statusFilterOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="请选择"
|
||||
:loading="statusOptionsLoading"
|
||||
class="w-full"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="加入时间 From">
|
||||
<DatePicker v-model="joinedTenantsJoinedAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="加入时间 To">
|
||||
<DatePicker v-model="joinedTenantsJoinedAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
|
||||
</SearchField>
|
||||
</SearchPanel>
|
||||
|
||||
<DataTable
|
||||
:value="joinedTenants"
|
||||
dataKey="tenant_id"
|
||||
:loading="joinedTenantsLoading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="joinedTenantsRows"
|
||||
:totalRecords="joinedTenantsTotal"
|
||||
:first="(joinedTenantsPage - 1) * joinedTenantsRows"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
@page="onJoinedTenantsPage"
|
||||
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
scrollable
|
||||
scrollHeight="420px"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="tenant_id" header="TenantID" style="min-width: 7rem" />
|
||||
<Column field="code" header="Code" style="min-width: 10rem" />
|
||||
<Column field="name" header="名称" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
:label="data.name || '-'"
|
||||
icon="pi pi-external-link"
|
||||
text
|
||||
size="small"
|
||||
class="p-0"
|
||||
as="router-link"
|
||||
:to="`/superadmin/tenants/${data.tenant_id}`"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Owner" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span>{{ data?.owner?.username ?? '-' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="租户状态" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.tenant_status_description || data.tenant_status || '-'" severity="secondary" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="成员角色" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Tag v-for="r in data.role || []" :key="r" :value="r" severity="secondary" />
|
||||
<span v-if="!data.role || data.role.length === 0" class="text-muted-color">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="成员状态" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.member_status_description || data.member_status || '-'" :severity="getStatusSeverity(data.member_status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="joined_at" header="加入时间" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.joined_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="expired_at" header="过期时间" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.expired_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="statusDialogVisible" :modal="true" :style="{ width: '420px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">更新用户状态</span>
|
||||
<span class="text-muted-color truncate max-w-[240px]">{{ user?.username ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="block font-medium mb-2">用户状态</label>
|
||||
<Select v-model="statusValue" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="选择状态" :disabled="statusLoading" :loading="statusOptionsLoading" fluid />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="取消" icon="pi pi-times" text @click="statusDialogVisible = false" :disabled="statusLoading" />
|
||||
<Button label="确认" icon="pi pi-check" @click="confirmUpdateStatus" :loading="statusLoading" :disabled="!statusValue" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="rolesDialogVisible" :modal="true" :style="{ width: '420px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">更新用户角色</span>
|
||||
<span class="text-muted-color truncate max-w-[240px]">{{ user?.username ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox inputId="rolesSuperAdmin" v-model="rolesSuperAdmin" binary :disabled="rolesLoading" />
|
||||
<label for="rolesSuperAdmin" class="cursor-pointer">super_admin</label>
|
||||
</div>
|
||||
<div class="text-sm text-muted-color">默认包含 user 角色。</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="取消" icon="pi pi-times" text @click="rolesDialogVisible = false" :disabled="rolesLoading" />
|
||||
<Button label="确认" icon="pi pi-check" @click="confirmUpdateRoles" :loading="rolesLoading" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -473,7 +473,19 @@ onMounted(() => {
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="id" header="ID" sortable style="min-width: 6rem" />
|
||||
<Column field="username" header="用户名" sortable style="min-width: 14rem" />
|
||||
<Column field="username" header="用户名" sortable style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
:label="data.username || '-'"
|
||||
icon="pi pi-external-link"
|
||||
text
|
||||
size="small"
|
||||
class="p-0"
|
||||
as="router-link"
|
||||
:to="`/superadmin/users/${data.id}`"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="状态" sortable style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status_description || data.status || '-'" :severity="getStatusSeverity(data.status)" class="cursor-pointer" @click="openStatusDialog(data)" />
|
||||
|
||||
Reference in New Issue
Block a user