mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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--"
|
||||
>
|
||||
<
|
||||
</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++"
|
||||
>
|
||||
>
|
||||
</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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1518,6 +1518,10 @@ export default {
|
||||
rateUpdated: '专属倍率已更新',
|
||||
rateDeleted: '专属倍率已删除',
|
||||
rateAdded: '专属倍率已添加',
|
||||
clearAll: '全部清空',
|
||||
confirmClearAll: '确定要清空该分组所有用户的专属倍率设置吗?此操作不可撤销。',
|
||||
rateCleared: '已清空所有专属倍率',
|
||||
userInfo: '用户信息',
|
||||
subscription: {
|
||||
title: '订阅设置',
|
||||
type: '计费类型',
|
||||
|
||||
Reference in New Issue
Block a user