diff --git a/api/tenant/v1/member.go b/api/tenant/v1/member.go
index 5139708..6543692 100644
--- a/api/tenant/v1/member.go
+++ b/api/tenant/v1/member.go
@@ -27,6 +27,17 @@ type TenantMemberItem struct {
Status string `json:"status"`
LockedUntil string `json:"locked_until"`
CreatedAt string `json:"created_at"`
+ // 额度限制(直接取自 tnt_users,与详情页 MemberQuota 口径一致)
+ QuotaType string `json:"quota_type"` // none / total / periodic
+ QuotaLimit float64 `json:"quota_limit"` // 额度上限 USD(none 时为 0)
+ QuotaPeriod string `json:"quota_period"` // day / week / month(仅 periodic)
+ // 可用模型数(取自 tnt_member_model_scopes 聚合)
+ ModelCount int `json:"model_count"` // 已授权模型数(model_id>0 的数量)
+ ModelUnlimited bool `json:"model_unlimited"` // true=不限制(无任何模型范围记录)
+ // 本月消费(取自 bil_usage_logs 当月 SUM(total_cost),USD)
+ MonthCost float64 `json:"month_cost"`
+ // 最后更新时间(tnt_users.updated_at)
+ UpdatedAt string `json:"updated_at"`
}
// TenantMemberInviteReq 生成邀请链接请求
diff --git a/internal/logic/tenant/member.go b/internal/logic/tenant/member.go
index f90674a..1db6cea 100644
--- a/internal/logic/tenant/member.go
+++ b/internal/logic/tenant/member.go
@@ -48,14 +48,18 @@ func (s *sTenant) ListMembers(ctx context.Context, req *v1.TenantMemberListReq)
}
var users []struct {
- Id int64 `json:"id"`
- Username string `json:"username"`
- Email string `json:"email"`
- DisplayName string `json:"display_name"`
- Role string `json:"role"`
- Status string `json:"status"`
- LockedUntil string `json:"locked_until"`
- CreatedAt string `json:"created_at"`
+ Id int64 `json:"id"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ Role string `json:"role"`
+ Status string `json:"status"`
+ LockedUntil string `json:"locked_until"`
+ CreatedAt string `json:"created_at"`
+ QuotaType string `json:"quota_type"`
+ QuotaLimit float64 `json:"quota_limit"`
+ QuotaPeriod string `json:"quota_period"`
+ UpdatedAt string `json:"updated_at"`
}
err = model.OrderDesc("id").
Page(page, pageSize).
@@ -64,17 +68,79 @@ func (s *sTenant) ListMembers(ctx context.Context, req *v1.TenantMemberListReq)
return nil, err
}
+ // 收集本页用户 ID,批量聚合模型范围与本月消费,避免逐行 N+1 查询
+ userIds := make([]int64, len(users))
+ for i, u := range users {
+ userIds[i] = u.Id
+ }
+
+ // 模型范围:有记录=受限(unlimited=false),无任何记录=不限制(unlimited=true)
+ // model_id>0 计入有效授权数;model_id=-1 为「禁止所有」哨兵,不计入
+ modelCountMap := make(map[int64]int)
+ hasScopeMap := make(map[int64]bool)
+ if len(userIds) > 0 {
+ var scopeRows []struct {
+ UserId int64 `json:"user_id"`
+ ModelId int64 `json:"model_id"`
+ }
+ if err = dao.TntMemberModelScopes.Ctx(ctx).
+ Where("tenant_id", tenantID).
+ Where("user_id", userIds).
+ Scan(&scopeRows); err != nil {
+ if err = common.IgnoreScanNoRows(err); err != nil {
+ return nil, err
+ }
+ }
+ for _, r := range scopeRows {
+ hasScopeMap[r.UserId] = true
+ if r.ModelId > 0 {
+ modelCountMap[r.UserId]++
+ }
+ }
+ }
+
+ // 本月消费:按 user_id 聚合当月 total_cost,口径与 GetMemberUsage 一致
+ monthCostMap := make(map[int64]float64)
+ if len(userIds) > 0 {
+ monthStart := time.Now().Format("2006-01") + "-01 00:00:00"
+ var costRows []struct {
+ UserId int64 `json:"user_id"`
+ Cost float64 `json:"cost"`
+ }
+ if err = dao.BilUsageLogs.Ctx(ctx).
+ Fields("user_id, COALESCE(SUM(total_cost), 0) as cost").
+ Where("tenant_id", tenantID).
+ Where("user_id", userIds).
+ Where("created_at >= ?", monthStart).
+ Group("user_id").
+ Scan(&costRows); err != nil {
+ if err = common.IgnoreScanNoRows(err); err != nil {
+ return nil, err
+ }
+ }
+ for _, c := range costRows {
+ monthCostMap[c.UserId] = c.Cost
+ }
+ }
+
items := make([]v1.TenantMemberItem, len(users))
for i, u := range users {
items[i] = v1.TenantMemberItem{
- ID: u.Id,
- Username: u.Username,
- Email: u.Email,
- DisplayName: u.DisplayName,
- Role: u.Role,
- Status: u.Status,
- LockedUntil: u.LockedUntil,
- CreatedAt: u.CreatedAt,
+ ID: u.Id,
+ Username: u.Username,
+ Email: u.Email,
+ DisplayName: u.DisplayName,
+ Role: u.Role,
+ Status: u.Status,
+ LockedUntil: u.LockedUntil,
+ CreatedAt: u.CreatedAt,
+ QuotaType: u.QuotaType,
+ QuotaLimit: u.QuotaLimit,
+ QuotaPeriod: u.QuotaPeriod,
+ ModelCount: modelCountMap[u.Id],
+ ModelUnlimited: !hasScopeMap[u.Id],
+ MonthCost: monthCostMap[u.Id],
+ UpdatedAt: u.UpdatedAt,
}
}
diff --git a/web/tenant/src/views/team/MemberDetailPage.vue b/web/tenant/src/views/team/MemberDetailPage.vue
index efda7d8..8231c5e 100644
--- a/web/tenant/src/views/team/MemberDetailPage.vue
+++ b/web/tenant/src/views/team/MemberDetailPage.vue
@@ -131,6 +131,52 @@ function formatDate(d: string | null | undefined): string {
return d.replace('T', ' ').substring(0, 16)
}
+const statCards = computed(() => {
+ const u = usage.value
+ return [
+ {
+ label: '今日请求数',
+ icon: 'play',
+ color: '#06b6d4',
+ soft: '#cffafe',
+ value: u ? formatNumber(u.today_requests) : '--',
+ detail: u ? '次' : '',
+ },
+ {
+ label: '本月请求数',
+ icon: 'chart',
+ color: '#10b981',
+ soft: '#d1fae5',
+ value: u ? formatNumber(u.month_requests) : '--',
+ detail: u ? '次' : '',
+ },
+ {
+ label: '本月 Token 用量',
+ icon: 'clipboard',
+ color: '#f59e0b',
+ soft: '#fef3c7',
+ value: u ? formatNumber(u.month_input_tokens + u.month_output_tokens) : '--',
+ detail: u ? `输入 ${formatNumber(u.month_input_tokens)} · 输出 ${formatNumber(u.month_output_tokens)}` : '',
+ },
+ {
+ label: '本月消费',
+ icon: 'creditCard',
+ color: '#8b5cf6',
+ soft: '#ede9fe',
+ value: u ? `$${u.month_total_cost.toFixed(2)}` : '--',
+ detail: u ? 'USD' : '',
+ },
+ {
+ label: 'API Key 数量',
+ icon: 'key',
+ color: '#0ea5e9',
+ soft: '#e0f2fe',
+ value: String(apiKeyCount.value),
+ detail: '个',
+ },
+ ]
+})
+
async function fetchMemberDetail() {
loading.value = true
try {
@@ -471,51 +517,24 @@ onMounted(() => {
-
-
-
+
+
+
-
-
{{ usage ? formatNumber(usage.today_requests) : '--' }}
-
今日请求数
+
+
{{ card.label }}
+
{{ card.detail }}
-
-
-
-
-
-
-
{{ usage ? formatNumber(usage.month_requests) : '--' }}
-
本月请求数
-
-
-
-
-
-
-
-
{{ usage ? formatNumber(usage.month_input_tokens + usage.month_output_tokens) : '--' }}
-
本月 Token 用量
-
-
-
-
-
-
-
-
{{ usage ? '$' + usage.month_total_cost.toFixed(2) : '--' }}
-
本月消费
-
-
-
-
-
-
-
-
{{ apiKeyCount }}
-
API Key 数量
-
-
+
@@ -904,3 +923,67 @@ onMounted(() => {
+
+
diff --git a/web/tenant/src/views/team/MembersPage.vue b/web/tenant/src/views/team/MembersPage.vue
index 8fe7638..854ffa9 100644
--- a/web/tenant/src/views/team/MembersPage.vue
+++ b/web/tenant/src/views/team/MembersPage.vue
@@ -9,7 +9,7 @@ import TeamLockedBanner from '@/components/common/TeamLockedBanner.vue'
import BaseSelect from '../../components/common/BaseSelect.vue'
import TableFilterForm, { type FilterField } from '@/components/common/TableFilterForm.vue'
import Icon from '@/components/common/Icon.vue'
-import { renderBadge, BADGE_TYPE_MAP, tableScrollX } from '@/utils/renderUtils'
+import { renderBadge, BADGE_TYPE_MAP, tableScrollX, formatMoney, formatDate } from '@/utils/renderUtils'
import request from '@/utils/request'
import { toast } from '@/utils/toast'
import { useExport } from '@/composables/useExport'
@@ -51,6 +51,17 @@ interface Member {
role: string
status: string
created_at: string
+ // 额度限制
+ quota_type: string
+ quota_limit: number
+ quota_period: string
+ // 可用模型数
+ model_count: number
+ model_unlimited: boolean
+ // 本月消费(USD)
+ month_cost: number
+ // 最后更新时间
+ updated_at: string
}
@@ -238,6 +249,36 @@ function goDetail(memberId: number) {
router.push(`/tenant/members/${memberId}`)
}
+const periodLabel: Record
= {
+ day: '按天',
+ week: '按周',
+ month: '按月',
+}
+
+// 额度限制列渲染:不限制 / 金额(周期性追加周期徽章)
+function renderQuota(row: Member) {
+ if (!row.quota_type || row.quota_type === 'none') {
+ return h(NTag, { size: 'small', type: 'info', bordered: false }, { default: () => '不限制' })
+ }
+ const children: ReturnType[] = [
+ h('span', { class: 'text-sm font-medium text-gray-900' }, formatMoney(row.quota_limit, { precision: 2 })),
+ ]
+ if (row.quota_type === 'periodic' && row.quota_period) {
+ children.push(
+ h(NTag, { size: 'small', type: 'warning', bordered: false }, { default: () => periodLabel[row.quota_period] || row.quota_period })
+ )
+ }
+ return h('div', { class: 'flex items-center gap-1.5' }, children)
+}
+
+// 可用模型数列渲染:不限 / 授权数量
+function renderModelCount(row: Member) {
+ if (row.model_unlimited) {
+ return h(NTag, { size: 'small', type: 'info', bordered: false }, { default: () => '不限' })
+ }
+ return h('span', { class: 'text-sm font-medium text-gray-900' }, String(row.model_count))
+}
+
// NDataTable 列定义
const columns = computed>(() => [
{
@@ -283,12 +324,36 @@ const columns = computed>(() => [
}
),
},
+ {
+ title: '额度限制',
+ key: 'quota_type',
+ width: 130,
+ render: (row) => renderQuota(row),
+ },
+ {
+ title: '可用模型',
+ key: 'model_count',
+ width: 100,
+ render: (row) => renderModelCount(row),
+ },
+ {
+ title: '本月消费',
+ key: 'month_cost',
+ width: 120,
+ render: (row) => h('span', { class: 'text-sm font-semibold text-primary-600' }, formatMoney(row.month_cost, { precision: 2 })),
+ },
{
title: '加入时间',
key: 'created_at',
width: 170,
render: (row) => row.created_at || '--',
},
+ {
+ title: '最后更新',
+ key: 'updated_at',
+ width: 170,
+ render: (row) => formatDate(row.updated_at),
+ },
{
title: '操作',
key: 'actions',