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 @@ + + @@ -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 = ''