feat: 更新租户和订单相关功能,添加租户成员列表接口,优化数据处理和前端展示

This commit is contained in:
2025-12-23 23:38:05 +08:00
parent bcb8c822f1
commit 26e4279f1e
10 changed files with 750 additions and 34 deletions

View File

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

View File

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

View File

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

View File

@@ -22,4 +22,3 @@ type order struct{}
func (*order) statistics(ctx fiber.Ctx) (*dto.OrderStatisticsResponse, error) {
return services.Order.SuperStatistics(ctx)
}

View File

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

View File

@@ -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 更新过期时间

View File

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

View File

@@ -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-Brwtp57n.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Ba8sjR1v.css">
<script type="module" crossorigin src="./assets/index-CpopCMB_.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BJeWLZjR.css">
</head>
<body>

View File

@@ -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',

View File

@@ -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(() => {
</div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="名称 / Code">
<SearchField label="TenantID">
<InputNumber v-model="tenantID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="Owner UserID">
<InputNumber v-model="ownerUserID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="名称">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="keyword" placeholder="请输入" class="w-full" @keyup.enter="onSearch" />
<InputText v-model="nameKeyword" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</IconField>
</SearchField>
<SearchField label="Code">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="codeKeyword" placeholder="模糊匹配(小写)" class="w-full" @keyup.enter="onSearch" />
</IconField>
</SearchField>
<SearchField label="状态">
<Select v-model="status" :options="tenantStatusOptions" optionLabel="label" optionValue="value" placeholder="请选择" :loading="tenantStatusOptionsLoading" class="w-full" />
</SearchField>
<SearchField label="过期时间 From">
<DatePicker v-model="expiredAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="过期时间 To">
<DatePicker v-model="expiredAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
<SearchField label="创建时间 From">
<DatePicker v-model="createdAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="创建时间 To">
<DatePicker v-model="createdAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
</SearchPanel>
<DataTable
@@ -319,13 +447,42 @@ 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="user_id" header="Owner" sortable style="min-width: 12rem">
<template #body="{ data }">
<span v-if="data.owner?.username">{{ data.owner.username }}</span>
<span v-else class="text-muted-color">{{ data.user_id ?? '-' }}</span>
</template>
</Column>
<Column header="管理员" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex flex-wrap gap-1">
<Tag v-for="u in data.admin_users || []" :key="u.id" :value="u.username || String(u.id)" severity="info" />
<span v-if="!data.admin_users || data.admin_users.length === 0" class="text-muted-color">-</span>
</div>
</template>
</Column>
<Column field="status_description" header="状态" style="min-width: 10rem">
<template #body="{ data }">
<Tag :value="data.status_description || data.status || '-'" :severity="getStatusSeverity(data.status)" class="cursor-pointer" @click="openTenantStatusDialog(data)" />
</template>
</Column>
<Column field="user_count" header="用户数" sortable style="min-width: 8rem" />
<Column field="user_balance" header="余额" sortable style="min-width: 8rem" />
<Column field="user_count" header="用户数" sortable style="min-width: 8rem">
<template #body="{ data }">
<Button
:label="String(data.user_count ?? 0)"
text
size="small"
icon="pi pi-users"
class="p-0"
@click="openTenantUsersDialog(data)"
/>
</template>
</Column>
<Column field="income_amount_paid_sum" header="累计收入" sortable style="min-width: 10rem">
<template #body="{ data }">
{{ formatCny(data.income_amount_paid_sum) }}
</template>
</Column>
<Column field="expired_at" header="过期时间" sortable style="min-width: 14rem">
<template #body="{ data }">
<span v-if="data.expired_at" v-tooltip="getExpiryDaysInfo(data.expired_at).tooltipText" :class="getExpiryDaysInfo(data.expired_at).textClass">
@@ -444,5 +601,96 @@ onMounted(() => {
<Button label="确认创建" icon="pi pi-check" @click="confirmCreateTenant" :loading="creating" :disabled="!canCreateTenant" />
</template>
</Dialog>
<Dialog v-model:visible="tenantUsersDialogVisible" :modal="true" :style="{ width: '980px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">租户成员</span>
<span class="text-muted-color truncate max-w-[420px]">{{ tenantUsersTenant?.name ?? tenantUsersTenant?.code ?? '-' }}</span>
</div>
</template>
<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="用户名">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="tenantUsersUsername" placeholder="模糊匹配" class="w-full" @keyup.enter="onTenantUsersSearch" />
</IconField>
</SearchField>
<SearchField label="角色">
<Select
v-model="tenantUsersRole"
:options="[
{ label: '全部', value: '' },
{ label: 'member', value: 'member' },
{ label: 'tenant_admin', value: 'tenant_admin' }
]"
optionLabel="label"
optionValue="value"
placeholder="请选择"
class="w-full"
/>
</SearchField>
<SearchField label="状态">
<Select
v-model="tenantUsersStatus"
:options="[
{ label: '全部', value: '' },
{ label: 'pending_verify', value: 'pending_verify' },
{ label: 'verified', value: 'verified' },
{ label: 'banned', value: 'banned' }
]"
optionLabel="label"
optionValue="value"
placeholder="请选择"
class="w-full"
/>
</SearchField>
</SearchPanel>
<DataTable
:value="tenantUsers"
dataKey="tenant_user.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="420px"
responsiveLayout="scroll"
>
<Column field="tenant_user.user_id" header="UserID" style="min-width: 7rem" />
<Column field="user.username" header="用户名" style="min-width: 14rem" />
<Column field="tenant_user.role" header="角色" style="min-width: 12rem">
<template #body="{ data }">
<div class="flex flex-wrap gap-1">
<Tag v-for="r in data?.tenant_user?.role || []" :key="r" :value="r" severity="secondary" />
<span v-if="!data?.tenant_user?.role || data.tenant_user.role.length === 0" class="text-muted-color">-</span>
</div>
</template>
</Column>
<Column field="user.status_description" header="状态" style="min-width: 10rem" />
<Column field="tenant_user.created_at" header="加入时间" style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data?.tenant_user?.created_at) }}
</template>
</Column>
</DataTable>
</div>
<template #footer>
<Button label="关闭" icon="pi pi-times" text @click="tenantUsersDialogVisible = false" />
</template>
</Dialog>
</div>
</template>