feat(groups): improve rate multipliers modal UX

- Add user_name to GroupRateMultiplierEntry (backend + frontend)
- Compact row height with user ID, name, email display
- Add client-side pagination (15 per page)
- Add "Clear All" button with DELETE /admin/groups/:id/rate-multipliers
- Add i18n keys for new UI elements
This commit is contained in:
erio
2026-03-12 18:39:48 +08:00
parent dd6841725f
commit 83fa33e366
10 changed files with 169 additions and 40 deletions
@@ -179,6 +179,10 @@ func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) (
return nil, nil
}
func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) error {
return nil
}
func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]service.Account, int64, error) {
return s.accounts, int64(len(s.accounts)), nil
}
@@ -360,6 +360,23 @@ func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
response.Success(c, entries)
}
// ClearGroupRateMultipliers handles clearing all rate multipliers for a group
// DELETE /api/v1/admin/groups/:id/rate-multipliers
func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) {
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
return
}
if err := h.adminService.ClearGroupRateMultipliers(c.Request.Context(), groupID); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"message": "Rate multipliers cleared successfully"})
}
// UpdateSortOrderRequest represents the request to update group sort orders
type UpdateSortOrderRequest struct {
Updates []struct {
@@ -98,7 +98,7 @@ func (r *userGroupRateRepository) GetByUserIDs(ctx context.Context, userIDs []in
// GetByGroupID 获取指定分组下所有用户的专属倍率
func (r *userGroupRateRepository) GetByGroupID(ctx context.Context, groupID int64) ([]service.UserGroupRateEntry, error) {
query := `
SELECT ugr.user_id, u.email, ugr.rate_multiplier
SELECT ugr.user_id, u.username, u.email, ugr.rate_multiplier
FROM user_group_rate_multipliers ugr
JOIN users u ON u.id = ugr.user_id
WHERE ugr.group_id = $1
@@ -113,7 +113,7 @@ func (r *userGroupRateRepository) GetByGroupID(ctx context.Context, groupID int6
var result []service.UserGroupRateEntry
for rows.Next() {
var entry service.UserGroupRateEntry
if err := rows.Scan(&entry.UserID, &entry.UserEmail, &entry.RateMultiplier); err != nil {
if err := rows.Scan(&entry.UserID, &entry.UserName, &entry.UserEmail, &entry.RateMultiplier); err != nil {
return nil, err
}
result = append(result, entry)
+1
View File
@@ -229,6 +229,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
groups.DELETE("/:id", h.Admin.Group.Delete)
groups.GET("/:id/stats", h.Admin.Group.GetStats)
groups.GET("/:id/rate-multipliers", h.Admin.Group.GetGroupRateMultipliers)
groups.DELETE("/:id/rate-multipliers", h.Admin.Group.ClearGroupRateMultipliers)
groups.GET("/:id/api-keys", h.Admin.Group.GetGroupAPIKeys)
}
}
@@ -43,6 +43,7 @@ type AdminService interface {
DeleteGroup(ctx context.Context, id int64) error
GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error)
GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error)
ClearGroupRateMultipliers(ctx context.Context, groupID int64) error
UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
// API Key management (admin)
@@ -1271,6 +1272,13 @@ func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID
return s.userGroupRateRepo.GetByGroupID(ctx, groupID)
}
func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupID int64) error {
if s.userGroupRateRepo == nil {
return nil
}
return s.userGroupRateRepo.DeleteByGroupID(ctx, groupID)
}
func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error {
return s.groupRepo.UpdateSortOrders(ctx, updates)
}
@@ -5,6 +5,7 @@ import "context"
// UserGroupRateEntry 分组下用户专属倍率条目
type UserGroupRateEntry struct {
UserID int64 `json:"user_id"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
RateMultiplier float64 `json:"rate_multiplier"`
}
+12
View File
@@ -158,6 +158,7 @@ export async function getGroupApiKeys(
*/
export interface GroupRateMultiplierEntry {
user_id: number
user_name: string
user_email: string
rate_multiplier: number
}
@@ -188,6 +189,16 @@ export async function updateSortOrder(
return data
}
/**
* Clear all rate multipliers for a group
* @param id - Group ID
* @returns Success confirmation
*/
export async function clearGroupRateMultipliers(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}/rate-multipliers`)
return data
}
export const groupsAPI = {
list,
getAll,
@@ -200,6 +211,7 @@ export const groupsAPI = {
getStats,
getGroupApiKeys,
getGroupRateMultipliers,
clearGroupRateMultipliers,
updateSortOrder
}
@@ -1,8 +1,8 @@
<template>
<BaseDialog :show="show" :title="t('admin.groups.rateMultipliersTitle')" width="normal" @close="$emit('close')">
<div v-if="group" class="space-y-5">
<div v-if="group" class="space-y-4">
<!-- 分组信息 -->
<div class="flex flex-wrap items-center gap-3 rounded-lg bg-gray-50 px-4 py-3 text-sm dark:bg-dark-700">
<div class="flex flex-wrap items-center gap-3 rounded-lg bg-gray-50 px-4 py-2.5 text-sm dark:bg-dark-700">
<span class="font-medium text-gray-900 dark:text-white">{{ group.name }}</span>
<span class="text-gray-400">|</span>
<span class="text-gray-600 dark:text-gray-400">{{ t('admin.groups.platforms.' + group.platform) }}</span>
@@ -13,11 +13,11 @@
</div>
<!-- 添加用户 -->
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<h4 class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.addUserRate') }}
</h4>
<div class="flex items-end gap-3">
<div class="flex items-end gap-2">
<div class="relative flex-1">
<input
v-model="searchQuery"
@@ -27,7 +27,6 @@
@input="handleSearchUsers"
@focus="showDropdown = true"
/>
<!-- 搜索结果下拉 -->
<div
v-if="showDropdown && searchResults.length > 0"
class="absolute left-0 right-0 top-full z-10 mt-1 max-h-48 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-500 dark:bg-dark-700"
@@ -36,14 +35,16 @@
v-for="user in searchResults"
:key="user.id"
type="button"
class="flex w-full items-center px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-dark-600"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-gray-50 dark:hover:bg-dark-600"
@click="selectUser(user)"
>
<span class="text-gray-900 dark:text-white">{{ user.email }}</span>
<span class="text-gray-500 dark:text-gray-400">#{{ user.id }}</span>
<span class="text-gray-900 dark:text-white">{{ user.username || user.email }}</span>
<span v-if="user.username" class="text-xs text-gray-400">{{ user.email }}</span>
</button>
</div>
</div>
<div class="w-28">
<div class="w-24">
<input
v-model.number="newRate"
type="number"
@@ -66,8 +67,8 @@
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex justify-center py-8">
<svg class="h-8 w-8 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24">
<div v-if="loading" class="flex justify-center py-6">
<svg class="h-6 w-6 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@@ -75,37 +76,87 @@
<!-- 已设置的用户列表 -->
<div v-else>
<h4 class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.rateMultipliers') }} ({{ entries.length }})
</h4>
<div class="mb-2 flex items-center justify-between">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.rateMultipliers') }} ({{ entries.length }})
</h4>
<button
v-if="entries.length > 0"
type="button"
class="text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
:disabled="clearing"
@click="handleClearAll"
>
<Icon v-if="clearing" name="refresh" size="xs" class="mr-0.5 inline animate-spin" />
{{ t('admin.groups.clearAll') }}
</button>
</div>
<div v-if="entries.length === 0" class="py-8 text-center text-sm text-gray-400 dark:text-gray-500">
<div v-if="entries.length === 0" class="py-6 text-center text-sm text-gray-400 dark:text-gray-500">
{{ t('admin.groups.noRateMultipliers') }}
</div>
<div v-else class="space-y-2">
<div
v-for="entry in entries"
:key="entry.user_id"
class="flex items-center gap-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-dark-600"
>
<span class="flex-1 text-sm text-gray-900 dark:text-white">{{ entry.user_email }}</span>
<input
type="number"
step="0.001"
min="0"
:value="entry.rate_multiplier"
class="hide-spinner w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
@blur="handleUpdateRate(entry, ($event.target as HTMLInputElement).value)"
@keydown.enter="($event.target as HTMLInputElement).blur()"
/>
<button
type="button"
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
@click="handleDeleteRate(entry)"
<div v-else>
<!-- 表头 -->
<div class="mb-1 flex items-center gap-2 px-2 text-xs font-medium text-gray-400 dark:text-gray-500">
<span class="w-10">ID</span>
<span class="flex-1">{{ t('admin.groups.userInfo') }}</span>
<span class="w-20 text-center">{{ t('admin.groups.columns.rateMultiplier') }}</span>
<span class="w-8"></span>
</div>
<!-- 列表 -->
<div class="max-h-[320px] space-y-1 overflow-y-auto">
<div
v-for="entry in paginatedEntries"
:key="entry.user_id"
class="flex items-center gap-2 rounded-md border border-gray-100 px-2 py-1.5 dark:border-dark-600"
>
<Icon name="trash" size="sm" />
</button>
<span class="w-10 shrink-0 text-xs text-gray-400 dark:text-gray-500">#{{ entry.user_id }}</span>
<div class="flex-1 min-w-0">
<div class="truncate text-sm text-gray-900 dark:text-white">{{ entry.user_name || entry.user_email }}</div>
<div v-if="entry.user_name" class="truncate text-xs text-gray-400">{{ entry.user_email }}</div>
</div>
<input
type="number"
step="0.001"
min="0"
:value="entry.rate_multiplier"
class="hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
@blur="handleUpdateRate(entry, ($event.target as HTMLInputElement).value)"
@keydown.enter="($event.target as HTMLInputElement).blur()"
/>
<button
type="button"
class="w-8 shrink-0 rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
@click="handleDeleteRate(entry)"
>
<Icon name="trash" size="sm" />
</button>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="mt-2 flex items-center justify-between border-t border-gray-100 pt-2 dark:border-dark-600">
<span class="text-xs text-gray-400">{{ currentPage }} / {{ totalPages }}</span>
<div class="flex gap-1">
<button
type="button"
class="rounded px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-100 disabled:opacity-40 dark:hover:bg-dark-600"
:disabled="currentPage <= 1"
@click="currentPage--"
>
&lt;
</button>
<button
type="button"
class="rounded px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-100 disabled:opacity-40 dark:hover:bg-dark-600"
:disabled="currentPage >= totalPages"
@click="currentPage++"
>
&gt;
</button>
</div>
</div>
</div>
</div>
@@ -114,7 +165,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
@@ -144,14 +195,24 @@ const showDropdown = ref(false)
const selectedUser = ref<AdminUser | null>(null)
const newRate = ref<number | null>(null)
const addingRate = ref(false)
const clearing = ref(false)
const currentPage = ref(1)
const pageSize = 15
let searchTimeout: ReturnType<typeof setTimeout>
const totalPages = computed(() => Math.max(1, Math.ceil(entries.value.length / pageSize)))
const paginatedEntries = computed(() => {
const start = (currentPage.value - 1) * pageSize
return entries.value.slice(start, start + pageSize)
})
const loadEntries = async () => {
if (!props.group) return
loading.value = true
try {
entries.value = await adminAPI.groups.getGroupRateMultipliers(props.group.id)
currentPage.value = 1
} catch (error) {
appStore.showError(t('admin.groups.failedToLoad'))
console.error('Error loading group rate multipliers:', error)
@@ -249,6 +310,23 @@ const handleDeleteRate = async (entry: GroupRateMultiplierEntry) => {
}
}
const handleClearAll = async () => {
if (!props.group || entries.value.length === 0) return
if (!confirm(t('admin.groups.confirmClearAll'))) return
clearing.value = true
try {
await adminAPI.groups.clearGroupRateMultipliers(props.group.id)
appStore.showSuccess(t('admin.groups.rateCleared'))
await loadEntries()
emit('success')
} catch (error) {
appStore.showError(t('admin.groups.failedToSave'))
console.error('Error clearing rate multipliers:', error)
} finally {
clearing.value = false
}
}
// 点击外部关闭下拉
const handleClickOutside = () => {
showDropdown.value = false
+4
View File
@@ -1419,6 +1419,10 @@ export default {
rateUpdated: 'Rate multiplier updated',
rateDeleted: 'Rate multiplier removed',
rateAdded: 'Rate multiplier added',
clearAll: 'Clear All',
confirmClearAll: 'Are you sure you want to clear all rate multiplier settings for this group? This cannot be undone.',
rateCleared: 'All rate multipliers cleared',
userInfo: 'User Info',
platforms: {
all: 'All Platforms',
anthropic: 'Anthropic',
+4
View File
@@ -1518,6 +1518,10 @@ export default {
rateUpdated: '专属倍率已更新',
rateDeleted: '专属倍率已删除',
rateAdded: '专属倍率已添加',
clearAll: '全部清空',
confirmClearAll: '确定要清空该分组所有用户的专属倍率设置吗?此操作不可撤销。',
rateCleared: '已清空所有专属倍率',
userInfo: '用户信息',
subscription: {
title: '订阅设置',
type: '计费类型',