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,35 +1,353 @@
|
||||
<template>
|
||||
<div class="card p-6"><div class="flex flex-wrap items-end gap-4">
|
||||
<div class="min-w-[200px] relative"><label class="input-label">{{ t('admin.usage.userFilter') }}</label>
|
||||
<input v-model="userKW" type="text" class="input pr-8" :placeholder="t('admin.usage.searchUserPlaceholder')" @input="debounceSearch" @focus="showDD = true" />
|
||||
<button v-if="modelValue.user_id" @click="clearUser" class="absolute right-2 top-9 text-gray-400">✕</button>
|
||||
<div v-if="showDD && (results.length > 0 || userKW)" class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800">
|
||||
<button v-for="u in results" :key="u.id" @click="selectUser(u)" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"><span>{{ u.email }}</span><span class="ml-2 text-xs text-gray-400">#{{ u.id }}</span></button>
|
||||
<div class="card p-6">
|
||||
<!-- Toolbar: left filters (multi-line) + right actions -->
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<!-- Left: filters (allowed to wrap to multiple rows) -->
|
||||
<div class="flex flex-1 flex-wrap items-end gap-4">
|
||||
<!-- User Search -->
|
||||
<div ref="userSearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]">
|
||||
<label class="input-label">{{ t('admin.usage.userFilter') }}</label>
|
||||
<input
|
||||
v-model="userKeyword"
|
||||
type="text"
|
||||
class="input pr-8"
|
||||
:placeholder="t('admin.usage.searchUserPlaceholder')"
|
||||
@input="debounceUserSearch"
|
||||
@focus="showUserDropdown = true"
|
||||
/>
|
||||
<button
|
||||
v-if="filters.user_id"
|
||||
type="button"
|
||||
@click="clearUser"
|
||||
class="absolute right-2 top-9 text-gray-400"
|
||||
aria-label="Clear user filter"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div
|
||||
v-if="showUserDropdown && (userResults.length > 0 || userKeyword)"
|
||||
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
|
||||
>
|
||||
<button
|
||||
v-for="u in userResults"
|
||||
:key="u.id"
|
||||
type="button"
|
||||
@click="selectUser(u)"
|
||||
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ u.email }}</span>
|
||||
<span class="ml-2 text-xs text-gray-400">#{{ u.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key Search -->
|
||||
<div ref="apiKeySearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]">
|
||||
<label class="input-label">{{ t('usage.apiKeyFilter') }}</label>
|
||||
<input
|
||||
v-model="apiKeyKeyword"
|
||||
type="text"
|
||||
class="input pr-8"
|
||||
:placeholder="t('admin.usage.searchApiKeyPlaceholder')"
|
||||
@input="debounceApiKeySearch"
|
||||
@focus="showApiKeyDropdown = true"
|
||||
/>
|
||||
<button
|
||||
v-if="filters.api_key_id"
|
||||
type="button"
|
||||
@click="onClearApiKey"
|
||||
class="absolute right-2 top-9 text-gray-400"
|
||||
aria-label="Clear API key filter"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div
|
||||
v-if="showApiKeyDropdown && (apiKeyResults.length > 0 || apiKeyKeyword)"
|
||||
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
|
||||
>
|
||||
<button
|
||||
v-for="k in apiKeyResults"
|
||||
:key="k.id"
|
||||
type="button"
|
||||
@click="selectApiKey(k)"
|
||||
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span class="truncate">{{ k.name || `#${k.id}` }}</span>
|
||||
<span class="ml-2 text-xs text-gray-400">#{{ k.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[220px]">
|
||||
<label class="input-label">{{ t('usage.model') }}</label>
|
||||
<Select v-model="filters.model" :options="modelOptions" searchable @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Account Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[220px]">
|
||||
<label class="input-label">{{ t('admin.usage.account') }}</label>
|
||||
<Select v-model="filters.account_id" :options="accountOptions" searchable @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Stream Type Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<label class="input-label">{{ t('usage.type') }}</label>
|
||||
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Billing Type Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<label class="input-label">{{ t('usage.billingType') }}</label>
|
||||
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Group Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.group') }}</label>
|
||||
<Select v-model="filters.group_id" :options="groupOptions" searchable @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<div class="w-full sm:w-auto [&_.date-picker-trigger]:w-full">
|
||||
<label class="input-label">{{ t('usage.timeRange') }}</label>
|
||||
<DateRangePicker
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
@update:startDate="updateStartDate"
|
||||
@update:endDate="updateEndDate"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
||||
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
|
||||
{{ t('usage.exportExcel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-[180px]"><label class="input-label">{{ t('usage.model') }}</label><Select v-model="filters.model" :options="mOpts" searchable @change="emitChange" /></div>
|
||||
<div class="min-w-[150px]"><label class="input-label">{{ t('admin.usage.group') }}</label><Select v-model="filters.group_id" :options="gOpts" @change="emitChange" /></div>
|
||||
<div><label class="input-label">{{ t('usage.timeRange') }}</label><DateRangePicker :start-date="startDate" :end-date="endDate" @update:startDate="$emit('update:startDate', $event)" @update:endDate="$emit('update:endDate', $event)" @change="emitChange" /></div>
|
||||
<div class="ml-auto flex gap-3"><button @click="$emit('reset')" class="btn btn-secondary">{{ t('common.reset') }}</button><button @click="$emit('export')" :disabled="exporting" class="btn btn-primary">{{ t('usage.exportExcel') }}</button></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'; import { adminAPI } from '@/api/admin'; import Select from '@/components/common/Select.vue'; import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
const props = defineProps(['modelValue', 'exporting', 'startDate', 'endDate']); const emit = defineEmits(['update:modelValue', 'update:startDate', 'update:endDate', 'change', 'reset', 'export'])
|
||||
const { t } = useI18n(); const filters = props.modelValue
|
||||
const userKW = ref(''); const results = ref<any[]>([]); const showDD = ref(false); let timeout: any = null
|
||||
const mOpts = ref<{value: string | null, label: string}[]>([{ value: null, label: t('admin.usage.allModels') }]); const gOpts = ref<any[]>([{ value: null, label: t('admin.usage.allGroups') }])
|
||||
import { ref, onMounted, onUnmounted, toRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import Select, { type SelectOption } from '@/components/common/Select.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import type { SimpleApiKey, SimpleUser } from '@/api/admin/usage'
|
||||
|
||||
type ModelValue = Record<string, any>
|
||||
|
||||
interface Props {
|
||||
modelValue: ModelValue
|
||||
exporting: boolean
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'update:startDate',
|
||||
'update:endDate',
|
||||
'change',
|
||||
'reset',
|
||||
'export'
|
||||
])
|
||||
|
||||
const { t } = useI18n()
|
||||
const filters = toRef(props, 'modelValue')
|
||||
|
||||
const userSearchRef = ref<HTMLElement | null>(null)
|
||||
const apiKeySearchRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const userKeyword = ref('')
|
||||
const userResults = ref<SimpleUser[]>([])
|
||||
const showUserDropdown = ref(false)
|
||||
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const apiKeyKeyword = ref('')
|
||||
const apiKeyResults = ref<SimpleApiKey[]>([])
|
||||
const showApiKeyDropdown = ref(false)
|
||||
let apiKeySearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
|
||||
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
|
||||
const accountOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allAccounts') }])
|
||||
|
||||
const streamTypeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allTypes') },
|
||||
{ value: true, label: t('usage.stream') },
|
||||
{ value: false, label: t('usage.sync') }
|
||||
])
|
||||
|
||||
const billingTypeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allBillingTypes') },
|
||||
{ value: 1, label: t('usage.subscription') },
|
||||
{ value: 0, label: t('usage.balance') }
|
||||
])
|
||||
|
||||
const emitChange = () => emit('change')
|
||||
const debounceSearch = () => { clearTimeout(timeout); timeout = setTimeout(async () => { if(!userKW.value) { results.value = []; return }; try { results.value = await adminAPI.usage.searchUsers(userKW.value) } catch {} }, 300) }
|
||||
const selectUser = (u: any) => { userKW.value = u.email; showDD.value = false; filters.user_id = u.id; emitChange() }
|
||||
const clearUser = () => { userKW.value = ''; results.value = []; filters.user_id = undefined; emitChange() }
|
||||
|
||||
const updateStartDate = (value: string) => {
|
||||
emit('update:startDate', value)
|
||||
filters.value.start_date = value
|
||||
}
|
||||
|
||||
const updateEndDate = (value: string) => {
|
||||
emit('update:endDate', value)
|
||||
filters.value.end_date = value
|
||||
}
|
||||
|
||||
const debounceUserSearch = () => {
|
||||
if (userSearchTimeout) clearTimeout(userSearchTimeout)
|
||||
userSearchTimeout = setTimeout(async () => {
|
||||
if (!userKeyword.value) {
|
||||
userResults.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
userResults.value = await adminAPI.usage.searchUsers(userKeyword.value)
|
||||
} catch {
|
||||
userResults.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const debounceApiKeySearch = () => {
|
||||
if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout)
|
||||
apiKeySearchTimeout = setTimeout(async () => {
|
||||
if (!apiKeyKeyword.value) {
|
||||
apiKeyResults.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
apiKeyResults.value = await adminAPI.usage.searchApiKeys(
|
||||
filters.value.user_id,
|
||||
apiKeyKeyword.value
|
||||
)
|
||||
} catch {
|
||||
apiKeyResults.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const selectUser = (u: SimpleUser) => {
|
||||
userKeyword.value = u.email
|
||||
showUserDropdown.value = false
|
||||
filters.value.user_id = u.id
|
||||
clearApiKey()
|
||||
emitChange()
|
||||
}
|
||||
|
||||
const clearUser = () => {
|
||||
userKeyword.value = ''
|
||||
userResults.value = []
|
||||
showUserDropdown.value = false
|
||||
filters.value.user_id = undefined
|
||||
clearApiKey()
|
||||
emitChange()
|
||||
}
|
||||
|
||||
const selectApiKey = (k: SimpleApiKey) => {
|
||||
apiKeyKeyword.value = k.name || String(k.id)
|
||||
showApiKeyDropdown.value = false
|
||||
filters.value.api_key_id = k.id
|
||||
emitChange()
|
||||
}
|
||||
|
||||
const clearApiKey = () => {
|
||||
apiKeyKeyword.value = ''
|
||||
apiKeyResults.value = []
|
||||
showApiKeyDropdown.value = false
|
||||
filters.value.api_key_id = undefined
|
||||
}
|
||||
|
||||
const onClearApiKey = () => {
|
||||
clearApiKey()
|
||||
emitChange()
|
||||
}
|
||||
|
||||
const onDocumentClick = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null
|
||||
if (!target) return
|
||||
|
||||
const clickedInsideUser = userSearchRef.value?.contains(target) ?? false
|
||||
const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false
|
||||
|
||||
if (!clickedInsideUser) showUserDropdown.value = false
|
||||
if (!clickedInsideApiKey) showApiKeyDropdown.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.startDate,
|
||||
(value) => {
|
||||
filters.value.start_date = value
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.endDate,
|
||||
(value) => {
|
||||
filters.value.end_date = value
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filters.value.user_id,
|
||||
(userId) => {
|
||||
if (!userId) {
|
||||
userKeyword.value = ''
|
||||
userResults.value = []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filters.value.api_key_id,
|
||||
(apiKeyId) => {
|
||||
if (!apiKeyId) {
|
||||
apiKeyKeyword.value = ''
|
||||
apiKeyResults.value = []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try { const [gs, ms] = await Promise.all([adminAPI.groups.list(1, 1000), adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate })])
|
||||
gOpts.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
|
||||
const unique = new Set<string>(); ms.models?.forEach((s: any) => s.model && unique.add(s.model))
|
||||
mOpts.value.push(...Array.from(unique).sort().map(m => ({ value: m, label: m })))
|
||||
} catch {}
|
||||
document.addEventListener('click', (e) => { if(!(e.target as HTMLElement).closest('.relative')) showDD.value = false })
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
|
||||
try {
|
||||
const [gs, ms, as] = await Promise.all([
|
||||
adminAPI.groups.list(1, 1000),
|
||||
adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate }),
|
||||
adminAPI.accounts.list(1, 1000)
|
||||
])
|
||||
|
||||
groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
|
||||
|
||||
accountOptions.value.push(...as.items.map((a: any) => ({ value: a.id, label: a.name })))
|
||||
|
||||
const uniqueModels = new Set<string>()
|
||||
ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model))
|
||||
modelOptions.value.push(
|
||||
...Array.from(uniqueModels)
|
||||
.sort()
|
||||
.map((m) => ({ value: m, label: m }))
|
||||
)
|
||||
} catch {
|
||||
// Ignore filter option loading errors (page still usable)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user