refactor(frontend): 统一管理页面工具条布局和操作列样式

## 修复内容

### 1. 统一操作列按钮样式
- 所有操作列按钮统一为"图标+文字"垂直排列样式
- UsersView: 编辑和更多按钮添加文字标签
- 与 AccountsView、GroupsView 等页面保持一致

### 2. 统一顶部工具条布局(6个管理页面)
- 使用 flex + justify-between 布局
- 左侧:模糊搜索框、筛选器(可多行排列)
- 右侧:刷新、创建等操作按钮(靠右对齐)
- 响应式:宽度不够时右侧按钮自动换行到上一行

### 3. 修复的页面
- AccountsView: 合并 actions/filters 到单行工具条
- UsersView: 标准左右分栏,操作列添加文字
- GroupsView: 新增搜索框,左右分栏布局
- ProxiesView: 左右分栏,响应式布局
- SubscriptionsView: 新增用户模糊搜索,左右分栏
- UsageView: 补齐所有筛选项,左右分栏

### 4. 新增功能
- GroupsView: 新增分组名称/描述模糊搜索
- SubscriptionsView: 新增用户模糊搜索功能
- UsageView: 补齐 API Key 搜索筛选

### 5. 国际化
- 新增相关搜索框的 placeholder 文案(中英文)

## 技术细节
- 使用 flex-wrap-reverse 实现响应式换行
- 左侧筛选区使用 flex-wrap 支持多行
- 右侧按钮区使用 ml-auto + justify-end 保持右对齐
- 移动端使用 w-full sm:w-* 响应式宽度

## 验证结果
-  TypeScript 类型检查通过
-  所有页面布局统一
-  响应式布局正常工作
This commit is contained in:
IanShaw027
2026-01-05 01:00:00 +08:00
parent 06216aad53
commit eef12cb900
10 changed files with 826 additions and 265 deletions

View File

