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:"-"`
|
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 {
|
func (User) TableName() string {
|
||||||
|
|||||||
@@ -73,10 +73,37 @@ func (r *UserRepository) ListWithFilters(ctx context.Context, params pagination.
|
|||||||
return nil, nil, err
|
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 {
|
if err := db.Offset(params.Offset()).Limit(params.Limit()).Order("id DESC").Find(&users).Error; err != nil {
|
||||||
return nil, nil, err
|
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()
|
pages := int(total) / params.Limit()
|
||||||
if int(total)%params.Limit() > 0 {
|
if int(total)%params.Limit() > 0 {
|
||||||
pages++
|
pages++
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
<PlatformIcon v-if="platform" :platform="platform" size="sm" />
|
<PlatformIcon v-if="platform" :platform="platform" size="sm" />
|
||||||
<!-- Group name -->
|
<!-- Group name -->
|
||||||
<span class="truncate">{{ name }}</span>
|
<span class="truncate">{{ name }}</span>
|
||||||
<!-- Right side label: subscription shows "订阅", standard shows rate multiplier -->
|
<!-- Right side label -->
|
||||||
<span
|
<span
|
||||||
v-if="showRate"
|
v-if="showLabel"
|
||||||
:class="labelClass"
|
:class="labelClass"
|
||||||
>
|
>
|
||||||
{{ labelText }}
|
{{ labelText }}
|
||||||
@@ -31,39 +31,73 @@ interface Props {
|
|||||||
subscriptionType?: SubscriptionType
|
subscriptionType?: SubscriptionType
|
||||||
rateMultiplier?: number
|
rateMultiplier?: number
|
||||||
showRate?: boolean
|
showRate?: boolean
|
||||||
|
daysRemaining?: number | null // 剩余天数(订阅类型时使用)
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
subscriptionType: 'standard',
|
subscriptionType: 'standard',
|
||||||
showRate: true
|
showRate: true,
|
||||||
|
daysRemaining: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const isSubscription = computed(() => props.subscriptionType === 'subscription')
|
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(() => {
|
const labelText = computed(() => {
|
||||||
if (isSubscription.value) {
|
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 t('groups.subscription')
|
||||||
}
|
}
|
||||||
return props.rateMultiplier !== undefined ? `${props.rateMultiplier}x` : ''
|
return props.rateMultiplier !== undefined ? `${props.rateMultiplier}x` : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// Label style based on type
|
// Label style based on type and days remaining
|
||||||
const labelClass = computed(() => {
|
const labelClass = computed(() => {
|
||||||
const base = 'px-1.5 py-0.5 rounded text-[10px] font-semibold'
|
const base = 'px-1.5 py-0.5 rounded text-[10px] font-semibold'
|
||||||
if (isSubscription.value) {
|
|
||||||
// Subscription: more prominent style with border
|
if (!isSubscription.value) {
|
||||||
if (props.platform === 'anthropic') {
|
// Standard: subtle background
|
||||||
return `${base} bg-orange-200/60 text-orange-800 dark:bg-orange-800/40 dark:text-orange-300`
|
return `${base} bg-black/10 dark:bg-white/10`
|
||||||
} 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`
|
|
||||||
}
|
}
|
||||||
// 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
|
// Badge color based on platform and subscription type
|
||||||
|
|||||||
@@ -449,6 +449,7 @@ export default {
|
|||||||
columns: {
|
columns: {
|
||||||
user: 'User',
|
user: 'User',
|
||||||
role: 'Role',
|
role: 'Role',
|
||||||
|
subscriptions: 'Subscriptions',
|
||||||
balance: 'Balance',
|
balance: 'Balance',
|
||||||
usage: 'Usage',
|
usage: 'Usage',
|
||||||
concurrency: 'Concurrency',
|
concurrency: 'Concurrency',
|
||||||
@@ -458,6 +459,9 @@ export default {
|
|||||||
},
|
},
|
||||||
today: 'Today',
|
today: 'Today',
|
||||||
total: 'Total',
|
total: 'Total',
|
||||||
|
noSubscription: 'No subscription',
|
||||||
|
daysRemaining: '{days}d',
|
||||||
|
expired: 'Expired',
|
||||||
disableUser: 'Disable User',
|
disableUser: 'Disable User',
|
||||||
enableUser: 'Enable User',
|
enableUser: 'Enable User',
|
||||||
viewApiKeys: 'View API Keys',
|
viewApiKeys: 'View API Keys',
|
||||||
|
|||||||
@@ -467,15 +467,20 @@ export default {
|
|||||||
columns: {
|
columns: {
|
||||||
email: '邮箱',
|
email: '邮箱',
|
||||||
role: '角色',
|
role: '角色',
|
||||||
|
subscriptions: '订阅分组',
|
||||||
balance: '余额',
|
balance: '余额',
|
||||||
usage: '用量',
|
usage: '用量',
|
||||||
concurrency: '并发数',
|
concurrency: '并发数',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
created: '创建时间',
|
created: '创建时间',
|
||||||
actions: '操作',
|
actions: '操作',
|
||||||
|
user: '用户',
|
||||||
},
|
},
|
||||||
today: '今日',
|
today: '今日',
|
||||||
total: '累计',
|
total: '累计',
|
||||||
|
noSubscription: '暂无订阅',
|
||||||
|
daysRemaining: '{days}天',
|
||||||
|
expired: '已过期',
|
||||||
roles: {
|
roles: {
|
||||||
admin: '管理员',
|
admin: '管理员',
|
||||||
user: '用户',
|
user: '用户',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface User {
|
|||||||
concurrency: number; // Allowed concurrent requests
|
concurrency: number; // Allowed concurrent requests
|
||||||
status: 'active' | 'disabled'; // Account status
|
status: 'active' | 'disabled'; // Account status
|
||||||
allowed_groups: number[] | null; // Allowed group IDs (null = all non-exclusive groups)
|
allowed_groups: number[] | null; // Allowed group IDs (null = all non-exclusive groups)
|
||||||
|
subscriptions?: UserSubscription[]; // User's active subscriptions
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,27 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</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 }">
|
<template #cell-balance="{ value }">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -662,12 +683,14 @@ import Modal from '@/components/common/Modal.vue'
|
|||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
|
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => [
|
||||||
{ key: 'email', label: t('admin.users.columns.user'), sortable: true },
|
{ key: 'email', label: t('admin.users.columns.user'), sortable: true },
|
||||||
{ key: 'role', label: t('admin.users.columns.role'), 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: 'balance', label: t('admin.users.columns.balance'), sortable: true },
|
||||||
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
|
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
|
||||||
{ key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true },
|
{ 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 generateRandomPasswordStr = () => {
|
||||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
|
||||||
let password = ''
|
let password = ''
|
||||||
|
|||||||
Reference in New Issue
Block a user