Commit 961c30e7 authored by QTom's avatar QTom
Browse files

feat(admin): 分组管理列表新增用量列与账号数分类



分组管理列表增强:

1. 今日/累计用量列:
   - 新增独立端点 GET /admin/groups/usage-summary
   - 一次查询返回所有分组的今日费用和累计费用(actual_cost)
   - 前端异步加载后合并显示在分组列表中

2. 账号数区分可用/限流/总量:
   - 将账号数列从单一总量改为 badge 内多行展示
   - 可用: active + schedulable 的账号数(绿色)
   - 限流: rate_limit/overload/temp_unschedulable 的账号数(橙色,无限流时隐藏)
   - 总量: 全部关联账号数
Co-Authored-By: default avatarClaude Opus 4.6 (1M context) <noreply@anthropic.com>
parent 045cba78
...@@ -27,7 +27,7 @@ type GroupRepository interface { ...@@ -27,7 +27,7 @@ type GroupRepository interface {
ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error) ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error)
ExistsByName(ctx context.Context, name string) (bool, error) ExistsByName(ctx context.Context, name string) (bool, error)
GetAccountCount(ctx context.Context, groupID int64) (int64, error) GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error)
DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error)
// GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID(去重) // GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID(去重)
GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error)
...@@ -202,7 +202,7 @@ func (s *GroupService) GetStats(ctx context.Context, id int64) (map[string]any, ...@@ -202,7 +202,7 @@ func (s *GroupService) GetStats(ctx context.Context, id int64) (map[string]any,
} }
// 获取账号数量 // 获取账号数量
accountCount, err := s.groupRepo.GetAccountCount(ctx, id) accountCount, _, err := s.groupRepo.GetAccountCount(ctx, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("get account count: %w", err) return nil, fmt.Errorf("get account count: %w", err)
} }
......
...@@ -52,8 +52,8 @@ func (r *stubGroupRepoForQuota) ListActiveByPlatform(context.Context, string) ([ ...@@ -52,8 +52,8 @@ func (r *stubGroupRepoForQuota) ListActiveByPlatform(context.Context, string) ([
func (r *stubGroupRepoForQuota) ExistsByName(context.Context, string) (bool, error) { func (r *stubGroupRepoForQuota) ExistsByName(context.Context, string) (bool, error) {
return false, nil return false, nil
} }
func (r *stubGroupRepoForQuota) GetAccountCount(context.Context, int64) (int64, error) { func (r *stubGroupRepoForQuota) GetAccountCount(context.Context, int64) (int64, int64, error) {
return 0, nil return 0, 0, nil
} }
func (r *stubGroupRepoForQuota) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { func (r *stubGroupRepoForQuota) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
return 0, nil return 0, nil
......
...@@ -40,7 +40,7 @@ func (groupRepoNoop) ListActiveByPlatform(context.Context, string) ([]Group, err ...@@ -40,7 +40,7 @@ func (groupRepoNoop) ListActiveByPlatform(context.Context, string) ([]Group, err
func (groupRepoNoop) ExistsByName(context.Context, string) (bool, error) { func (groupRepoNoop) ExistsByName(context.Context, string) (bool, error) {
panic("unexpected ExistsByName call") panic("unexpected ExistsByName call")
} }
func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, error) { func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, int64, error) {
panic("unexpected GetAccountCount call") panic("unexpected GetAccountCount call")
} }
func (groupRepoNoop) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { func (groupRepoNoop) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
......
...@@ -218,6 +218,22 @@ export async function batchSetGroupRateMultipliers( ...@@ -218,6 +218,22 @@ export async function batchSetGroupRateMultipliers(
return data return data
} }
/**
* Get usage summary (today + cumulative cost) for all groups
* @param timezone - IANA timezone string (e.g. "Asia/Shanghai")
* @returns Array of group usage summaries
*/
export async function getUsageSummary(
timezone?: string
): Promise<{ group_id: number; today_cost: number; total_cost: number }[]> {
const { data } = await apiClient.get<
{ group_id: number; today_cost: number; total_cost: number }[]
>('/admin/groups/usage-summary', {
params: timezone ? { timezone } : undefined
})
return data
}
export const groupsAPI = { export const groupsAPI = {
list, list,
getAll, getAll,
...@@ -232,7 +248,8 @@ export const groupsAPI = { ...@@ -232,7 +248,8 @@ export const groupsAPI = {
getGroupRateMultipliers, getGroupRateMultipliers,
clearGroupRateMultipliers, clearGroupRateMultipliers,
batchSetGroupRateMultipliers, batchSetGroupRateMultipliers,
updateSortOrder updateSortOrder,
getUsageSummary
} }
export default groupsAPI export default groupsAPI
...@@ -1505,6 +1505,7 @@ export default { ...@@ -1505,6 +1505,7 @@ export default {
rateMultiplier: 'Rate Multiplier', rateMultiplier: 'Rate Multiplier',
type: 'Type', type: 'Type',
accounts: 'Accounts', accounts: 'Accounts',
usage: 'Usage',
status: 'Status', status: 'Status',
actions: 'Actions', actions: 'Actions',
billingType: 'Billing Type', billingType: 'Billing Type',
...@@ -1513,6 +1514,12 @@ export default { ...@@ -1513,6 +1514,12 @@ export default {
userNotes: 'Notes', userNotes: 'Notes',
userStatus: 'Status' userStatus: 'Status'
}, },
usageToday: 'Today',
usageTotal: 'Total',
accountsAvailable: 'Avail:',
accountsRateLimited: 'Limited:',
accountsTotal: 'Total:',
accountsUnit: '',
rateAndAccounts: '{rate}x rate · {count} accounts', rateAndAccounts: '{rate}x rate · {count} accounts',
accountsCount: '{count} accounts', accountsCount: '{count} accounts',
form: { form: {
......
...@@ -1561,6 +1561,7 @@ export default { ...@@ -1561,6 +1561,7 @@ export default {
priority: '优先级', priority: '优先级',
apiKeys: 'API 密钥数', apiKeys: 'API 密钥数',
accounts: '账号数', accounts: '账号数',
usage: '用量',
status: '状态', status: '状态',
actions: '操作', actions: '操作',
billingType: '计费类型', billingType: '计费类型',
...@@ -1569,6 +1570,12 @@ export default { ...@@ -1569,6 +1570,12 @@ export default {
userNotes: '备注', userNotes: '备注',
userStatus: '状态' userStatus: '状态'
}, },
usageToday: '今日',
usageTotal: '累计',
accountsAvailable: '可用:',
accountsRateLimited: '限流:',
accountsTotal: '总量:',
accountsUnit: '个账号',
form: { form: {
name: '名称', name: '名称',
description: '描述', description: '描述',
......
...@@ -411,6 +411,8 @@ export interface AdminGroup extends Group { ...@@ -411,6 +411,8 @@ export interface AdminGroup extends Group {
// 分组下账号数量(仅管理员可见) // 分组下账号数量(仅管理员可见)
account_count?: number account_count?: number
active_account_count?: number
rate_limited_account_count?: number
// OpenAI Messages 调度配置(仅 openai 平台使用) // OpenAI Messages 调度配置(仅 openai 平台使用)
default_mapped_model?: string default_mapped_model?: string
......
...@@ -158,12 +158,38 @@ ...@@ -158,12 +158,38 @@
</span> </span>
</template> </template>
<template #cell-account_count="{ value }"> <template #cell-account_count="{ row }">
<span <div class="space-y-0.5 text-xs">
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300" <div>
> <span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsAvailable') }}</span>
{{ t('admin.groups.accountsCount', { count: value || 0 }) }} <span class="ml-1 font-medium text-emerald-600 dark:text-emerald-400">{{ (row.active_account_count || 0) - (row.rate_limited_account_count || 0) }}</span>
</span> <span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
</div>
<div v-if="row.rate_limited_account_count">
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsRateLimited') }}</span>
<span class="ml-1 font-medium text-amber-600 dark:text-amber-400">{{ row.rate_limited_account_count }}</span>
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsTotal') }}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ row.account_count || 0 }}</span>
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
</div>
</div>
</template>
<template #cell-usage="{ row }">
<div v-if="usageLoading" class="text-xs text-gray-400"></div>
<div v-else class="space-y-0.5 text-xs">
<div class="text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.usageToday') }}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.today_cost ?? 0) }}</span>
</div>
<div class="text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.usageTotal') }}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.total_cost ?? 0) }}</span>
</div>
</div>
</template> </template>
<template #cell-status="{ value }"> <template #cell-status="{ value }">
...@@ -1827,6 +1853,7 @@ const columns = computed<Column[]>(() => [ ...@@ -1827,6 +1853,7 @@ const columns = computed<Column[]>(() => [
{ key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true }, { key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true },
{ key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true }, { key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true },
{ key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true }, { key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true },
{ key: 'usage', label: t('admin.groups.columns.usage'), sortable: false },
{ key: 'status', label: t('admin.groups.columns.status'), sortable: true }, { key: 'status', label: t('admin.groups.columns.status'), sortable: true },
{ key: 'actions', label: t('admin.groups.columns.actions'), sortable: false } { key: 'actions', label: t('admin.groups.columns.actions'), sortable: false }
]) ])
...@@ -1963,6 +1990,8 @@ const copyAccountsGroupOptionsForEdit = computed(() => { ...@@ -1963,6 +1990,8 @@ const copyAccountsGroupOptionsForEdit = computed(() => {
const groups = ref<AdminGroup[]>([]) const groups = ref<AdminGroup[]>([])
const loading = ref(false) const loading = ref(false)
const usageMap = ref<Map<number, { today_cost: number; total_cost: number }>>(new Map())
const usageLoading = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const filters = reactive({ const filters = reactive({
platform: '', platform: '',
...@@ -2301,6 +2330,7 @@ const loadGroups = async () => { ...@@ -2301,6 +2330,7 @@ const loadGroups = async () => {
groups.value = response.items groups.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
loadUsageSummary()
} catch (error: any) { } catch (error: any) {
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') { if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
return return
...@@ -2314,6 +2344,29 @@ const loadGroups = async () => { ...@@ -2314,6 +2344,29 @@ const loadGroups = async () => {
} }
} }
const formatCost = (cost: number): string => {
if (cost >= 1000) return cost.toFixed(0)
if (cost >= 100) return cost.toFixed(1)
return cost.toFixed(2)
}
const loadUsageSummary = async () => {
usageLoading.value = true
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
const data = await adminAPI.groups.getUsageSummary(tz)
const map = new Map<number, { today_cost: number; total_cost: number }>()
for (const item of data) {
map.set(item.group_id, { today_cost: item.today_cost, total_cost: item.total_cost })
}
usageMap.value = map
} catch (error) {
console.error('Error loading group usage summary:', error)
} finally {
usageLoading.value = false
}
}
let searchTimeout: ReturnType<typeof setTimeout> let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => { const handleSearch = () => {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment