feat: 用户列表显示订阅分组及剩余天数
- User模型新增Subscriptions关联 - 用户列表批量加载订阅信息避免N+1查询 - GroupBadge组件支持显示剩余天数(过期红色、<=3天红色、<=7天橙色) - 用户管理页面新增订阅分组列
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<PlatformIcon v-if="platform" :platform="platform" size="sm" />
|
||||
<!-- Group name -->
|
||||
<span class="truncate">{{ name }}</span>
|
||||
<!-- Right side label: subscription shows "订阅", standard shows rate multiplier -->
|
||||
<!-- Right side label -->
|
||||
<span
|
||||
v-if="showRate"
|
||||
v-if="showLabel"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ labelText }}
|
||||
@@ -31,39 +31,73 @@ interface Props {
|
||||
subscriptionType?: SubscriptionType
|
||||
rateMultiplier?: number
|
||||
showRate?: boolean
|
||||
daysRemaining?: number | null // 剩余天数(订阅类型时使用)
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '用户',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,27 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-subscriptions="{ row }">
|
||||
<div v-if="row.subscriptions && row.subscriptions.length > 0" class="flex flex-wrap gap-1.5">
|
||||
<GroupBadge
|
||||
v-for="sub in row.subscriptions"
|
||||
:key="sub.id"
|
||||
:name="sub.group?.name || ''"
|
||||
:platform="sub.group?.platform"
|
||||
:subscription-type="sub.group?.subscription_type"
|
||||
:rate-multiplier="sub.group?.rate_multiplier"
|
||||
:days-remaining="sub.expires_at ? getDaysRemaining(sub.expires_at) : null"
|
||||
:title="sub.expires_at ? formatExpiresAt(sub.expires_at) : ''"
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<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" />
|
||||
</svg>
|
||||
<span>{{ t('admin.users.noSubscription') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-balance="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
|
||||
</template>
|
||||
@@ -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<Column[]>(() => [
|
||||
{ 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 = ''
|
||||
|
||||
Reference in New Issue
Block a user