feat(admin): 添加账号管理和订阅管理的列设置功能
- 账号管理新增代理列显示和列设置下拉菜单 - 订阅管理新增列设置,支持用户列在邮箱/用户名间切换 - 列设置持久化到 localStorage - 统一列设置图标样式 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<slot name="before"></slot>
|
||||
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary">
|
||||
<Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" />
|
||||
</button>
|
||||
|
||||
@@ -673,6 +673,7 @@ export default {
|
||||
updating: 'Updating...',
|
||||
columns: {
|
||||
user: 'User',
|
||||
email: 'Email',
|
||||
username: 'Username',
|
||||
notes: 'Notes',
|
||||
role: 'Role',
|
||||
@@ -1093,6 +1094,7 @@ export default {
|
||||
todayStats: 'Today Stats',
|
||||
groups: 'Groups',
|
||||
usageWindows: 'Usage Windows',
|
||||
proxy: 'Proxy',
|
||||
lastUsed: 'Last Used',
|
||||
expiresAt: 'Expires At',
|
||||
actions: 'Actions'
|
||||
|
||||
@@ -1142,6 +1142,7 @@ export default {
|
||||
todayStats: '今日统计',
|
||||
groups: '分组',
|
||||
usageWindows: '用量窗口',
|
||||
proxy: '代理',
|
||||
lastUsed: '最近使用',
|
||||
expiresAt: '过期时间',
|
||||
actions: '操作'
|
||||
|
||||
@@ -15,7 +15,40 @@
|
||||
@refresh="load"
|
||||
@sync="showSync = true"
|
||||
@create="showCreate = true"
|
||||
/>
|
||||
>
|
||||
<template #before>
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@click="showColumnDropdown = !showColumnDropdown"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.columnSettings')"
|
||||
>
|
||||
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="showColumnDropdown"
|
||||
class="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="max-h-80 overflow-y-auto p-2">
|
||||
<button
|
||||
v-for="col in toggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleColumn(col.key)"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AccountTableActions>
|
||||
</div>
|
||||
</template>
|
||||
<template #table>
|
||||
@@ -54,6 +87,15 @@
|
||||
<template #cell-usage="{ row }">
|
||||
<AccountUsageCell :account="row" />
|
||||
</template>
|
||||
<template #cell-proxy="{ row }">
|
||||
<div v-if="row.proxy" class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ row.proxy.name }}</span>
|
||||
<span v-if="row.proxy.country_code" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
({{ row.proxy.country_code }})
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
</template>
|
||||
<template #cell-rate_multiplier="{ row }">
|
||||
<span class="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{{ (row.rate_multiplier ?? 1).toFixed(2) }}x
|
||||
@@ -143,6 +185,7 @@ import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vu
|
||||
import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
|
||||
import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
|
||||
@@ -171,12 +214,54 @@ const statsAcc = ref<Account | null>(null)
|
||||
const togglingSchedulable = ref<number | null>(null)
|
||||
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
|
||||
|
||||
// Column settings
|
||||
const showColumnDropdown = ref(false)
|
||||
const columnDropdownRef = ref<HTMLElement | null>(null)
|
||||
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||
const DEFAULT_HIDDEN_COLUMNS = ['proxy', 'notes', 'priority', 'rate_multiplier']
|
||||
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
|
||||
|
||||
const loadSavedColumns = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as string[]
|
||||
parsed.forEach(key => hiddenColumns.add(key))
|
||||
} else {
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load saved columns:', e)
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||
}
|
||||
}
|
||||
|
||||
const saveColumnsToStorage = () => {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
||||
} catch (e) {
|
||||
console.error('Failed to save columns:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleColumn = (key: string) => {
|
||||
if (hiddenColumns.has(key)) {
|
||||
hiddenColumns.delete(key)
|
||||
} else {
|
||||
hiddenColumns.add(key)
|
||||
}
|
||||
saveColumnsToStorage()
|
||||
}
|
||||
|
||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||
|
||||
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({
|
||||
fetchFn: adminAPI.accounts.list,
|
||||
initialParams: { platform: '', type: '', status: '', search: '' }
|
||||
})
|
||||
|
||||
const cols = computed(() => {
|
||||
// All available columns
|
||||
const allColumns = computed(() => {
|
||||
const c = [
|
||||
{ key: 'select', label: '', sortable: false },
|
||||
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
||||
@@ -189,11 +274,12 @@ const cols = computed(() => {
|
||||
if (!authStore.isSimpleMode) {
|
||||
c.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false })
|
||||
}
|
||||
c.push(
|
||||
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
|
||||
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
|
||||
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
|
||||
c.push(
|
||||
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
|
||||
{ key: 'proxy', label: t('admin.accounts.columns.proxy'), sortable: false },
|
||||
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
|
||||
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
|
||||
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
|
||||
{ key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
|
||||
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
|
||||
@@ -201,6 +287,18 @@ const cols = computed(() => {
|
||||
return c
|
||||
})
|
||||
|
||||
// Columns that can be toggled (exclude select, name, and actions)
|
||||
const toggleableColumns = computed(() =>
|
||||
allColumns.value.filter(col => col.key !== 'select' && col.key !== 'name' && col.key !== 'actions')
|
||||
)
|
||||
|
||||
// Filtered columns based on visibility
|
||||
const cols = computed(() =>
|
||||
allColumns.value.filter(col =>
|
||||
col.key === 'select' || col.key === 'name' || col.key === 'actions' || !hiddenColumns.has(col.key)
|
||||
)
|
||||
)
|
||||
|
||||
const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true }
|
||||
const openMenu = (a: Account, e: MouseEvent) => {
|
||||
menu.acc = a
|
||||
@@ -403,12 +501,21 @@ const isExpired = (value: number | null) => {
|
||||
return value * 1000 <= Date.now()
|
||||
}
|
||||
|
||||
// 滚动时关闭菜单
|
||||
// 滚动时关闭操作菜单(不关闭列设置下拉菜单)
|
||||
const handleScroll = () => {
|
||||
menu.show = false
|
||||
}
|
||||
|
||||
// 点击外部关闭列设置下拉菜单
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
||||
showColumnDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadSavedColumns()
|
||||
load()
|
||||
try {
|
||||
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
|
||||
@@ -418,9 +525,11 @@ onMounted(async () => {
|
||||
console.error('Failed to load proxies/groups:', error)
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -85,6 +85,57 @@
|
||||
|
||||
<!-- Right: Actions -->
|
||||
<div class="ml-auto flex flex-wrap items-center justify-end gap-3">
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@click="showColumnDropdown = !showColumnDropdown"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.columnSettings')"
|
||||
>
|
||||
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="showColumnDropdown"
|
||||
class="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="p-2">
|
||||
<!-- User column mode selection -->
|
||||
<div class="mb-2 border-b border-gray-200 pb-2 dark:border-gray-700">
|
||||
<div class="px-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.subscriptions.columns.user') }}
|
||||
</div>
|
||||
<button
|
||||
@click="setUserColumnMode('email')"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ t('admin.users.columns.email') }}</span>
|
||||
<Icon v-if="userColumnMode === 'email'" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
<button
|
||||
@click="setUserColumnMode('username')"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ t('admin.users.columns.username') }}</span>
|
||||
<Icon v-if="userColumnMode === 'username'" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Other columns toggle -->
|
||||
<button
|
||||
v-for="col in toggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleColumn(col.key)"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="loadSubscriptions"
|
||||
:disabled="loading"
|
||||
@@ -110,12 +161,18 @@
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
|
||||
>
|
||||
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">
|
||||
{{ row.user?.email?.charAt(0).toUpperCase() || '?' }}
|
||||
{{ userColumnMode === 'email'
|
||||
? (row.user?.email?.charAt(0).toUpperCase() || '?')
|
||||
: (row.user?.username?.charAt(0).toUpperCase() || '?')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.user?.email || t('admin.redeem.userPrefix', { id: row.user_id })
|
||||
}}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ userColumnMode === 'email'
|
||||
? (row.user?.email || t('admin.redeem.userPrefix', { id: row.user_id }))
|
||||
: (row.user?.username || '-')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -545,8 +602,43 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'user', label: t('admin.subscriptions.columns.user'), sortable: true },
|
||||
// User column display mode: 'email' or 'username'
|
||||
const userColumnMode = ref<'email' | 'username'>('email')
|
||||
const USER_COLUMN_MODE_KEY = 'subscription-user-column-mode'
|
||||
|
||||
const loadUserColumnMode = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(USER_COLUMN_MODE_KEY)
|
||||
if (saved === 'email' || saved === 'username') {
|
||||
userColumnMode.value = saved
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load user column mode:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const saveUserColumnMode = () => {
|
||||
try {
|
||||
localStorage.setItem(USER_COLUMN_MODE_KEY, userColumnMode.value)
|
||||
} catch (e) {
|
||||
console.error('Failed to save user column mode:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const setUserColumnMode = (mode: 'email' | 'username') => {
|
||||
userColumnMode.value = mode
|
||||
saveUserColumnMode()
|
||||
}
|
||||
|
||||
// All available columns
|
||||
const allColumns = computed<Column[]>(() => [
|
||||
{
|
||||
key: 'user',
|
||||
label: userColumnMode.value === 'email'
|
||||
? t('admin.subscriptions.columns.user')
|
||||
: t('admin.users.columns.username'),
|
||||
sortable: true
|
||||
},
|
||||
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: true },
|
||||
{ key: 'usage', label: t('admin.subscriptions.columns.usage'), sortable: false },
|
||||
{ key: 'expires_at', label: t('admin.subscriptions.columns.expires'), sortable: true },
|
||||
@@ -554,6 +646,69 @@ const columns = computed<Column[]>(() => [
|
||||
{ key: 'actions', label: t('admin.subscriptions.columns.actions'), sortable: false }
|
||||
])
|
||||
|
||||
// Columns that can be toggled (exclude user and actions which are always visible)
|
||||
const toggleableColumns = computed(() =>
|
||||
allColumns.value.filter(col => col.key !== 'user' && col.key !== 'actions')
|
||||
)
|
||||
|
||||
// Hidden columns set
|
||||
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||
|
||||
// Default hidden columns
|
||||
const DEFAULT_HIDDEN_COLUMNS: string[] = []
|
||||
|
||||
// localStorage key
|
||||
const HIDDEN_COLUMNS_KEY = 'subscription-hidden-columns'
|
||||
|
||||
// Load saved column settings
|
||||
const loadSavedColumns = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as string[]
|
||||
parsed.forEach(key => hiddenColumns.add(key))
|
||||
} else {
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load saved columns:', e)
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||
}
|
||||
}
|
||||
|
||||
// Save column settings to localStorage
|
||||
const saveColumnsToStorage = () => {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
||||
} catch (e) {
|
||||
console.error('Failed to save columns:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle column visibility
|
||||
const toggleColumn = (key: string) => {
|
||||
if (hiddenColumns.has(key)) {
|
||||
hiddenColumns.delete(key)
|
||||
} else {
|
||||
hiddenColumns.add(key)
|
||||
}
|
||||
saveColumnsToStorage()
|
||||
}
|
||||
|
||||
// Check if column is visible
|
||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||
|
||||
// Filtered columns for display
|
||||
const columns = computed<Column[]>(() =>
|
||||
allColumns.value.filter(col =>
|
||||
col.key === 'user' || col.key === 'actions' || !hiddenColumns.has(col.key)
|
||||
)
|
||||
)
|
||||
|
||||
// Column dropdown state
|
||||
const showColumnDropdown = ref(false)
|
||||
const columnDropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Filter options
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.subscriptions.allStatus') },
|
||||
@@ -949,14 +1104,19 @@ const formatResetTime = (windowStart: string, period: 'daily' | 'weekly' | 'mont
|
||||
}
|
||||
}
|
||||
|
||||
// Handle click outside to close user dropdown
|
||||
// Handle click outside to close dropdowns
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('[data-assign-user-search]')) showUserDropdown.value = false
|
||||
if (!target.closest('[data-filter-user-search]')) showFilterUserDropdown.value = false
|
||||
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
||||
showColumnDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUserColumnMode()
|
||||
loadSavedColumns()
|
||||
loadSubscriptions()
|
||||
loadGroups()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
||||
Reference in New Issue
Block a user