mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(groups): wider modal, table layout, unit tests, user details
- Widen modal to 'wide' size for table layout - Separate columns: ID, username, email, notes, status, rate - Use standard Pagination component for consistent style - Add user_notes and user_status to backend GroupRateMultiplierEntry - Add unit tests for GetGroupRateMultipliers and ClearGroupRateMultipliers - Allow duplicate user add (overwrites existing rate)
This commit is contained in:
@@ -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.username, u.email, ugr.rate_multiplier
|
||||
SELECT ugr.user_id, u.username, u.email, COALESCE(u.notes, ''), u.status, 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.UserName, &entry.UserEmail, &entry.RateMultiplier); err != nil {
|
||||
if err := rows.Scan(&entry.UserID, &entry.UserName, &entry.UserEmail, &entry.UserNotes, &entry.UserStatus, &entry.RateMultiplier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, entry)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// userGroupRateRepoStubForGroupRate implements UserGroupRateRepository for group rate tests.
|
||||
type userGroupRateRepoStubForGroupRate struct {
|
||||
getByGroupIDData map[int64][]UserGroupRateEntry
|
||||
getByGroupIDErr error
|
||||
|
||||
deletedGroupIDs []int64
|
||||
deleteByGroupErr error
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) GetByUserID(_ context.Context, _ int64) (map[int64]float64, error) {
|
||||
panic("unexpected GetByUserID call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) GetByUserAndGroup(_ context.Context, _, _ int64) (*float64, error) {
|
||||
panic("unexpected GetByUserAndGroup call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) GetByGroupID(_ context.Context, groupID int64) ([]UserGroupRateEntry, error) {
|
||||
if s.getByGroupIDErr != nil {
|
||||
return nil, s.getByGroupIDErr
|
||||
}
|
||||
return s.getByGroupIDData[groupID], nil
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) SyncUserGroupRates(_ context.Context, _ int64, _ map[int64]*float64) error {
|
||||
panic("unexpected SyncUserGroupRates call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) DeleteByGroupID(_ context.Context, groupID int64) error {
|
||||
s.deletedGroupIDs = append(s.deletedGroupIDs, groupID)
|
||||
return s.deleteByGroupErr
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) DeleteByUserID(_ context.Context, _ int64) error {
|
||||
panic("unexpected DeleteByUserID call")
|
||||
}
|
||||
|
||||
func TestAdminService_GetGroupRateMultipliers(t *testing.T) {
|
||||
t.Run("returns entries for group", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{
|
||||
getByGroupIDData: map[int64][]UserGroupRateEntry{
|
||||
10: {
|
||||
{UserID: 1, UserName: "alice", UserEmail: "alice@test.com", RateMultiplier: 1.5},
|
||||
{UserID: 2, UserName: "bob", UserEmail: "bob@test.com", RateMultiplier: 0.8},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
entries, err := svc.GetGroupRateMultipliers(context.Background(), 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 2)
|
||||
require.Equal(t, int64(1), entries[0].UserID)
|
||||
require.Equal(t, "alice", entries[0].UserName)
|
||||
require.Equal(t, 1.5, entries[0].RateMultiplier)
|
||||
require.Equal(t, int64(2), entries[1].UserID)
|
||||
require.Equal(t, 0.8, entries[1].RateMultiplier)
|
||||
})
|
||||
|
||||
t.Run("returns nil when repo is nil", func(t *testing.T) {
|
||||
svc := &adminServiceImpl{userGroupRateRepo: nil}
|
||||
|
||||
entries, err := svc.GetGroupRateMultipliers(context.Background(), 10)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, entries)
|
||||
})
|
||||
|
||||
t.Run("returns empty slice for group with no entries", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{
|
||||
getByGroupIDData: map[int64][]UserGroupRateEntry{},
|
||||
}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
entries, err := svc.GetGroupRateMultipliers(context.Background(), 99)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, entries)
|
||||
})
|
||||
|
||||
t.Run("propagates repo error", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{
|
||||
getByGroupIDErr: errors.New("db error"),
|
||||
}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
_, err := svc.GetGroupRateMultipliers(context.Background(), 10)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "db error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAdminService_ClearGroupRateMultipliers(t *testing.T) {
|
||||
t.Run("deletes by group ID", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
err := svc.ClearGroupRateMultipliers(context.Background(), 42)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{42}, repo.deletedGroupIDs)
|
||||
})
|
||||
|
||||
t.Run("returns nil when repo is nil", func(t *testing.T) {
|
||||
svc := &adminServiceImpl{userGroupRateRepo: nil}
|
||||
|
||||
err := svc.ClearGroupRateMultipliers(context.Background(), 42)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("propagates repo error", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{
|
||||
deleteByGroupErr: errors.New("delete failed"),
|
||||
}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
err := svc.ClearGroupRateMultipliers(context.Background(), 42)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "delete failed")
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,8 @@ type UserGroupRateEntry struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
UserEmail string `json:"user_email"`
|
||||
UserNotes string `json:"user_notes"`
|
||||
UserStatus string `json:"user_status"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,8 @@ export interface GroupRateMultiplierEntry {
|
||||
user_id: number
|
||||
user_name: string
|
||||
user_email: string
|
||||
user_notes: string
|
||||
user_status: string
|
||||
rate_multiplier: number
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.groups.rateMultipliersTitle')" width="normal" @close="$emit('close')">
|
||||
<BaseDialog :show="show" :title="t('admin.groups.rateMultipliersTitle')" width="wide" @close="$emit('close')">
|
||||
<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-2.5 text-sm dark:bg-dark-700">
|
||||
@@ -38,7 +38,7 @@
|
||||
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-500 dark:text-gray-400">#{{ user.id }}</span>
|
||||
<span class="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>
|
||||
@@ -97,67 +97,76 @@
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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 class="overflow-x-auto rounded-lg border border-gray-200 dark:border-dark-600">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 bg-gray-50 dark:border-dark-600 dark:bg-dark-700">
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">ID</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userName') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userEmail') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userNotes') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userStatus') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.rateMultiplier') }}</th>
|
||||
<th class="w-10 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-600">
|
||||
<tr
|
||||
v-for="entry in paginatedEntries"
|
||||
:key="entry.user_id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-700/50"
|
||||
>
|
||||
<td class="whitespace-nowrap px-3 py-2 text-gray-400 dark:text-gray-500">{{ entry.user_id }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-2 text-gray-900 dark:text-white">{{ entry.user_name || '-' }}</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">{{ entry.user_email }}</td>
|
||||
<td class="max-w-[160px] truncate px-3 py-2 text-gray-500 dark:text-gray-400" :title="entry.user_notes">{{ entry.user_notes || '-' }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-2">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
entry.user_status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
{{ entry.user_status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-2">
|
||||
<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()"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
<Pagination
|
||||
v-if="entries.length > pageSize"
|
||||
:total="entries.length"
|
||||
:page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:show-page-size-selector="false"
|
||||
@update:page="currentPage = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,6 +181,7 @@ 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 Pagination from '@/components/common/Pagination.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -201,7 +211,6 @@ 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)
|
||||
@@ -212,7 +221,11 @@ const loadEntries = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
entries.value = await adminAPI.groups.getGroupRateMultipliers(props.group.id)
|
||||
currentPage.value = 1
|
||||
// 确保当前页不超出范围
|
||||
const totalPages = Math.max(1, Math.ceil(entries.value.length / pageSize))
|
||||
if (currentPage.value > totalPages) {
|
||||
currentPage.value = totalPages
|
||||
}
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToLoad'))
|
||||
console.error('Error loading group rate multipliers:', error)
|
||||
@@ -223,6 +236,7 @@ const loadEntries = async () => {
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val && props.group) {
|
||||
currentPage.value = 1
|
||||
loadEntries()
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
|
||||
@@ -1372,7 +1372,11 @@ export default {
|
||||
accounts: 'Accounts',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
billingType: 'Billing Type'
|
||||
billingType: 'Billing Type',
|
||||
userName: 'Username',
|
||||
userEmail: 'Email',
|
||||
userNotes: 'Notes',
|
||||
userStatus: 'Status'
|
||||
},
|
||||
rateAndAccounts: '{rate}x rate · {count} accounts',
|
||||
accountsCount: '{count} accounts',
|
||||
|
||||
@@ -1428,7 +1428,11 @@ export default {
|
||||
accounts: '账号数',
|
||||
status: '状态',
|
||||
actions: '操作',
|
||||
billingType: '计费类型'
|
||||
billingType: '计费类型',
|
||||
userName: '用户名',
|
||||
userEmail: '邮箱',
|
||||
userNotes: '备注',
|
||||
userStatus: '状态'
|
||||
},
|
||||
form: {
|
||||
name: '名称',
|
||||
|
||||
Reference in New Issue
Block a user