mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-04-19 04:57:26 +00:00
feat: 分组管理页面新增专属倍率管理
- 后端新增 GET /admin/groups/:id/rate-multipliers API - 前端新增 GroupRateMultipliersModal 组件,支持查看/添加/修改/删除用户专属倍率 - 分组列表操作列新增"专属倍率"按钮 - 修复 antigravity_gateway_service_test.go 参数不匹配的预存问题
This commit is contained in:
@@ -175,6 +175,10 @@ func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, p
|
|||||||
return s.apiKeys, int64(len(s.apiKeys)), nil
|
return s.apiKeys, int64(len(s.apiKeys)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) ([]service.UserGroupRateEntry, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]service.Account, int64, error) {
|
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
|
return s.accounts, int64(len(s.accounts)), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,6 +339,27 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
|
|||||||
response.Paginated(c, outKeys, total, page, pageSize)
|
response.Paginated(c, outKeys, total, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetGroupRateMultipliers handles getting rate multipliers for users in a group
|
||||||
|
// GET /api/v1/admin/groups/:id/rate-multipliers
|
||||||
|
func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
|
||||||
|
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid group ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := h.adminService.GetGroupRateMultipliers(c.Request.Context(), groupID)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries == nil {
|
||||||
|
entries = []service.UserGroupRateEntry{}
|
||||||
|
}
|
||||||
|
response.Success(c, entries)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateSortOrderRequest represents the request to update group sort orders
|
// UpdateSortOrderRequest represents the request to update group sort orders
|
||||||
type UpdateSortOrderRequest struct {
|
type UpdateSortOrderRequest struct {
|
||||||
Updates []struct {
|
Updates []struct {
|
||||||
|
|||||||
@@ -95,6 +95,35 @@ func (r *userGroupRateRepository) GetByUserIDs(ctx context.Context, userIDs []in
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByGroupID 获取指定分组下所有用户的专属倍率
|
||||||
|
func (r *userGroupRateRepository) GetByGroupID(ctx context.Context, groupID int64) ([]service.UserGroupRateEntry, error) {
|
||||||
|
query := `
|
||||||
|
SELECT ugr.user_id, 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
|
||||||
|
ORDER BY ugr.user_id
|
||||||
|
`
|
||||||
|
rows, err := r.sql.QueryContext(ctx, query, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var result []service.UserGroupRateEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var entry service.UserGroupRateEntry
|
||||||
|
if err := rows.Scan(&entry.UserID, &entry.UserEmail, &entry.RateMultiplier); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, entry)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetByUserAndGroup 获取用户在特定分组的专属倍率
|
// GetByUserAndGroup 获取用户在特定分组的专属倍率
|
||||||
func (r *userGroupRateRepository) GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error) {
|
func (r *userGroupRateRepository) GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error) {
|
||||||
query := `SELECT rate_multiplier FROM user_group_rate_multipliers WHERE user_id = $1 AND group_id = $2`
|
query := `SELECT rate_multiplier FROM user_group_rate_multipliers WHERE user_id = $1 AND group_id = $2`
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
groups.PUT("/:id", h.Admin.Group.Update)
|
groups.PUT("/:id", h.Admin.Group.Update)
|
||||||
groups.DELETE("/:id", h.Admin.Group.Delete)
|
groups.DELETE("/:id", h.Admin.Group.Delete)
|
||||||
groups.GET("/:id/stats", h.Admin.Group.GetStats)
|
groups.GET("/:id/stats", h.Admin.Group.GetStats)
|
||||||
|
groups.GET("/:id/rate-multipliers", h.Admin.Group.GetGroupRateMultipliers)
|
||||||
groups.GET("/:id/api-keys", h.Admin.Group.GetGroupAPIKeys)
|
groups.GET("/:id/api-keys", h.Admin.Group.GetGroupAPIKeys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type AdminService interface {
|
|||||||
UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error)
|
UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error)
|
||||||
DeleteGroup(ctx context.Context, id int64) error
|
DeleteGroup(ctx context.Context, id int64) error
|
||||||
GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error)
|
GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error)
|
||||||
|
GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error)
|
||||||
UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
|
UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
|
||||||
|
|
||||||
// API Key management (admin)
|
// API Key management (admin)
|
||||||
@@ -1263,6 +1264,13 @@ func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, p
|
|||||||
return keys, result.Total, nil
|
return keys, result.Total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error) {
|
||||||
|
if s.userGroupRateRepo == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return s.userGroupRateRepo.GetByGroupID(ctx, groupID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error {
|
func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error {
|
||||||
return s.groupRepo.UpdateSortOrders(ctx, updates)
|
return s.groupRepo.UpdateSortOrders(ctx, updates)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ func (s *userGroupRateRepoStubForListUsers) SyncUserGroupRates(_ context.Context
|
|||||||
panic("unexpected SyncUserGroupRates call")
|
panic("unexpected SyncUserGroupRates call")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *userGroupRateRepoStubForListUsers) GetByGroupID(_ context.Context, groupID int64) ([]UserGroupRateEntry, error) {
|
||||||
|
panic("unexpected GetByGroupID call")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *userGroupRateRepoStubForListUsers) DeleteByGroupID(_ context.Context, groupID int64) error {
|
func (s *userGroupRateRepoStubForListUsers) DeleteByGroupID(_ context.Context, groupID int64) error {
|
||||||
panic("unexpected DeleteByGroupID call")
|
panic("unexpected DeleteByGroupID call")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1022,7 +1022,7 @@ func TestHandleClaudeStreamingResponse_EmptyStream(t *testing.T) {
|
|||||||
fmt.Fprintln(pw, "")
|
fmt.Fprintln(pw, "")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "claude-sonnet-4-5")
|
_, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "claude-sonnet-4-5", 0)
|
||||||
_ = pr.Close()
|
_ = pr.Close()
|
||||||
|
|
||||||
// 应当返回 UpstreamFailoverError 而非 nil,以便上层触发 failover
|
// 应当返回 UpstreamFailoverError 而非 nil,以便上层触发 failover
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ package service
|
|||||||
|
|
||||||
import "context"
|
import "context"
|
||||||
|
|
||||||
|
// UserGroupRateEntry 分组下用户专属倍率条目
|
||||||
|
type UserGroupRateEntry struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
UserEmail string `json:"user_email"`
|
||||||
|
RateMultiplier float64 `json:"rate_multiplier"`
|
||||||
|
}
|
||||||
|
|
||||||
// UserGroupRateRepository 用户专属分组倍率仓储接口
|
// UserGroupRateRepository 用户专属分组倍率仓储接口
|
||||||
// 允许管理员为特定用户设置分组的专属计费倍率,覆盖分组默认倍率
|
// 允许管理员为特定用户设置分组的专属计费倍率,覆盖分组默认倍率
|
||||||
type UserGroupRateRepository interface {
|
type UserGroupRateRepository interface {
|
||||||
@@ -13,6 +20,9 @@ type UserGroupRateRepository interface {
|
|||||||
// 如果未设置专属倍率,返回 nil
|
// 如果未设置专属倍率,返回 nil
|
||||||
GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error)
|
GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error)
|
||||||
|
|
||||||
|
// GetByGroupID 获取指定分组下所有用户的专属倍率
|
||||||
|
GetByGroupID(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error)
|
||||||
|
|
||||||
// SyncUserGroupRates 同步用户的分组专属倍率
|
// SyncUserGroupRates 同步用户的分组专属倍率
|
||||||
// rates: map[groupID]*rateMultiplier,nil 表示删除该分组的专属倍率
|
// rates: map[groupID]*rateMultiplier,nil 表示删除该分组的专属倍率
|
||||||
SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error
|
SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error
|
||||||
|
|||||||
@@ -153,6 +153,27 @@ export async function getGroupApiKeys(
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate multiplier entry for a user in a group
|
||||||
|
*/
|
||||||
|
export interface GroupRateMultiplierEntry {
|
||||||
|
user_id: number
|
||||||
|
user_email: string
|
||||||
|
rate_multiplier: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate multipliers for users in a group
|
||||||
|
* @param id - Group ID
|
||||||
|
* @returns List of user rate multiplier entries
|
||||||
|
*/
|
||||||
|
export async function getGroupRateMultipliers(id: number): Promise<GroupRateMultiplierEntry[]> {
|
||||||
|
const { data } = await apiClient.get<GroupRateMultiplierEntry[]>(
|
||||||
|
`/admin/groups/${id}/rate-multipliers`
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update group sort orders
|
* Update group sort orders
|
||||||
* @param updates - Array of { id, sort_order } objects
|
* @param updates - Array of { id, sort_order } objects
|
||||||
@@ -178,6 +199,7 @@ export const groupsAPI = {
|
|||||||
toggleStatus,
|
toggleStatus,
|
||||||
getStats,
|
getStats,
|
||||||
getGroupApiKeys,
|
getGroupApiKeys,
|
||||||
|
getGroupRateMultipliers,
|
||||||
updateSortOrder
|
updateSortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<template>
|
||||||
|
<BaseDialog :show="show" :title="t('admin.groups.rateMultipliersTitle')" width="normal" @close="$emit('close')">
|
||||||
|
<div v-if="group" class="space-y-5">
|
||||||
|
<!-- 分组信息 -->
|
||||||
|
<div class="flex flex-wrap items-center gap-3 rounded-lg bg-gray-50 px-4 py-3 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>
|
||||||
|
<span class="text-gray-400">|</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">
|
||||||
|
{{ t('admin.groups.columns.rateMultiplier') }}: {{ group.rate_multiplier }}x
|
||||||
|
</span>
|
||||||
|
</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">
|
||||||
|
{{ t('admin.groups.addUserRate') }}
|
||||||
|
</h4>
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
class="input w-full"
|
||||||
|
:placeholder="t('admin.groups.searchUserPlaceholder')"
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
@click="selectUser(user)"
|
||||||
|
>
|
||||||
|
<span class="text-gray-900 dark:text-white">{{ user.email }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-28">
|
||||||
|
<input
|
||||||
|
v-model.number="newRate"
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
min="0"
|
||||||
|
class="hide-spinner input w-full"
|
||||||
|
placeholder="1.0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary shrink-0"
|
||||||
|
:disabled="!selectedUser || !newRate || addingRate"
|
||||||
|
@click="handleAddRate"
|
||||||
|
>
|
||||||
|
<Icon v-if="addingRate" name="refresh" size="sm" class="mr-1 animate-spin" />
|
||||||
|
{{ t('common.add') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已设置的用户列表 -->
|
||||||
|
<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 v-if="entries.length === 0" class="py-8 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)"
|
||||||
|
>
|
||||||
|
<Icon name="trash" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { adminAPI } from '@/api/admin'
|
||||||
|
import type { GroupRateMultiplierEntry } from '@/api/admin/groups'
|
||||||
|
import type { AdminGroup, AdminUser } from '@/types'
|
||||||
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
group: AdminGroup | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
success: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const entries = ref<GroupRateMultiplierEntry[]>([])
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchResults = ref<AdminUser[]>([])
|
||||||
|
const showDropdown = ref(false)
|
||||||
|
const selectedUser = ref<AdminUser | null>(null)
|
||||||
|
const newRate = ref<number | null>(null)
|
||||||
|
const addingRate = ref(false)
|
||||||
|
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
const loadEntries = async () => {
|
||||||
|
if (!props.group) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
entries.value = await adminAPI.groups.getGroupRateMultipliers(props.group.id)
|
||||||
|
} catch (error) {
|
||||||
|
appStore.showError(t('admin.groups.failedToLoad'))
|
||||||
|
console.error('Error loading group rate multipliers:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.show, (val) => {
|
||||||
|
if (val && props.group) {
|
||||||
|
loadEntries()
|
||||||
|
searchQuery.value = ''
|
||||||
|
searchResults.value = []
|
||||||
|
selectedUser.value = null
|
||||||
|
newRate.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSearchUsers = () => {
|
||||||
|
clearTimeout(searchTimeout)
|
||||||
|
selectedUser.value = null
|
||||||
|
if (!searchQuery.value.trim()) {
|
||||||
|
searchResults.value = []
|
||||||
|
showDropdown.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searchTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.users.list(1, 10, { search: searchQuery.value.trim() })
|
||||||
|
searchResults.value = res.items
|
||||||
|
showDropdown.value = true
|
||||||
|
} catch {
|
||||||
|
searchResults.value = []
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectUser = (user: AdminUser) => {
|
||||||
|
selectedUser.value = user
|
||||||
|
searchQuery.value = user.email
|
||||||
|
showDropdown.value = false
|
||||||
|
searchResults.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddRate = async () => {
|
||||||
|
if (!selectedUser.value || !newRate.value || !props.group) return
|
||||||
|
addingRate.value = true
|
||||||
|
try {
|
||||||
|
await adminAPI.users.update(selectedUser.value.id, {
|
||||||
|
group_rates: { [props.group.id]: newRate.value }
|
||||||
|
})
|
||||||
|
appStore.showSuccess(t('admin.groups.rateAdded'))
|
||||||
|
searchQuery.value = ''
|
||||||
|
selectedUser.value = null
|
||||||
|
newRate.value = null
|
||||||
|
await loadEntries()
|
||||||
|
emit('success')
|
||||||
|
} catch (error) {
|
||||||
|
appStore.showError(t('admin.groups.failedToSave'))
|
||||||
|
console.error('Error adding rate multiplier:', error)
|
||||||
|
} finally {
|
||||||
|
addingRate.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateRate = async (entry: GroupRateMultiplierEntry, value: string) => {
|
||||||
|
if (!props.group) return
|
||||||
|
const numValue = parseFloat(value)
|
||||||
|
if (isNaN(numValue) || numValue === entry.rate_multiplier) return
|
||||||
|
try {
|
||||||
|
await adminAPI.users.update(entry.user_id, {
|
||||||
|
group_rates: { [props.group.id]: numValue }
|
||||||
|
})
|
||||||
|
appStore.showSuccess(t('admin.groups.rateUpdated'))
|
||||||
|
await loadEntries()
|
||||||
|
emit('success')
|
||||||
|
} catch (error) {
|
||||||
|
appStore.showError(t('admin.groups.failedToSave'))
|
||||||
|
console.error('Error updating rate multiplier:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteRate = async (entry: GroupRateMultiplierEntry) => {
|
||||||
|
if (!props.group) return
|
||||||
|
try {
|
||||||
|
await adminAPI.users.update(entry.user_id, {
|
||||||
|
group_rates: { [props.group.id]: null }
|
||||||
|
})
|
||||||
|
appStore.showSuccess(t('admin.groups.rateDeleted'))
|
||||||
|
await loadEntries()
|
||||||
|
emit('success')
|
||||||
|
} catch (error) {
|
||||||
|
appStore.showError(t('admin.groups.failedToSave'))
|
||||||
|
console.error('Error deleting rate multiplier:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部关闭下拉
|
||||||
|
const handleClickOutside = () => {
|
||||||
|
showDropdown.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hide-spinner::-webkit-outer-spin-button,
|
||||||
|
.hide-spinner::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.hide-spinner {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1409,6 +1409,14 @@ export default {
|
|||||||
failedToUpdate: 'Failed to update group',
|
failedToUpdate: 'Failed to update group',
|
||||||
failedToDelete: 'Failed to delete group',
|
failedToDelete: 'Failed to delete group',
|
||||||
nameRequired: 'Please enter group name',
|
nameRequired: 'Please enter group name',
|
||||||
|
rateMultipliers: 'Rate Multipliers',
|
||||||
|
rateMultipliersTitle: 'Group Rate Multipliers',
|
||||||
|
addUserRate: 'Add User Rate Multiplier',
|
||||||
|
searchUserPlaceholder: 'Search user email...',
|
||||||
|
noRateMultipliers: 'No user rate multipliers configured',
|
||||||
|
rateUpdated: 'Rate multiplier updated',
|
||||||
|
rateDeleted: 'Rate multiplier removed',
|
||||||
|
rateAdded: 'Rate multiplier added',
|
||||||
platforms: {
|
platforms: {
|
||||||
all: 'All Platforms',
|
all: 'All Platforms',
|
||||||
anthropic: 'Anthropic',
|
anthropic: 'Anthropic',
|
||||||
|
|||||||
@@ -1508,6 +1508,14 @@ export default {
|
|||||||
failedToCreate: '创建分组失败',
|
failedToCreate: '创建分组失败',
|
||||||
failedToUpdate: '更新分组失败',
|
failedToUpdate: '更新分组失败',
|
||||||
nameRequired: '请输入分组名称',
|
nameRequired: '请输入分组名称',
|
||||||
|
rateMultipliers: '专属倍率',
|
||||||
|
rateMultipliersTitle: '分组专属倍率管理',
|
||||||
|
addUserRate: '添加用户专属倍率',
|
||||||
|
searchUserPlaceholder: '搜索用户邮箱...',
|
||||||
|
noRateMultipliers: '暂无用户设置了专属倍率',
|
||||||
|
rateUpdated: '专属倍率已更新',
|
||||||
|
rateDeleted: '专属倍率已删除',
|
||||||
|
rateAdded: '专属倍率已添加',
|
||||||
subscription: {
|
subscription: {
|
||||||
title: '订阅设置',
|
title: '订阅设置',
|
||||||
type: '计费类型',
|
type: '计费类型',
|
||||||
|
|||||||
@@ -181,6 +181,13 @@
|
|||||||
<Icon name="edit" size="sm" />
|
<Icon name="edit" size="sm" />
|
||||||
<span class="text-xs">{{ t('common.edit') }}</span>
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
@click="handleRateMultipliers(row)"
|
||||||
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-purple-600 dark:hover:bg-dark-700 dark:hover:text-purple-400"
|
||||||
|
>
|
||||||
|
<Icon name="dollar" size="sm" />
|
||||||
|
<span class="text-xs">{{ t('admin.groups.rateMultipliers') }}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="handleDelete(row)"
|
@click="handleDelete(row)"
|
||||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
@@ -1879,6 +1886,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
|
<!-- Group Rate Multipliers Modal -->
|
||||||
|
<GroupRateMultipliersModal
|
||||||
|
:show="showRateMultipliersModal"
|
||||||
|
:group="rateMultipliersGroup"
|
||||||
|
@close="showRateMultipliersModal = false"
|
||||||
|
@success="loadGroups"
|
||||||
|
/>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1900,6 +1915,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
|
|||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import GroupRateMultipliersModal from '@/components/admin/group/GroupRateMultipliersModal.vue'
|
||||||
import { VueDraggable } from 'vue-draggable-plus'
|
import { VueDraggable } from 'vue-draggable-plus'
|
||||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||||
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
||||||
@@ -2074,6 +2090,8 @@ const submitting = ref(false)
|
|||||||
const sortSubmitting = ref(false)
|
const sortSubmitting = ref(false)
|
||||||
const editingGroup = ref<AdminGroup | null>(null)
|
const editingGroup = ref<AdminGroup | null>(null)
|
||||||
const deletingGroup = ref<AdminGroup | null>(null)
|
const deletingGroup = ref<AdminGroup | null>(null)
|
||||||
|
const showRateMultipliersModal = ref(false)
|
||||||
|
const rateMultipliersGroup = ref<AdminGroup | null>(null)
|
||||||
const sortableGroups = ref<AdminGroup[]>([])
|
const sortableGroups = ref<AdminGroup[]>([])
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
@@ -2574,6 +2592,11 @@ const handleUpdateGroup = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRateMultipliers = (group: AdminGroup) => {
|
||||||
|
rateMultipliersGroup.value = group
|
||||||
|
showRateMultipliersModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = (group: AdminGroup) => {
|
const handleDelete = (group: AdminGroup) => {
|
||||||
deletingGroup.value = group
|
deletingGroup.value = group
|
||||||
showDeleteDialog.value = true
|
showDeleteDialog.value = true
|
||||||
|
|||||||
Reference in New Issue
Block a user