feat: paginate dashboard clients and proxies via API v2 (#5354)

Move the frps dashboard Clients and Proxies views to the paginated
/api/v2/clients and /api/v2/proxies endpoints instead of fetching all
data at once, and extend server-side proxy search so the search box
keeps working under pagination.

Frontend:
- Add V2Envelope/V2Page types and getV2 HTTP helper to api/http.ts
- Add v2 paginated fetch functions to api/client.ts and api/proxy.ts
- Add ClientV2Info and ProxyV2Info types for v2 API responses
- Rewrite Clients.vue with server-side pagination, status/user search
  filtering, and ElPagination component
- Rewrite Proxies.vue with server-side pagination, type tabs, client
  dropdown filter, and a search box that passes q to the API
- Default page size 10, selectable sizes [10, 20, 50, 100]

Backend:
- Extend /api/v2/proxies q matching to also cover online proxy spec
  fields: TCP/UDP remotePort and HTTP/HTTPS/TCPMux customDomains and
  subdomain, so dashboard search no longer needs to scan every page
- Add controller_v2 tests for the new spec-field matching
This commit is contained in:
fatedier
2026-06-03 14:08:45 +08:00
committed by GitHub
parent c6c545289c
commit 9bde0b07de
9 changed files with 536 additions and 175 deletions

View File

@@ -318,13 +318,31 @@ func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
}
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
return containsV2Query(q,
values := []string{
item.Name,
item.Type,
item.User,
item.ClientID,
item.Status.State,
)
}
switch spec := item.Spec.(type) {
case *model.TCPOutConf:
values = append(values, strconv.Itoa(spec.RemotePort))
case *model.UDPOutConf:
values = append(values, strconv.Itoa(spec.RemotePort))
case *model.HTTPOutConf:
values = append(values, spec.CustomDomains...)
values = append(values, spec.SubDomain)
case *model.HTTPSOutConf:
values = append(values, spec.CustomDomains...)
values = append(values, spec.SubDomain)
case *model.TCPMuxOutConf:
values = append(values, spec.CustomDomains...)
values = append(values, spec.SubDomain)
}
return containsV2Query(q, values...)
}
func containsV2Query(q string, values ...string) bool {

View File

@@ -193,6 +193,86 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
}
}
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
tests := []struct {
name string
item model.V2ProxyResp
q string
want bool
}{
{
name: "tcp remote port",
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
RemotePort: 6000,
}},
q: "6000",
want: true,
},
{
name: "udp remote port",
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
RemotePort: 7000,
}},
q: "7000",
want: true,
},
{
name: "remote port does not match colon form",
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
RemotePort: 6000,
}},
q: ":6000",
want: false,
},
{
name: "http custom domain",
item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}},
}},
q: "app.example.com",
want: true,
},
{
name: "https subdomain",
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
}},
q: "portal",
want: true,
},
{
name: "subdomain does not match expanded host",
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
}},
q: "portal.example.com",
want: false,
},
{
name: "tcpmux custom domain",
item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}},
}},
q: "mux.example.com",
want: true,
},
{
name: "nil spec does not match spec fields",
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
q: "6000",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := matchV2ProxyQuery(tt.item, tt.q); got != tt.want {
t.Fatalf("matchV2ProxyQuery() = %v, want %v", got, tt.want)
}
})
}
}
func TestLegacyAPIResponsesRemainBare(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)

View File

@@ -1,10 +1,26 @@
import { http } from './http'
import type { ClientInfoData } from '../types/client'
import { buildQueryString, http } from './http'
import type { V2Page } from './http'
import type { ClientInfoData, ClientListV2Params } from '../types/client'
export const getClients = () => {
return http.get<ClientInfoData[]>('../api/clients')
}
export const getClientsV2 = (params: ClientListV2Params = {}) => {
return http.getV2<V2Page<ClientInfoData>>(
`../api/v2/clients${buildQueryString({
page: params.page,
pageSize: params.pageSize,
status:
params.status && params.status !== 'all' ? params.status : undefined,
q: params.q || undefined,
user: params.user,
clientID: params.clientID || undefined,
runID: params.runID || undefined,
})}`,
)
}
export const getClient = (key: string) => {
return http.get<ClientInfoData>(`../api/clients/${key}`)
}

View File

