Skip to content

Commit f0fabf8

Browse files
committed
feat: 用户列表显示订阅分组及剩余天数
- User模型新增Subscriptions关联 - 用户列表批量加载订阅信息避免N+1查询 - GroupBadge组件支持显示剩余天数(过期红色、<=3天红色、<=7天橙色) - 用户管理页面新增订阅分组列
1 parent 5bbfbcd commit f0fabf8

7 files changed

Lines changed: 124 additions & 15 deletions

File tree

backend/internal/model/user.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ type User struct {
2222
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
2323

2424
// 关联
25-
ApiKeys []ApiKey `gorm:"foreignKey:UserID" json:"api_keys,omitempty"`
25+
ApiKeys []ApiKey `gorm:"foreignKey:UserID" json:"api_keys,omitempty"`
26+
Subscriptions []UserSubscription `gorm:"foreignKey:UserID" json:"subscriptions,omitempty"`
2627
}
2728

2829
func (User) TableName() string {

backend/internal/repository/user_repo.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,37 @@ func (r *UserRepository) ListWithFilters(ctx context.Context, params pagination.
7373
return nil, nil, err
7474
}
7575

76+
// Query users with pagination (reuse the same db with filters applied)
7677
if err := db.Offset(params.Offset()).Limit(params.Limit()).Order("id DESC").Find(&users).Error; err != nil {
7778
return nil, nil, err
7879
}
7980

81+
// Batch load subscriptions for all users (avoid N+1)
82+
if len(users) > 0 {
83+
userIDs := make([]int64, len(users))
84+
userMap := make(map[int64]*model.User, len(users))
85+
for i := range users {
86+
userIDs[i] = users[i].ID
87+
userMap[users[i].ID] = &users[i]
88+
}
89+
90+
// Query active subscriptions with groups in one query
91+
var subscriptions []model.UserSubscription
92+
if err := r.db.WithContext(ctx).
93+
Preload("Group").
94+
Where("user_id IN ? AND status = ?", userIDs, model.SubscriptionStatusActive).
95+
Find(&subscriptions).Error; err != nil {
96+
return nil, nil, err
97+
}
98+
99+
// Associate subscriptions with users
100+
for i := range subscriptions {
101+
if user, ok := userMap[subscriptions[i].UserID]; ok {
102+
user.Subscriptions = append(user.Subscriptions, subscriptions[i])
103+
}
104+
}
105+
}
106+
80107
pages := int(total) / params.Limit()
81108
if int(total)%params.Limit() > 0 {
82109
pages++

frontend/src/components/common/GroupBadge.vue

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
<PlatformIcon v-if="platform" :platform="platform" size="sm" />
1010
<!-- Group name -->
1111
<span class="truncate">{{ name }}</span>
12-
<!-- Right side label: subscription shows "订阅", standard shows rate multiplier -->
12+
<!-- Right side label -->
1313
<span
14-
v-if="showRate"
14+
v-if="showLabel"
1515
:class="labelClass"
1616
>
1717
{{ labelText }}
@@ -31,39 +31,73 @@ interface Props {
3131
subscriptionType?: SubscriptionType
3232
rateMultiplier?: number
3333
showRate?: boolean
34+
daysRemaining?: number | null // 剩余天数(订阅类型时使用)
3435
}
3536
3637
const props = withDefaults(defineProps<Props>(), {
3738
subscriptionType: 'standard',
38-
showRate: true
39+
showRate: true,
40+
daysRemaining: null
3941
})
4042
4143
const { t } = useI18n()
4244
4345
const isSubscription = computed(() => props.subscriptionType === 'subscription')
4446
45-
// Label text: subscription shows localized text, standard shows rate
47+
// 是否显示右侧标签
48+
const showLabel = computed(() => {
49+
if (!props.showRate) return false
50+
// 订阅类型:显示天数或"订阅"
51+
if (isSubscription.value) return true
52+
// 标准类型:显示倍率
53+
return props.rateMultiplier !== undefined
54+
})
55+
56+
// Label text
4657
const labelText = computed(() => {
4758
if (isSubscription.value) {
59+
// 如果有剩余天数,显示天数
60+
if (props.daysRemaining !== null && props.daysRemaining !== undefined) {
61+
if (props.daysRemaining <= 0) {
62+
return t('admin.users.expired')
63+
}
64+
return t('admin.users.daysRemaining', { days: props.daysRemaining })
65+
}
66+
// 否则显示"订阅"
4867
return t('groups.subscription')
4968
}
5069
return props.rateMultiplier !== undefined ? `${props.rateMultiplier}x` : ''
5170
})
5271
53-
// Label style based on type
72+
// Label style based on type and days remaining
5473
const labelClass = computed(() => {
5574
const base = 'px-1.5 py-0.5 rounded text-[10px] font-semibold'
56-
if (isSubscription.value) {
57-
// Subscription: more prominent style with border
58-
if (props.platform === 'anthropic') {
59-
return `${base} bg-orange-200/60 text-orange-800 dark:bg-orange-800/40 dark:text-orange-300`
60-
} else if (props.platform === 'openai') {
61-
return `${base} bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300`
75+
76+
if (!isSubscription.value) {
77+
// Standard: subtle background
78+
return `${base} bg-black/10 dark:bg-white/10`
79+
}
80+
81+
// 订阅类型:根据剩余天数显示不同颜色
82+
if (props.daysRemaining !== null && props.daysRemaining !== undefined) {
83+
if (props.daysRemaining <= 0 || props.daysRemaining <= 3) {
84+
// 已过期或紧急(<=3天):红色
85+
return `${base} bg-red-200/80 text-red-800 dark:bg-red-800/50 dark:text-red-300`
86+
}
87+
if (props.daysRemaining <= 7) {
88+
// 警告(<=7天):橙色
89+
return `${base} bg-amber-200/80 text-amber-800 dark:bg-amber-800/50 dark:text-amber-300`
6290
}
63-
return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
6491
}
65-
// Standard: subtle background
66-
return `${base} bg-black/10 dark:bg-white/10`
92+
93+
// 正常状态或无天数:根据平台显示主题色
94+
if (props.platform === 'anthropic') {
95+
return `${base} bg-orange-200/60 text-orange-800 dark:bg-orange-800/40 dark:text-orange-300`
96+
}
97+
if (props.platform === 'openai') {
98+
return `${base} bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300`
99+
}
100+
return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
67101
})
68102
69103
// Badge color based on platform and subscription type

frontend/src/i18n/locales/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,7 @@ export default {
449449
columns: {
450450
user: 'User',
451451
role: 'Role',
452+
subscriptions: 'Subscriptions',
452453
balance: 'Balance',
453454
usage: 'Usage',
454455
concurrency: 'Concurrency',
@@ -458,6 +459,9 @@ export default {
458459
},
459460
today: 'Today',
460461
total: 'Total',
462+
noSubscription: 'No subscription',
463+
daysRemaining: '{days}d',
464+
expired: 'Expired',
461465
disableUser: 'Disable User',
462466
enableUser: 'Enable User',
463467
viewApiKeys: 'View API Keys',

frontend/src/i18n/locales/zh.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,15 +467,20 @@ export default {
467467
columns: {
468468
email: '邮箱',
469469
role: '角色',
470+
subscriptions: '订阅分组',
470471
balance: '余额',
471472
usage: '用量',
472473
concurrency: '并发数',
473474
status: '状态',
474475
created: '创建时间',
475476
actions: '操作',
477+
user: '用户',
476478
},
477479
today: '今日',
478480
total: '累计',
481+
noSubscription: '暂无订阅',
482+
daysRemaining: '{days}天',
483+
expired: '已过期',
479484
roles: {
480485
admin: '管理员',
481486
user: '用户',

frontend/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface User {
1313
concurrency: number; // Allowed concurrent requests
1414
status: 'active' | 'disabled'; // Account status
1515
allowed_groups: number[] | null; // Allowed group IDs (null = all non-exclusive groups)
16+
subscriptions?: UserSubscription[]; // User's active subscriptions
1617
created_at: string;
1718
updated_at: string;
1819
}

frontend/src/views/admin/UsersView.vue

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@
8484
</span>
8585
</template>
8686

87+
<template #cell-subscriptions="{ row }">
88+
<div v-if="row.subscriptions && row.subscriptions.length > 0" class="flex flex-wrap gap-1.5">
89+
<GroupBadge
90+
v-for="sub in row.subscriptions"
91+
:key="sub.id"
92+
:name="sub.group?.name || ''"
93+
:platform="sub.group?.platform"
94+
:subscription-type="sub.group?.subscription_type"
95+
:rate-multiplier="sub.group?.rate_multiplier"
96+
:days-remaining="sub.expires_at ? getDaysRemaining(sub.expires_at) : null"
97+
:title="sub.expires_at ? formatExpiresAt(sub.expires_at) : ''"
98+
/>
99+
</div>
100+
<span v-else class="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs text-gray-400 dark:text-dark-500 bg-gray-50 dark:bg-dark-700/50">
101+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
102+
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
103+
</svg>
104+
<span>{{ t('admin.users.noSubscription') }}</span>
105+
</span>
106+
</template>
107+
87108
<template #cell-balance="{ value }">
88109
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
89110
</template>
@@ -662,12 +683,14 @@ import Modal from '@/components/common/Modal.vue'
662683
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
663684
import EmptyState from '@/components/common/EmptyState.vue'
664685
import Select from '@/components/common/Select.vue'
686+
import GroupBadge from '@/components/common/GroupBadge.vue'
665687
666688
const appStore = useAppStore()
667689
668690
const columns = computed<Column[]>(() => [
669691
{ key: 'email', label: t('admin.users.columns.user'), sortable: true },
670692
{ key: 'role', label: t('admin.users.columns.role'), sortable: true },
693+
{ key: 'subscriptions', label: t('admin.users.columns.subscriptions'), sortable: false },
671694
{ key: 'balance', label: t('admin.users.columns.balance'), sortable: true },
672695
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
673696
{ key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true },
@@ -749,6 +772,20 @@ const formatDate = (dateString: string): string => {
749772
})
750773
}
751774
775+
// 计算剩余天数
776+
const getDaysRemaining = (expiresAt: string): number => {
777+
const now = new Date()
778+
const expires = new Date(expiresAt)
779+
const diffMs = expires.getTime() - now.getTime()
780+
return Math.ceil(diffMs / (1000 * 60 * 60 * 24))
781+
}
782+
783+
// 格式化过期时间(用于 tooltip)
784+
const formatExpiresAt = (expiresAt: string): string => {
785+
const date = new Date(expiresAt)
786+
return date.toLocaleString()
787+
}
788+
752789
const generateRandomPasswordStr = () => {
753790
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
754791
let password = ''

0 commit comments

Comments
 (0)