diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go
index 61fc5215..cf3877fc 100644
--- a/backend/internal/model/user.go
+++ b/backend/internal/model/user.go
@@ -22,7 +22,8 @@ type User struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// 关联
- ApiKeys []ApiKey `gorm:"foreignKey:UserID" json:"api_keys,omitempty"`
+ ApiKeys []ApiKey `gorm:"foreignKey:UserID" json:"api_keys,omitempty"`
+ Subscriptions []UserSubscription `gorm:"foreignKey:UserID" json:"subscriptions,omitempty"`
}
func (User) TableName() string {
diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go
index 2ec84c3e..0b49a52d 100644
--- a/backend/internal/repository/user_repo.go
+++ b/backend/internal/repository/user_repo.go
@@ -73,10 +73,37 @@ func (r *UserRepository) ListWithFilters(ctx context.Context, params pagination.
return nil, nil, err
}
+ // Query users with pagination (reuse the same db with filters applied)
if err := db.Offset(params.Offset()).Limit(params.Limit()).Order("id DESC").Find(&users).Error; err != nil {
return nil, nil, err
}
+ // Batch load subscriptions for all users (avoid N+1)
+ if len(users) > 0 {
+ userIDs := make([]int64, len(users))
+ userMap := make(map[int64]*model.User, len(users))
+ for i := range users {
+ userIDs[i] = users[i].ID
+ userMap[users[i].ID] = &users[i]
+ }
+
+ // Query active subscriptions with groups in one query
+ var subscriptions []model.UserSubscription
+ if err := r.db.WithContext(ctx).
+ Preload("Group").
+ Where("user_id IN ? AND status = ?", userIDs, model.SubscriptionStatusActive).
+ Find(&subscriptions).Error; err != nil {
+ return nil, nil, err
+ }
+
+ // Associate subscriptions with users
+ for i := range subscriptions {
+ if user, ok := userMap[subscriptions[i].UserID]; ok {
+ user.Subscriptions = append(user.Subscriptions, subscriptions[i])
+ }
+ }
+ }
+
pages := int(total) / params.Limit()
if int(total)%params.Limit() > 0 {
pages++
diff --git a/frontend/src/components/common/GroupBadge.vue b/frontend/src/components/common/GroupBadge.vue
index 51a9d09b..e8cefff7 100644
--- a/frontend/src/components/common/GroupBadge.vue
+++ b/frontend/src/components/common/GroupBadge.vue
@@ -9,9 +9,9 @@
{{ name }}
-
+
{{ labelText }}
@@ -31,39 +31,73 @@ interface Props {
subscriptionType?: SubscriptionType
rateMultiplier?: number
showRate?: boolean
+ daysRemaining?: number | null // 剩余天数(订阅类型时使用)
}
const props = withDefaults(defineProps(), {
subscriptionType: 'standard',
- showRate: true
+ showRate: true,
+ daysRemaining: null
})
const { t } = useI18n()
const isSubscription = computed(() => props.subscriptionType === 'subscription')
-// Label text: subscription shows localized text, standard shows rate
+// 是否显示右侧标签
+const showLabel = computed(() => {
+ if (!props.showRate) return false
+ // 订阅类型:显示天数或"订阅"
+ if (isSubscription.value) return true
+ // 标准类型:显示倍率
+ return props.rateMultiplier !== undefined
+})
+
+// Label text
const labelText = computed(() => {
if (isSubscription.value) {
+ // 如果有剩余天数,显示天数
+ if (props.daysRemaining !== null && props.daysRemaining !== undefined) {
+ if (props.daysRemaining <= 0) {
+ return t('admin.users.expired')
+ }
+ return t('admin.users.daysRemaining', { days: props.daysRemaining })
+ }
+ // 否则显示"订阅"
return t('groups.subscription')
}
return props.rateMultiplier !== undefined ? `${props.rateMultiplier}x` : ''
})
-// Label style based on type
+// Label style based on type and days remaining
const labelClass = computed(() => {
const base = 'px-1.5 py-0.5 rounded text-[10px] font-semibold'
- if (isSubscription.value) {
- // Subscription: more prominent style with border
- if (props.platform === 'anthropic') {
- return `${base} bg-orange-200/60 text-orange-800 dark:bg-orange-800/40 dark:text-orange-300`
- } else if (props.platform === 'openai') {
- return `${base} bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300`
- }
- return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
+
+ if (!isSubscription.value) {
+ // Standard: subtle background
+ return `${base} bg-black/10 dark:bg-white/10`
}
- // Standard: subtle background
- return `${base} bg-black/10 dark:bg-white/10`
+
+ // 订阅类型:根据剩余天数显示不同颜色
+ if (props.daysRemaining !== null && props.daysRemaining !== undefined) {
+ if (props.daysRemaining <= 0 || props.daysRemaining <= 3) {
+ // 已过期或紧急(<=3天):红色
+ return `${base} bg-red-200/80 text-red-800 dark:bg-red-800/50 dark:text-red-300`
+ }
+ if (props.daysRemaining <= 7) {
+ // 警告(<=7天):橙色
+ return `${base} bg-amber-200/80 text-amber-800 dark:bg-amber-800/50 dark:text-amber-300`
+ }
+ }
+
+ // 正常状态或无天数:根据平台显示主题色
+ if (props.platform === 'anthropic') {
+ return `${base} bg-orange-200/60 text-orange-800 dark:bg-orange-800/40 dark:text-orange-300`
+ }
+ if (props.platform === 'openai') {
+ return `${base} bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300`
+ }
+ return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
})
// Badge color based on platform and subscription type
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index ba766465..d632ea29 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -449,6 +449,7 @@ export default {
columns: {
user: 'User',
role: 'Role',
+ subscriptions: 'Subscriptions',
balance: 'Balance',
usage: 'Usage',
concurrency: 'Concurrency',
@@ -458,6 +459,9 @@ export default {
},
today: 'Today',
total: 'Total',
+ noSubscription: 'No subscription',
+ daysRemaining: '{days}d',
+ expired: 'Expired',
disableUser: 'Disable User',
enableUser: 'Enable User',
viewApiKeys: 'View API Keys',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 539cda34..b8859c23 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -467,15 +467,20 @@ export default {
columns: {
email: '邮箱',
role: '角色',
+ subscriptions: '订阅分组',
balance: '余额',
usage: '用量',
concurrency: '并发数',
status: '状态',
created: '创建时间',
actions: '操作',
+ user: '用户',
},
today: '今日',
total: '累计',
+ noSubscription: '暂无订阅',
+ daysRemaining: '{days}天',
+ expired: '已过期',
roles: {
admin: '管理员',
user: '用户',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 5668dd70..de5ac92c 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -13,6 +13,7 @@ export interface User {
concurrency: number; // Allowed concurrent requests
status: 'active' | 'disabled'; // Account status
allowed_groups: number[] | null; // Allowed group IDs (null = all non-exclusive groups)
+ subscriptions?: UserSubscription[]; // User's active subscriptions
created_at: string;
updated_at: string;
}
diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue
index 06bcd2d2..6a541d12 100644
--- a/frontend/src/views/admin/UsersView.vue
+++ b/frontend/src/views/admin/UsersView.vue
@@ -84,6 +84,27 @@
+
+
+
+
+
+
+ {{ t('admin.users.noSubscription') }}
+
+
+
${{ value.toFixed(2) }}
@@ -662,12 +683,14 @@ import Modal from '@/components/common/Modal.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
+import GroupBadge from '@/components/common/GroupBadge.vue'
const appStore = useAppStore()
const columns = computed(() => [
{ key: 'email', label: t('admin.users.columns.user'), sortable: true },
{ key: 'role', label: t('admin.users.columns.role'), sortable: true },
+ { key: 'subscriptions', label: t('admin.users.columns.subscriptions'), sortable: false },
{ key: 'balance', label: t('admin.users.columns.balance'), sortable: true },
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
{ key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true },
@@ -749,6 +772,20 @@ const formatDate = (dateString: string): string => {
})
}
+// 计算剩余天数
+const getDaysRemaining = (expiresAt: string): number => {
+ const now = new Date()
+ const expires = new Date(expiresAt)
+ const diffMs = expires.getTime() - now.getTime()
+ return Math.ceil(diffMs / (1000 * 60 * 60 * 24))
+}
+
+// 格式化过期时间(用于 tooltip)
+const formatExpiresAt = (expiresAt: string): string => {
+ const date = new Date(expiresAt)
+ return date.toLocaleString()
+}
+
const generateRandomPasswordStr = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
let password = ''