@@ -11,6 +11,21 @@ class HTTPError extends Error {
}
}
export interface V2Envelope<T> {
code: number
msg: string
data: T
}
export interface V2Page<T> {
total: number
page: number
pageSize: number
items: T[]
}
type QueryParamValue = string | number | boolean | null | undefined
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const defaultOptions: RequestInit = {
credentials: 'include',
@@ -34,9 +49,55 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
return response.json()
}
async function requestV2<T>(
url: string,
options: RequestInit = {},
): Promise<T> {
const defaultOptions: RequestInit = {
credentials: 'include',
}
const response = await fetch(url, { ...defaultOptions, ...options })
const envelope = (await response.json().catch(() => null)) as
| V2Envelope<T>
| null
if (!response.ok) {
throw new HTTPError(
response.status,
response.statusText,
envelope?.msg || `HTTP ${response.status}`,
)
}
if (!envelope || typeof envelope.code !== 'number') {
throw new Error('Invalid API v2 response')
}
if (envelope.code >= 400) {
throw new HTTPError(envelope.code, envelope.msg, envelope.msg)
}
return envelope.data
}
export const buildQueryString = (
params: Record<string, QueryParamValue>,
): string => {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined) continue
query.append(key, String(value))
}
const text = query.toString()
return text ? `?${text}` : ''
}
export const http = {
get: <T>(url: string, options?: RequestInit) =>
request<T>(url, { ...options, method: 'GET' }),
getV2: <T>(url: string, options?: RequestInit) =>
requestV2<T>(url, { ...options, method: 'GET' }),
post: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, {
...options,

View File

@@ -1,7 +1,10 @@
import { http } from './http'
import { buildQueryString, http } from './http'
import type { V2Page } from './http'
import type {
GetProxyResponse,
ProxyListV2Params,
ProxyStatsInfo,
ProxyV2Info,
TrafficResponse,
} from '../types/proxy'
@@ -9,6 +12,40 @@ export const getProxiesByType = (type: string) => {
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
}
export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
const page = await http.getV2<V2Page<ProxyV2Info>>(
`../api/v2/proxies${buildQueryString({
page: params.page,
pageSize: params.pageSize,
status:
params.status && params.status !== 'all' ? params.status : undefined,
q: params.q || undefined,
type: params.type || undefined,
user: params.user,
clientID: params.clientID || undefined,
})}`,
)
return {
...page,
items: page.items.map(toLegacyProxyStats),
}
}
const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({
name: proxy.name,
type: proxy.type,
conf: proxy.spec,
user: proxy.user,
clientID: proxy.clientID,
todayTrafficIn: proxy.status.todayTrafficIn,
todayTrafficOut: proxy.status.todayTrafficOut,
curConns: proxy.status.curConns,
lastStartTime: proxy.status.lastStartTime,
lastCloseTime: proxy.status.lastCloseTime,
status: proxy.status.phase,
})
export const getProxy = (type: string, name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
}

View File

@@ -13,3 +13,13 @@ export interface ClientInfoData {
disconnectedAt?: number
online: boolean
}
export interface ClientListV2Params {
page?: number
pageSize?: number
status?: 'all' | 'online' | 'offline'
q?: string
user?: string
clientID?: string
runID?: string
}

View File

@@ -1,5 +1,6 @@
export interface ProxyStatsInfo {
name: string
type?: string
conf: any
user: string
clientID: string
@@ -15,6 +16,34 @@ export interface GetProxyResponse {
proxies: ProxyStatsInfo[]
}
export interface ProxyListV2Params {
page?: number
pageSize?: number
status?: 'all' | 'online' | 'offline'
q?: string
type?: string
user?: string
clientID?: string
}
export interface ProxyV2Info {
name: string
type: string
user: string
clientID: string
spec: any
status: ProxyV2Status
}
export interface ProxyV2Status {
phase: string
todayTrafficIn: number
todayTrafficOut: number
curConns: number
lastStartTime: string
lastCloseTime: string
}
export interface TrafficResponse {
name: string
trafficIn: number[]

View File

@@ -16,7 +16,9 @@
>
<span class="status-dot" :class="tab.value"></span>
<span class="tab-label">{{ tab.label }}</span>
<span class="tab-count">{{ tab.count }}</span>
<span v-if="tab.count !== null" class="tab-count">{{
tab.count
}}</span>
</button>
</div>
</div>
@@ -33,9 +35,9 @@
</div>
<div v-loading="loading" class="clients-content">
<div v-if="filteredClients.length > 0" class="clients-list">
<div v-if="clients.length > 0" class="clients-list">
<ClientCard
v-for="client in filteredClients"
v-for="client in clients"
:key="client.key"
:client="client"
/>
@@ -44,82 +46,123 @@
<el-empty description="No clients found" />
</div>
</div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ElMessage, ElPagination } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { Client } from '../utils/client'
import ClientCard from '../components/ClientCard.vue'
import { getClients } from '../api/client'
import { getClientsV2 } from '../api/client'
const clients = ref<Client[]>([])
const loading = ref(false)
const searchText = ref('')
const statusFilter = ref<'all' | 'online' | 'offline'>('all')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
let refreshTimer: number | null = null
const stats = computed(() => {
const total = clients.value.length
const online = clients.value.filter((c) => c.online).length
const offline = total - online
return { total, online, offline }
})
let searchDebounceTimer: number | null = null
let requestSeq = 0
const statusTabs = computed(() => [
{ value: 'all' as const, label: 'All', count: stats.value.total },
{ value: 'online' as const, label: 'Online', count: stats.value.online },
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline },
{
value: 'all' as const,
label: 'All',
count: statusFilter.value === 'all' ? total.value : null,
},
{
value: 'online' as const,
label: 'Online',
count: statusFilter.value === 'online' ? total.value : null,
},
{
value: 'offline' as const,
label: 'Offline',
count: statusFilter.value === 'offline' ? total.value : null,
},
])
const filteredClients = computed(() => {
let result = clients.value
// Filter by status
if (statusFilter.value === 'online') {
result = result.filter((c) => c.online)
} else if (statusFilter.value === 'offline') {
result = result.filter((c) => !c.online)
}
// Filter by search text
if (searchText.value) {
result = result.filter((c) => c.matchesFilter(searchText.value))
}
// Sort: online first, then by display name
result.sort((a, b) => {
if (a.online !== b.online) {
return a.online ? -1 : 1
}
return a.displayName.localeCompare(b.displayName)
})
return result
})
const fetchData = async () => {
loading.value = true
const fetchData = async (silent = false) => {
const seq = ++requestSeq
if (!silent) loading.value = true
try {
const json = await getClients()
clients.value = json.map((data) => new Client(data))
const data = await getClientsV2({
page: page.value,
pageSize: pageSize.value,
status: statusFilter.value,
q: searchText.value.trim(),
})
if (seq !== requestSeq) return
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
page.value = maxPage
await fetchData(silent)
return
}
clients.value = data.items.map((item) => new Client(item))
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch (error: any) {
if (seq !== requestSeq) return
ElMessage({
showClose: true,
message: 'Failed to fetch clients: ' + error.message,
type: 'error',
})
} finally {
if (seq === requestSeq) {
loading.value = false
}
}
}
const clearSearchDebounce = () => {
if (searchDebounceTimer !== null) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const resetPageAndFetch = () => {
clearSearchDebounce()
page.value = 1
fetchData()
}
const onPageChange = (value: number) => {
clearSearchDebounce()
page.value = value
fetchData()
}
const onPageSizeChange = (value: number) => {
pageSize.value = value
resetPageAndFetch()
}
const startAutoRefresh = () => {
refreshTimer = window.setInterval(() => {
fetchData()
fetchData(true)
}, 5000)
}
@@ -130,6 +173,19 @@ const stopAutoRefresh = () => {
}
}
watch(statusFilter, () => {
resetPageAndFetch()
})
watch(searchText, () => {
clearSearchDebounce()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchData()
}, 300)
})
onMounted(() => {
fetchData()
startAutoRefresh()
@@ -137,6 +193,7 @@ onMounted(() => {
onUnmounted(() => {
stopAutoRefresh()
clearSearchDebounce()
})
</script>
@@ -274,6 +331,11 @@ onUnmounted(() => {
padding: 60px 0;
}
.pagination-section {
display: flex;
justify-content: flex-end;
}
/* Dark mode adjustments */
html.dark .status-tab {
background: var(--el-bg-color-overlay);
@@ -298,5 +360,9 @@ html.dark .status-tab.active {
.status-tab {
flex-shrink: 0;
}
.pagination-section {
justify-content: center;
}
}
</style>

View File

@@ -8,7 +8,7 @@
</div>
<div class="actions-section">
<ActionButton variant="outline" size="small" @click="fetchData">
<ActionButton variant="outline" size="small" @click="refreshData">
Refresh
</ActionButton>
@@ -74,9 +74,9 @@
</div>
<div v-loading="loading" class="proxies-content">
<div v-if="filteredProxies.length > 0" class="proxies-list">
<div v-if="proxies.length > 0" class="proxies-list">
<ProxyCard
v-for="proxy in filteredProxies"
v-for="proxy in proxies"
:key="`${proxy.type}:${proxy.name}`"
:proxy="proxy"
:show-type="activeType === 'all'"
@@ -87,6 +87,18 @@
</div>
</div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
<ConfirmDialog
v-model="showClearDialog"
title="Clear Offline"
@@ -99,9 +111,9 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElPagination } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import ActionButton from '@shared/components/ActionButton.vue'
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
@@ -119,12 +131,13 @@ import ProxyCard from '../components/ProxyCard.vue'
import PopoverMenu from '@shared/components/PopoverMenu.vue'
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
import {
getProxiesByType,
getProxiesV2,
clearOfflineProxies as apiClearOfflineProxies,
} from '../api/proxy'
import { getServerInfo } from '../api/server'
import { getClients } from '../api/client'
import { getClientsV2 } from '../api/client'
import { Client } from '../utils/client'
import type { ProxyStatsInfo } from '../types/proxy'
const route = useRoute()
const router = useRouter()
@@ -149,6 +162,12 @@ const searchText = ref('')
const showClearDialog = ref(false)
const clientIDFilter = ref((route.query.clientID as string) || '')
const userFilter = ref((route.query.user as string) || '')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const maxV2PageSize = 100
let requestSeq = 0
let searchDebounceTimer: number | null = null
const clientOptions = computed(() => {
return clients.value
@@ -193,58 +212,6 @@ const selectedClientInList = computed(() => {
)
})
const filteredProxies = computed(() => {
let result = proxies.value
// Filter by clientID and user if specified
if (clientIDFilter.value) {
result = result.filter(
(p) => p.clientID === clientIDFilter.value && p.user === userFilter.value,
)
}
// Filter by search text across multiple fields
if (searchText.value) {
const search = searchText.value.toLowerCase()
result = result.filter((p) => {
const fields: unknown[] = [
p.name,
p.type,
p.clientID,
p.user,
p.addr,
p.port,
p.customDomains,
p.subdomain,
]
return fields.some((v) => matchesSearch(v, search))
})
}
return result
})
// Normalize a field of unknown shape (string / number / array / null) to a
// lowercase string for case-insensitive substring matching. Arrays are joined
// so e.g. customDomains: ["A.com","B.com"] is searchable as one blob.
const matchesSearch = (value: unknown, needle: string): boolean => {
if (value === null || value === undefined) return false
let str: string
if (Array.isArray(value)) {
str = value
.filter((v) => v !== null && v !== undefined)
.map((v) => String(v))
.join(' ')
} else if (typeof value === 'number') {
if (value === 0) return false
str = String(value)
} else {
str = String(value)
}
if (!str) return false
return str.toLowerCase().includes(needle)
}
const onClientFilterChange = (key: string) => {
if (key) {
const client = clientOptions.value.find((c) => c.key === key)
@@ -263,120 +230,172 @@ const onClientFilterChange = (key: string) => {
const fetchClients = async () => {
try {
const json = await getClients()
clients.value = json.map((data) => new Client(data))
} catch {
// Ignore errors when fetching clients
const allClients: Client[] = []
let nextPage = 1
let totalClients = 0
do {
const data = await getClientsV2({
page: nextPage,
pageSize: maxV2PageSize,
})
allClients.push(...data.items.map((item) => new Client(item)))
totalClients = data.total
nextPage += 1
} while (allClients.length < totalClients)
clients.value = allClients
} catch (err) {
// Client dropdown is a non-critical side load; log for diagnostics
// but don't surface a toast (would compete with the main fetch error).
console.warn('Failed to fetch clients for filter:', err)
}
}
// Server info cache
let serverInfo: {
// Server info cache - cache the Promise itself so concurrent first calls
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
type ServerInfoLite = {
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
}
let serverInfoPromise: Promise<ServerInfoLite> | null = null
const fetchServerInfo = async () => {
if (serverInfo) return serverInfo
const res = await getServerInfo()
serverInfo = res
return serverInfo
const fetchServerInfo = (): Promise<ServerInfoLite> => {
if (!serverInfoPromise) {
serverInfoPromise = getServerInfo().catch((err) => {
// Allow retry after failure
serverInfoPromise = null
throw err
})
}
return serverInfoPromise
}
const convertProxies = async (
type: string,
json: any,
): Promise<BaseProxy[]> => {
const convertProxy = async (
proxy: ProxyStatsInfo,
): Promise<BaseProxy | null> => {
const type = proxy.type || activeType.value
if (type === 'tcp') {
return json.proxies.map((p: any) => new TCPProxy(p))
return new TCPProxy(proxy)
}
if (type === 'udp') {
return json.proxies.map((p: any) => new UDPProxy(p))
return new UDPProxy(proxy)
}
if (type === 'http') {
const info = await fetchServerInfo()
if (info && info.vhostHTTPPort) {
return json.proxies.map(
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
)
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
}
return []
return null
}
if (type === 'https') {
const info = await fetchServerInfo()
if (info && info.vhostHTTPSPort) {
return json.proxies.map(
(p: any) => new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
)
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
}
return []
return null
}
if (type === 'tcpmux') {
const info = await fetchServerInfo()
if (info && info.tcpmuxHTTPConnectPort) {
return json.proxies.map(
(p: any) =>
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
return new TCPMuxProxy(
proxy,
info.tcpmuxHTTPConnectPort,
info.subdomainHost,
)
}
return []
return null
}
if (type === 'stcp') {
return json.proxies.map((p: any) => new STCPProxy(p))
return new STCPProxy(proxy)
}
if (type === 'sudp') {
return json.proxies.map((p: any) => new SUDPProxy(p))
return new SUDPProxy(proxy)
}
// Fallback for types without a dedicated class (e.g. xtcp). Matches the
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
return json.proxies.map((p: any) => {
const bp = new BaseProxy(p)
const bp = new BaseProxy(proxy)
bp.type = type
return bp
})
}
const allProxyTypes = [
'tcp',
'udp',
'http',
'https',
'tcpmux',
'stcp',
'xtcp',
'sudp',
]
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
const converted = await Promise.all(items.map((item) => convertProxy(item)))
return converted.filter((item): item is BaseProxy => item !== null)
}
const fetchData = async () => {
loading.value = true
proxies.value = []
const fetchData = async (silent = false) => {
const seq = ++requestSeq
if (!silent) loading.value = true
try {
const type = activeType.value
const q = searchText.value.trim()
const data = await getProxiesV2({
page: page.value,
pageSize: pageSize.value,
type: activeType.value === 'all' ? undefined : activeType.value,
q: q || undefined,
clientID: clientIDFilter.value || undefined,
user: clientIDFilter.value ? userFilter.value : undefined,
})
if (seq !== requestSeq) return
if (type === 'all') {
const results = await Promise.all(
allProxyTypes.map(async (t) => {
const json = await getProxiesByType(t)
return convertProxies(t, json)
}),
)
proxies.value = results.flat()
} else {
const json = await getProxiesByType(type)
proxies.value = await convertProxies(type, json)
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
page.value = maxPage
await fetchData(silent)
return
}
const converted = await convertProxies(data.items)
if (seq !== requestSeq) return
proxies.value = converted
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch (error: any) {
if (seq !== requestSeq) return
ElMessage({
showClose: true,
message: 'Failed to fetch proxies: ' + error.message,
type: 'error',
})
} finally {
if (seq === requestSeq) {
loading.value = false
}
}
}
const clearSearchDebounce = () => {
if (searchDebounceTimer !== null) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const resetPageAndFetch = () => {
clearSearchDebounce()
page.value = 1
fetchData()
}
const refreshData = () => {
fetchData()
}
const onPageChange = (value: number) => {
clearSearchDebounce()
page.value = value
fetchData()
}
const onPageSizeChange = (value: number) => {
pageSize.value = value
resetPageAndFetch()
}
const handleClearConfirm = async () => {
@@ -402,20 +421,36 @@ const clearOfflineProxies = async () => {
// Watch for type changes
watch(activeType, (newType) => {
clearSearchDebounce()
page.value = 1
// Update route but preserve query params
router.replace({ params: { type: newType }, query: route.query })
fetchData()
})
watch(searchText, () => {
clearSearchDebounce()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchData()
}, 300)
})
// Watch for route query changes (client filter)
watch(
() => [route.query.clientID, route.query.user],
([newClientID, newUser]) => {
clientIDFilter.value = (newClientID as string) || ''
userFilter.value = (newUser as string) || ''
resetPageAndFetch()
},
)
onUnmounted(() => {
clearSearchDebounce()
})
// Initial fetch
fetchData()
fetchClients()
@@ -539,6 +574,11 @@ fetchClients()
padding: 60px 0;
}
.pagination-section {
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) {
.search-row {
flex-direction: column;
@@ -547,5 +587,9 @@ fetchClients()
.client-filter {
width: 100%;
}
.pagination-section {
justify-content: center;
}
}
</style>