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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user