diff --git a/backend/app/http/super/dto/order.go b/backend/app/http/super/dto/order.go index 1c47c3e..69b7c04 100644 --- a/backend/app/http/super/dto/order.go +++ b/backend/app/http/super/dto/order.go @@ -11,7 +11,7 @@ type OrderStatisticsRow struct { } type OrderStatisticsResponse struct { - TotalCount int64 `json:"total_count"` - TotalAmountPaidSum int64 `json:"total_amount_paid_sum"` + TotalCount int64 `json:"total_count"` + TotalAmountPaidSum int64 `json:"total_amount_paid_sum"` ByStatus []*OrderStatisticsRow `json:"by_status"` } diff --git a/backend/app/http/super/dto/tenant.go b/backend/app/http/super/dto/tenant.go index a032c8b..f99a120 100644 --- a/backend/app/http/super/dto/tenant.go +++ b/backend/app/http/super/dto/tenant.go @@ -2,6 +2,7 @@ package dto import ( "errors" + "strings" "time" "quyun/v2/app/requests" @@ -10,19 +11,59 @@ import ( ) type TenantFilter struct { - requests.Pagination - requests.SortQueryFilter + // Pagination page/limit. + requests.Pagination `json:",inline" query:",inline"` + + // SortQueryFilter defines asc/desc ordering. + requests.SortQueryFilter `json:",inline" query:",inline"` Name *string `json:"name,omitempty" query:"name"` + Code *string `json:"code,omitempty" query:"code"` + ID *int64 `json:"id,omitempty" query:"id"` + UserID *int64 `json:"user_id,omitempty" query:"user_id"` Status *consts.TenantStatus `json:"status,omitempty" query:"status"` + + ExpiredAtFrom *time.Time `json:"expired_at_from,omitempty" query:"expired_at_from"` + ExpiredAtTo *time.Time `json:"expired_at_to,omitempty" query:"expired_at_to"` + + CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` + CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` +} + +func (f *TenantFilter) NameTrimmed() string { + if f == nil || f.Name == nil { + return "" + } + return strings.TrimSpace(*f.Name) +} + +func (f *TenantFilter) CodeTrimmed() string { + if f == nil || f.Code == nil { + return "" + } + return strings.ToLower(strings.TrimSpace(*f.Code)) } type TenantItem struct { *models.Tenant - UserCount int64 `json:"user_count"` - UserBalance int64 `json:"user_balance"` - StatusDescription string `json:"status_description"` + UserCount int64 `json:"user_count"` + // IncomeAmountPaidSum 累计收入金额(单位:分,CNY):按 orders 聚合得到的已支付净收入(不含退款中/已退款订单)。 + IncomeAmountPaidSum int64 `json:"income_amount_paid_sum"` + StatusDescription string `json:"status_description"` + + Owner *TenantOwnerUserLite `json:"owner,omitempty"` + AdminUsers []*TenantAdminUserLite `json:"admin_users,omitempty"` +} + +type TenantOwnerUserLite struct { + ID int64 `json:"id"` + Username string `json:"username"` +} + +type TenantAdminUserLite struct { + ID int64 `json:"id"` + Username string `json:"username"` } type TenantCreateForm struct { diff --git a/backend/app/http/super/dto/tenant_user.go b/backend/app/http/super/dto/tenant_user.go new file mode 100644 index 0000000..6a3d52c --- /dev/null +++ b/backend/app/http/super/dto/tenant_user.go @@ -0,0 +1,26 @@ +package dto + +import ( + "time" + + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + "go.ipao.vip/gen/types" +) + +type SuperUserLite struct { + ID int64 `json:"id"` + Username string `json:"username"` + Status consts.UserStatus `json:"status"` + Roles types.Array[consts.Role] `json:"roles"` + VerifiedAt time.Time `json:"verified_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StatusDescription string `json:"status_description,omitempty"` +} + +type SuperTenantUserItem struct { + TenantUser *models.TenantUser `json:"tenant_user,omitempty"` + User *SuperUserLite `json:"user,omitempty"` +} diff --git a/backend/app/http/super/order.go b/backend/app/http/super/order.go index f4163ae..2c3bd3b 100644 --- a/backend/app/http/super/order.go +++ b/backend/app/http/super/order.go @@ -22,4 +22,3 @@ type order struct{} func (*order) statistics(ctx fiber.Ctx) (*dto.OrderStatisticsResponse, error) { return services.Order.SuperStatistics(ctx) } - diff --git a/backend/app/http/super/routes.gen.go b/backend/app/http/super/routes.gen.go index b83c282..470d049 100644 --- a/backend/app/http/super/routes.gen.go +++ b/backend/app/http/super/routes.gen.go @@ -6,6 +6,7 @@ package super import ( "quyun/v2/app/http/super/dto" + tenantdto "quyun/v2/app/http/tenant/dto" "quyun/v2/app/middlewares" "github.com/gofiber/fiber/v3" @@ -70,6 +71,12 @@ func (r *Routes) Register(router fiber.Router) { r.tenant.create, Body[dto.TenantCreateForm]("form"), )) + r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID/users -> tenant.users") + router.Get("/super/v1/tenants/:tenantID/users"[len(r.Path()):], DataFunc2( + r.tenant.users, + PathParam[int64]("tenantID"), + Query[tenantdto.AdminTenantUserListFilter]("filter"), + )) r.log.Debugf("Registering route: Get /super/v1/tenants/statuses -> tenant.statusList") router.Get("/super/v1/tenants/statuses"[len(r.Path()):], DataFunc0( r.tenant.statusList, diff --git a/backend/app/http/super/tenant.go b/backend/app/http/super/tenant.go index d4ba54c..fde1b0e 100644 --- a/backend/app/http/super/tenant.go +++ b/backend/app/http/super/tenant.go @@ -3,6 +3,7 @@ package super import ( "quyun/v2/app/errorx" "quyun/v2/app/http/super/dto" + tenantdto "quyun/v2/app/http/tenant/dto" "quyun/v2/app/requests" "quyun/v2/app/services" "quyun/v2/database/models" @@ -44,6 +45,23 @@ func (*tenant) create(ctx fiber.Ctx, form *dto.TenantCreateForm) (*models.Tenant return services.Tenant.SuperCreateTenant(ctx, form) } +// users +// +// @Summary 租户成员列表(平台侧) +// @Tags Super +// @Accept json +// @Produce json +// @Param tenantID path int64 true "TenantID" +// @Param filter query tenantdto.AdminTenantUserListFilter true "Filter" +// @Success 200 {object} requests.Pager{items=dto.SuperTenantUserItem} +// +// @Router /super/v1/tenants/:tenantID/users [get] +// @Bind tenantID path +// @Bind filter query +func (*tenant) users(ctx fiber.Ctx, tenantID int64, filter *tenantdto.AdminTenantUserListFilter) (*requests.Pager, error) { + return services.Tenant.SuperTenantUsersPage(ctx, tenantID, filter) +} + // updateExpire // // @Summary 更新过期时间 diff --git a/backend/app/services/tenant.go b/backend/app/services/tenant.go index 635fce4..c0bd841 100644 --- a/backend/app/services/tenant.go +++ b/backend/app/services/tenant.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "quyun/v2/app/http/super/dto" + superdto "quyun/v2/app/http/super/dto" tenantdto "quyun/v2/app/http/tenant/dto" "quyun/v2/app/requests" "quyun/v2/database" @@ -16,6 +16,7 @@ import ( "github.com/samber/lo" "github.com/sirupsen/logrus" "go.ipao.vip/gen" + "go.ipao.vip/gen/field" "go.ipao.vip/gen/types" "gorm.io/gorm" ) @@ -26,7 +27,7 @@ import ( type tenant struct{} // SuperCreateTenant 超级管理员创建租户,并将指定用户设为租户管理员。 -func (t *tenant) SuperCreateTenant(ctx context.Context, form *dto.TenantCreateForm) (*models.Tenant, error) { +func (t *tenant) SuperCreateTenant(ctx context.Context, form *superdto.TenantCreateForm) (*models.Tenant, error) { if form == nil { return nil, errors.New("form is nil") } @@ -42,7 +43,7 @@ func (t *tenant) SuperCreateTenant(ctx context.Context, form *dto.TenantCreateFo if form.AdminUserID <= 0 { return nil, errors.New("admin_user_id must be > 0") } - duration, err := (&dto.TenantExpireUpdateForm{Duration: form.Duration}).ParseDuration() + duration, err := (&superdto.TenantExpireUpdateForm{Duration: form.Duration}).ParseDuration() if err != nil { return nil, err } @@ -164,6 +165,95 @@ func (t *tenant) AdminTenantUsersPage(ctx context.Context, tenantID int64, filte }, nil } +// SuperTenantUsersPage 超级管理员分页查询租户成员(脱敏 user 字段,避免泄露 password)。 +func (t *tenant) SuperTenantUsersPage(ctx context.Context, tenantID int64, filter *tenantdto.AdminTenantUserListFilter) (*requests.Pager, error) { + if tenantID <= 0 { + return nil, errors.New("tenant_id must be > 0") + } + if filter == nil { + filter = &tenantdto.AdminTenantUserListFilter{} + } + + filter.Pagination.Format() + + tbl, query := models.TenantUserQuery.QueryContext(ctx) + conds := []gen.Condition{tbl.TenantID.Eq(tenantID)} + if filter.UserID != nil && *filter.UserID > 0 { + conds = append(conds, tbl.UserID.Eq(*filter.UserID)) + } + if filter.Role != nil && *filter.Role != "" { + conds = append(conds, tbl.Role.Contains(types.NewArray([]consts.TenantUserRole{*filter.Role}))) + } + if filter.Status != nil && *filter.Status != "" { + conds = append(conds, tbl.Status.Eq(*filter.Status)) + } + if username := filter.UsernameTrimmed(); username != "" { + uTbl, _ := models.UserQuery.QueryContext(ctx) + query = query.LeftJoin(uTbl, uTbl.ID.EqCol(tbl.UserID)) + conds = append(conds, uTbl.Username.Like(database.WrapLike(username))) + } + + items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) + if err != nil { + return nil, err + } + + userIDs := make([]int64, 0, len(items)) + for _, tu := range items { + if tu == nil { + continue + } + userIDs = append(userIDs, tu.UserID) + } + + var users []*models.User + if len(userIDs) > 0 { + uTbl, uQuery := models.UserQuery.QueryContext(ctx) + users, err = uQuery.Where(uTbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, err + } + } + userMap := make(map[int64]*models.User, len(users)) + for _, u := range users { + if u == nil { + continue + } + userMap[u.ID] = u + } + + out := make([]*superdto.SuperTenantUserItem, 0, len(items)) + for _, tu := range items { + if tu == nil { + continue + } + u := userMap[tu.UserID] + var lite *superdto.SuperUserLite + if u != nil { + lite = &superdto.SuperUserLite{ + ID: u.ID, + Username: u.Username, + Status: u.Status, + StatusDescription: u.Status.Description(), + Roles: u.Roles, + VerifiedAt: u.VerifiedAt, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } + } + out = append(out, &superdto.SuperTenantUserItem{ + TenantUser: tu, + User: lite, + }) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: out, + }, nil +} + func (t *tenant) ContainsUserID(ctx context.Context, tenantID, userID int64) (*models.User, error) { tbl, query := models.TenantUserQuery.QueryContext(ctx) @@ -242,12 +332,28 @@ func (t *tenant) SetUserRole(ctx context.Context, tenantID, userID int64, role . } // Pager -func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests.Pager, error) { +func (t *tenant) Pager(ctx context.Context, filter *superdto.TenantFilter) (*requests.Pager, error) { tbl, query := models.TenantQuery.QueryContext(ctx) conds := []gen.Condition{} - if filter.Name != nil { - conds = append(conds, tbl.Name.Like(database.WrapLike(*filter.Name))) + if filter == nil { + filter = &superdto.TenantFilter{} + } + + if filter.ID != nil && *filter.ID > 0 { + conds = append(conds, tbl.ID.Eq(*filter.ID)) + } + if filter.UserID != nil && *filter.UserID > 0 { + conds = append(conds, tbl.UserID.Eq(*filter.UserID)) + } + + if name := filter.NameTrimmed(); name != "" { + conds = append(conds, tbl.Name.Like(database.WrapLike(name))) + } + + if code := filter.CodeTrimmed(); code != "" { + // code 在库内按约定存储为 lower-case;这里统一转小写后做 like。 + conds = append(conds, tbl.Code.Like(database.WrapLike(code))) } if filter.Status != nil { @@ -255,7 +361,65 @@ func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests } filter.Pagination.Format() - mm, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) + + if filter.ExpiredAtFrom != nil { + conds = append(conds, tbl.ExpiredAt.Gte(*filter.ExpiredAtFrom)) + } + if filter.ExpiredAtTo != nil { + conds = append(conds, tbl.ExpiredAt.Lte(*filter.ExpiredAtTo)) + } + if filter.CreatedAtFrom != nil { + conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) + } + if filter.CreatedAtTo != nil { + conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) + } + + // 排序白名单:避免把任意字符串拼进 SQL 导致注入或慢查询。 + orderBys := make([]field.Expr, 0, 6) + allowedAsc := map[string]field.Expr{ + "id": tbl.ID.Asc(), + "code": tbl.Code.Asc(), + "name": tbl.Name.Asc(), + "status": tbl.Status.Asc(), + "expired_at": tbl.ExpiredAt.Asc(), + "created_at": tbl.CreatedAt.Asc(), + "updated_at": tbl.UpdatedAt.Asc(), + } + allowedDesc := map[string]field.Expr{ + "id": tbl.ID.Desc(), + "code": tbl.Code.Desc(), + "name": tbl.Name.Desc(), + "status": tbl.Status.Desc(), + "expired_at": tbl.ExpiredAt.Desc(), + "created_at": tbl.CreatedAt.Desc(), + "updated_at": tbl.UpdatedAt.Desc(), + } + for _, f := range filter.AscFields() { + f = strings.TrimSpace(f) + if f == "" { + continue + } + if ob, ok := allowedAsc[f]; ok { + orderBys = append(orderBys, ob) + } + } + for _, f := range filter.DescFields() { + f = strings.TrimSpace(f) + if f == "" { + continue + } + if ob, ok := allowedDesc[f]; ok { + orderBys = append(orderBys, ob) + } + } + if len(orderBys) == 0 { + orderBys = append(orderBys, tbl.ID.Desc()) + } else { + orderBys = append(orderBys, tbl.ID.Desc()) + } + + mm, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) if err != nil { return nil, err } @@ -267,20 +431,42 @@ func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests return nil, err } - userBalanceMapping, err := t.TenantUserBalanceMapping(ctx, tenantIds) + incomeMapping, err := t.TenantIncomePaidMapping(ctx, tenantIds) if err != nil { return nil, err } - items := lo.Map(mm, func(model *models.Tenant, _ int) *dto.TenantItem { - return &dto.TenantItem{ - Tenant: model, - UserCount: lo.ValueOr(userCountMapping, model.ID, 0), - UserBalance: lo.ValueOr(userBalanceMapping, model.ID, 0), - StatusDescription: model.Status.Description(), + items := lo.Map(mm, func(model *models.Tenant, _ int) *superdto.TenantItem { + return &superdto.TenantItem{ + Tenant: model, + UserCount: lo.ValueOr(userCountMapping, model.ID, 0), + IncomeAmountPaidSum: lo.ValueOr(incomeMapping, model.ID, 0), + StatusDescription: model.Status.Description(), } }) + ownerMapping, err := t.TenantOwnerUserMapping(ctx, mm) + if err != nil { + return nil, err + } + for _, it := range items { + if it == nil || it.Tenant == nil { + continue + } + it.Owner = ownerMapping[it.Tenant.ID] + } + + adminUsersMapping, err := t.TenantAdminUsersMapping(ctx, tenantIds) + if err != nil { + return nil, err + } + for _, it := range items { + if it == nil || it.Tenant == nil { + continue + } + it.AdminUsers = adminUsersMapping[it.Tenant.ID] + } + return &requests.Pager{ Pagination: filter.Pagination, Total: total, @@ -288,6 +474,124 @@ func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests }, nil } +func (t *tenant) TenantOwnerUserMapping(ctx context.Context, tenants []*models.Tenant) (map[int64]*superdto.TenantOwnerUserLite, error) { + result := make(map[int64]*superdto.TenantOwnerUserLite, len(tenants)) + + userIDs := make([]int64, 0, len(tenants)) + tenantIDs := make([]int64, 0, len(tenants)) + for _, te := range tenants { + if te == nil || te.ID <= 0 { + continue + } + tenantIDs = append(tenantIDs, te.ID) + if te.UserID > 0 { + userIDs = append(userIDs, te.UserID) + } + } + for _, tenantID := range tenantIDs { + result[tenantID] = nil + } + userIDs = lo.Uniq(userIDs) + if len(userIDs) == 0 { + return result, nil + } + + uTbl, uQuery := models.UserQuery.QueryContext(ctx) + users, err := uQuery.Where(uTbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, err + } + userMap := make(map[int64]*models.User, len(users)) + for _, u := range users { + if u == nil { + continue + } + userMap[u.ID] = u + } + + for _, te := range tenants { + if te == nil || te.ID <= 0 || te.UserID <= 0 { + continue + } + u := userMap[te.UserID] + if u == nil { + continue + } + result[te.ID] = &superdto.TenantOwnerUserLite{ + ID: u.ID, + Username: u.Username, + } + } + + return result, nil +} + +// TenantAdminUsersMapping 返回每个租户的管理员用户(用于 superadmin 租户列表展示)。 +func (t *tenant) TenantAdminUsersMapping(ctx context.Context, tenantIDs []int64) (map[int64][]*superdto.TenantAdminUserLite, error) { + result := make(map[int64][]*superdto.TenantAdminUserLite, len(tenantIDs)) + for _, id := range tenantIDs { + if id <= 0 { + continue + } + result[id] = nil + } + if len(result) == 0 { + return result, nil + } + + tuTbl, tuQuery := models.TenantUserQuery.QueryContext(ctx) + tus, err := tuQuery.Where( + tuTbl.TenantID.In(tenantIDs...), + tuTbl.Role.Contains(types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleTenantAdmin})), + ).Find() + if err != nil { + return nil, err + } + + userIDs := make([]int64, 0, len(tus)) + type pair struct { + tenantID int64 + userID int64 + } + pairs := make([]pair, 0, len(tus)) + for _, tu := range tus { + if tu == nil || tu.TenantID <= 0 || tu.UserID <= 0 { + continue + } + userIDs = append(userIDs, tu.UserID) + pairs = append(pairs, pair{tenantID: tu.TenantID, userID: tu.UserID}) + } + userIDs = lo.Uniq(userIDs) + + userMap := map[int64]*models.User{} + if len(userIDs) > 0 { + uTbl, uQuery := models.UserQuery.QueryContext(ctx) + users, err := uQuery.Where(uTbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, err + } + for _, u := range users { + if u == nil { + continue + } + userMap[u.ID] = u + } + } + + for _, p := range pairs { + u := userMap[p.userID] + if u == nil { + continue + } + result[p.tenantID] = append(result[p.tenantID], &superdto.TenantAdminUserLite{ + ID: u.ID, + Username: u.Username, + }) + } + + return result, nil +} + func (t *tenant) TenantUserCountMapping(ctx context.Context, tenantIds []int64) (map[int64]int64, error) { // 关键语义:返回值必须包含入参中的所有 tenant_id。 // 即便该租户当前没有成员,也应返回 count=0,便于调用方直接取值而无需额外补全逻辑。 @@ -368,6 +672,41 @@ func (t *tenant) TenantUserBalanceMapping(ctx context.Context, tenantIds []int64 return result, nil } +// TenantIncomePaidMapping 按租户维度统计“已支付订单”的累计收入(单位:分,CNY)。 +// 说明: +// - 仅统计 orders.status = paid 的订单金额; +// - refunding/refunded 不计入收入(避免把已退/退款中的金额当作收入)。 +func (t *tenant) TenantIncomePaidMapping(ctx context.Context, tenantIDs []int64) (map[int64]int64, error) { + result := make(map[int64]int64, len(tenantIDs)) + for _, id := range tenantIDs { + if id <= 0 { + continue + } + result[id] = 0 + } + if len(result) == 0 { + return result, nil + } + + oTbl, oQuery := models.OrderQuery.QueryContext(ctx) + var rows []struct { + TenantID int64 + Income int64 + } + err := oQuery. + Select(oTbl.TenantID, oTbl.AmountPaid.Sum().As("income")). + Where(oTbl.TenantID.In(tenantIDs...), oTbl.Status.Eq(consts.OrderStatusPaid)). + Group(oTbl.TenantID). + Scan(&rows) + if err != nil { + return nil, err + } + for _, row := range rows { + result[row.TenantID] = row.Income + } + return result, nil +} + // FindByID func (t *tenant) FindByID(ctx context.Context, id int64) (*models.Tenant, error) { tbl, query := models.TenantQuery.QueryContext(ctx) diff --git a/frontend/superadmin/dist/index.html b/frontend/superadmin/dist/index.html index 47a50cc..0ab5f2b 100644 --- a/frontend/superadmin/dist/index.html +++ b/frontend/superadmin/dist/index.html @@ -7,8 +7,8 @@ Sakai Vue - - + + diff --git a/frontend/superadmin/src/service/TenantService.js b/frontend/superadmin/src/service/TenantService.js index 3b4e406..4cfb8c9 100644 --- a/frontend/superadmin/src/service/TenantService.js +++ b/frontend/superadmin/src/service/TenantService.js @@ -7,8 +7,41 @@ function normalizeItems(items) { } export const TenantService = { - async listTenants({ page, limit, name, code, status, sortField, sortOrder } = {}) { - const query = { page, limit, name, code, status }; + async listTenants({ + page, + limit, + id, + user_id, + name, + code, + status, + expired_at_from, + expired_at_to, + created_at_from, + created_at_to, + sortField, + sortOrder + } = {}) { + 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, + id, + user_id, + name, + code, + status, + expired_at_from: iso(expired_at_from), + expired_at_to: iso(expired_at_to), + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to) + }; if (sortField && sortOrder) { if (sortOrder === 1) query.asc = sortField; if (sortOrder === -1) query.desc = sortField; @@ -33,6 +66,11 @@ export const TenantService = { } }); }, + async listTenantUsers(tenantID, { page, limit, user_id, username, role, status } = {}) { + return requestJson(`/super/v1/tenants/${tenantID}/users`, { + query: { page, limit, user_id, username, role, status } + }); + }, async renewTenantExpire({ tenantID, duration }) { return requestJson(`/super/v1/tenants/${tenantID}`, { method: 'PATCH', diff --git a/frontend/superadmin/src/views/superadmin/Tenants.vue b/frontend/superadmin/src/views/superadmin/Tenants.vue index 1dc0800..f83b47e 100644 --- a/frontend/superadmin/src/views/superadmin/Tenants.vue +++ b/frontend/superadmin/src/views/superadmin/Tenants.vue @@ -15,11 +15,30 @@ const totalRecords = ref(0); const page = ref(1); const rows = ref(10); -const keyword = ref(''); +const nameKeyword = ref(''); +const codeKeyword = ref(''); const status = ref(''); +const tenantID = ref(null); +const ownerUserID = ref(null); +const expiredAtFrom = ref(null); +const expiredAtTo = ref(null); +const createdAtFrom = ref(null); +const createdAtTo = ref(null); const sortField = ref('id'); const sortOrder = ref(-1); +const tenantUsersDialogVisible = ref(false); +const tenantUsersLoading = ref(false); +const tenantUsersTenant = ref(null); +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(''); + const createDialogVisible = ref(false); const creating = ref(false); const createCode = ref(''); @@ -38,6 +57,12 @@ function formatDate(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': @@ -76,9 +101,15 @@ async function loadTenants() { const result = await TenantService.listTenants({ page: page.value, limit: rows.value, - name: keyword.value, - code: keyword.value, + id: tenantID.value || undefined, + user_id: ownerUserID.value || undefined, + name: nameKeyword.value, + code: codeKeyword.value, status: status.value, + expired_at_from: expiredAtFrom.value || undefined, + expired_at_to: expiredAtTo.value || undefined, + created_at_from: createdAtFrom.value || undefined, + created_at_to: createdAtTo.value || undefined, sortField: sortField.value, sortOrder: sortOrder.value }); @@ -102,8 +133,15 @@ function onSearch() { } function onReset() { - keyword.value = ''; + nameKeyword.value = ''; + codeKeyword.value = ''; status.value = ''; + tenantID.value = null; + ownerUserID.value = null; + expiredAtFrom.value = null; + expiredAtTo.value = null; + createdAtFrom.value = null; + createdAtTo.value = null; sortField.value = 'id'; sortOrder.value = -1; page.value = 1; @@ -123,6 +161,70 @@ function onSort(event) { loadTenants(); } +function openTenantUsersDialog(tenant) { + tenantUsersTenant.value = tenant; + tenantUsersDialogVisible.value = true; + tenantUsersPage.value = 1; + tenantUsersRows.value = 10; + tenantUsersUsername.value = ''; + tenantUsersUserID.value = null; + tenantUsersRole.value = ''; + tenantUsersStatus.value = ''; + loadTenantUsers(); +} + +async function loadTenantUsers() { + const tenant = tenantUsersTenant.value; + const id = tenant?.id; + 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 renewDialogVisible = ref(false); const renewing = ref(false); const renewTenant = ref(null); @@ -282,17 +384,43 @@ onMounted(() => { - + + + + + + + - + + + + + + + + + + + +