@@ -1,62 +1,143 @@
<template>
<AppLayout>
<TablePageLayout>
<!-- Page Header Actions -->
<template #actions>
<div class="flex justify-end gap-3">
<button
@click="loadSubscriptions"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<svg
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button @click="showAssignModal = true" class="btn btn-primary">
<svg
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ t('admin.subscriptions.assignSubscription') }}
</button>
</div>
</template>
<!-- Filters -->
<template #filters>
<div class="flex flex-wrap gap-3">
<Select
v-model="filters.status"
:options="statusOptions"
:placeholder="t('admin.subscriptions.allStatus')"
class="w-40"
@change="loadSubscriptions"
/>
<Select
v-model="filters.group_id"
:options="groupOptions"
:placeholder="t('admin.subscriptions.allGroups')"
class="w-48"
@change="loadSubscriptions"
/>
</div>
<!-- Top Toolbar: Left (search + filters) / Right (actions) -->
<div class="flex flex-wrap items-start justify-between gap-4">
<!-- Left: Fuzzy user search + filters (wrap to multiple lines) -->
<div class="flex flex-1 flex-wrap items-center gap-3">
<!-- User Search -->
<div
class="relative w-full sm:flex-1 sm:min-w-[14rem] sm:max-w-md"
data-filter-user-search
>
<svg
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
v-model="filterUserKeyword"
type="text"
:placeholder="t('admin.users.searchUsers')"
class="input pl-10 pr-8"
@input="debounceSearchFilterUsers"
@focus="showFilterUserDropdown = true"
/>
<button
v-if="selectedFilterUser"
@click="clearFilterUser"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
:title="t('common.clear')"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<!-- User Dropdown -->
<div
v-if="showFilterUserDropdown && (filterUserResults.length > 0 || filterUserKeyword)"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
<div
v-if="filterUserLoading"
class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
>
{{ t('common.loading') }}
</div>
<div
v-else-if="filterUserResults.length === 0 && filterUserKeyword"
class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
>
{{ t('common.noOptionsFound') }}
</div>
<button
v-for="user in filterUserResults"
:key="user.id"
type="button"
@click="selectFilterUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span class="font-medium text-gray-900 dark:text-white">{{ user.email }}</span>
<span class="ml-2 text-gray-500 dark:text-gray-400">#{{ user.id }}</span>
</button>
</div>
</div>
<!-- Filters -->
<div class="w-full sm:w-40">
<Select
v-model="filters.status"
:options="statusOptions"
:placeholder="t('admin.subscriptions.allStatus')"
@change="applyFilters"
/>
</div>
<div class="w-full sm:w-48">
<Select
v-model="filters.group_id"
:options="groupOptions"
:placeholder="t('admin.subscriptions.allGroups')"
@change="applyFilters"
/>
</div>
</div>
<!-- Right: Actions -->
<div class="ml-auto flex flex-wrap items-center justify-end gap-3">
<button
@click="loadSubscriptions"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<svg
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button @click="showAssignModal = true" class="btn btn-primary">
<svg
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{ t('admin.subscriptions.assignSubscription') }}
</button>
</div>
</div>
</template>
<!-- Subscriptions Table -->
@@ -338,7 +419,7 @@
>
<div>
<label class="input-label">{{ t('admin.subscriptions.form.user') }}</label>
<div class="relative">
<div class="relative" data-assign-user-search>
<input
v-model="userSearchKeyword"
type="text"
@@ -555,6 +636,14 @@ const groups = ref<Group[]>([])
const loading = ref(false)
let abortController: AbortController | null = null
// Toolbar user filter (fuzzy search -> select user_id)
const filterUserKeyword = ref('')
const filterUserResults = ref<SimpleUser[]>([])
const filterUserLoading = ref(false)
const showFilterUserDropdown = ref(false)
const selectedFilterUser = ref<SimpleUser | null>(null)
let filterUserSearchTimeout: ReturnType<typeof setTimeout> | null = null
// User search state
const userSearchKeyword = ref('')
const userSearchResults = ref<SimpleUser[]>([])
@@ -565,7 +654,8 @@ let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
const filters = reactive({
status: '',
group_id: ''
group_id: '',
user_id: null as number | null
})
const pagination = reactive({
page: 1,
@@ -604,6 +694,11 @@ const subscriptionGroupOptions = computed(() =>
.map((g) => ({ value: g.id, label: g.name }))
)
const applyFilters = () => {
pagination.page = 1
loadSubscriptions()
}
const loadSubscriptions = async () => {
if (abortController) {
abortController.abort()
@@ -614,12 +709,18 @@ const loadSubscriptions = async () => {
loading.value = true
try {
const response = await adminAPI.subscriptions.list(pagination.page, pagination.page_size, {
status: (filters.status as any) || undefined,
group_id: filters.group_id ? parseInt(filters.group_id) : undefined
}, {
signal
})
const response = await adminAPI.subscriptions.list(
pagination.page,
pagination.page_size,
{
status: (filters.status as any) || undefined,
group_id: filters.group_id ? parseInt(filters.group_id) : undefined,
user_id: filters.user_id || undefined
},
{
signal
}
)
if (signal.aborted || abortController !== requestController) return
subscriptions.value = response.items
pagination.total = response.total
@@ -646,6 +747,57 @@ const loadGroups = async () => {
}
}
// Toolbar user filter search with debounce
const debounceSearchFilterUsers = () => {
if (filterUserSearchTimeout) {
clearTimeout(filterUserSearchTimeout)
}
filterUserSearchTimeout = setTimeout(searchFilterUsers, 300)
}
const searchFilterUsers = async () => {
const keyword = filterUserKeyword.value.trim()
// Clear active user filter if user modified the search keyword
if (selectedFilterUser.value && keyword !== selectedFilterUser.value.email) {
selectedFilterUser.value = null
filters.user_id = null
applyFilters()
}
if (!keyword) {
filterUserResults.value = []
return
}
filterUserLoading.value = true
try {
filterUserResults.value = await adminAPI.usage.searchUsers(keyword)
} catch (error) {
console.error('Failed to search users:', error)
filterUserResults.value = []
} finally {
filterUserLoading.value = false
}
}
const selectFilterUser = (user: SimpleUser) => {
selectedFilterUser.value = user
filterUserKeyword.value = user.email
showFilterUserDropdown.value = false
filters.user_id = user.id
applyFilters()
}
const clearFilterUser = () => {
selectedFilterUser.value = null
filterUserKeyword.value = ''
filterUserResults.value = []
showFilterUserDropdown.value = false
filters.user_id = null
applyFilters()
}
// User search with debounce
const debounceSearchUsers = () => {
if (userSearchTimeout) {
@@ -856,9 +1008,8 @@ const formatResetTime = (windowStart: string, period: 'daily' | 'weekly' | 'mont
// Handle click outside to close user dropdown
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!target.closest('.relative')) {
showUserDropdown.value = false
}
if (!target.closest('[data-assign-user-search]')) showUserDropdown.value = false
if (!target.closest('[data-filter-user-search]')) showFilterUserDropdown.value = false
}
onMounted(() => {
@@ -869,6 +1020,9 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
if (filterUserSearchTimeout) {
clearTimeout(filterUserSearchTimeout)
}
if (userSearchTimeout) {
clearTimeout(userSearchTimeout)
}