improve(ui):优化成员管理页面

This commit is contained in:
qian committed 2026-08-11 23:54:26 +08:00
1 parent f8d021eb29
commit 7a6dd11814
4 files changed
+285 -60

No files matched your search

+11
View File
@@ -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 生成邀请链接请求
+82 -16
View File
@@ -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,
}
}
+126 -43
View File
@@ -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(() => {
<!-- Stats Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<div class="stat-card">
<div class="stat-icon stat-icon-primary">
<Icon name="play" size="lg" />
<article
v-for="card in statCards"
:key="card.label"
class="stat-card member-stat-card"
:style="{ '--stat-color': card.color, '--stat-soft': card.soft }"
>
<div class="member-stat-accent" aria-hidden="true"></div>
<div class="flex items-center justify-between gap-3">
<div class="member-stat-icon">
<Icon :name="card.icon" size="md" />
</div>
<p class="member-stat-value">{{ card.value }}</p>
</div>
<div>
<p class="stat-value">{{ usage ? formatNumber(usage.today_requests) : '--' }}</p>
<p class="stat-label">今日请求数</p>
<div class="mt-2.5 flex items-baseline justify-between gap-3">
<p class="flex-shrink-0 text-sm font-medium text-slate-600">{{ card.label }}</p>
<p v-if="card.detail" class="member-stat-unit" :title="card.detail">{{ card.detail }}</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon stat-icon-success">
<Icon name="chart" size="lg" />
</div>
<div>
<p class="stat-value">{{ usage ? formatNumber(usage.month_requests) : '--' }}</p>
<p class="stat-label">本月请求数</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon stat-icon-warning">
<Icon name="clipboard" size="lg" />
</div>
<div>
<p class="stat-value">{{ usage ? formatNumber(usage.month_input_tokens + usage.month_output_tokens) : '--' }}</p>
<p class="stat-label">本月 Token 用量</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon stat-icon-danger">
<Icon name="creditCard" size="lg" />
</div>
<div>
<p class="stat-value">{{ usage ? '$' + usage.month_total_cost.toFixed(2) : '--' }}</p>
<p class="stat-label">本月消费</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon stat-icon-primary">
<Icon name="key" size="lg" />
</div>
<div>
<p class="stat-value">{{ apiKeyCount }}</p>
<p class="stat-label">API Key 数量</p>
</div>
</div>
</article>
</div>
<!-- Info Grid -->
@@ -904,3 +923,67 @@ onMounted(() => {
</BaseModal>
</div>
</template>
<style scoped>
/* ============ 成员统计卡片(对齐仪表盘 metric-card 风格) ============ */
.member-stat-card {
min-height: 108px;
display: flex;
flex-direction: column;
padding: 1.125rem 1.25rem;
transition: transform 220ms ease, box-shadow 220ms ease;
}
.member-stat-card:hover {
transform: translateY(-3px);
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.12);
}
.member-stat-accent {
position: absolute;
top: 0;
right: 1.25rem;
left: 1.25rem;
height: 2px;
border-radius: 0 0 9999px 9999px;
background: linear-gradient(90deg, transparent, var(--stat-color), transparent);
opacity: 0.7;
}
.member-stat-icon {
display: flex;
height: 2.5rem;
width: 2.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.82);
border-radius: 0.75rem;
background: var(--stat-soft);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
color: var(--stat-color);
}
.member-stat-value {
min-width: 0;
overflow: hidden;
color: #172033;
font-size: 1.75rem;
font-weight: 750;
font-variant-numeric: tabular-nums;
line-height: 1;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-stat-unit {
min-width: 0;
overflow: hidden;
color: #94a3b8;
font-size: 0.6875rem;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+66 -1
View File
@@ -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<string, string> = {
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<typeof h>[] = [
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<DataTableColumns<Member>>(() => [
{
@@ -283,12 +324,36 @@ const columns = computed<DataTableColumns<Member>>(() => [
}
),
},
{
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',