feat(admin): 添加账号管理和订阅管理的列设置功能

- 账号管理新增代理列显示和列设置下拉菜单
- 订阅管理新增列设置,支持用户列在邮箱/用户名间切换
- 列设置持久化到 localStorage
- 统一列设置图标样式

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gemini Wen
2026-01-18 11:57:07 +08:00
parent dae0d5321f
commit 45e8598d32
5 changed files with 288 additions and 15 deletions

View File

@@ -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)