improve(ui):优化成员管理页面
This commit is contained in:
1 parent
f8d021eb29
commit
7a6dd11814
4 files changed
+285
-60
No files matched your search
@@ -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 生成邀请链接请求
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user