From 7a6dd11814fce432daa4e8c2885bfffbdd93f705 Mon Sep 17 00:00:00 2001 From: qian Date: Tue, 11 Aug 2026 23:54:26 +0800 Subject: [PATCH] =?UTF-8?q?improve(ui):=E4=BC=98=E5=8C=96=E6=88=90?= =?UTF-8?q?=E5=91=98=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/tenant/v1/member.go | 11 ++ internal/logic/tenant/member.go | 98 ++++++++-- .../src/views/team/MemberDetailPage.vue | 169 +++++++++++++----- web/tenant/src/views/team/MembersPage.vue | 67 ++++++- 4 files changed, 285 insertions(+), 60 deletions(-) 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(() => {
-
-
- +
+ +
+
+ +
+

{{ card.value }}

-
-

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