feat: refine proxy export and toolbar layout
This commit is contained in:
@@ -259,6 +259,12 @@ func (s *stubAdminService) GetAllProxiesWithAccountCount(ctx context.Context) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubAdminService) GetProxy(ctx context.Context, id int64) (*service.Proxy, error) {
|
func (s *stubAdminService) GetProxy(ctx context.Context, id int64) (*service.Proxy, error) {
|
||||||
|
for i := range s.proxies {
|
||||||
|
proxy := s.proxies[i]
|
||||||
|
if proxy.ID == id {
|
||||||
|
return &proxy, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
proxy := service.Proxy{ID: id, Name: "proxy", Status: service.StatusActive}
|
proxy := service.Proxy{ID: id, Name: "proxy", Status: service.StatusActive}
|
||||||
return &proxy, nil
|
return &proxy, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,6 +16,20 @@ import (
|
|||||||
func (h *ProxyHandler) ExportData(c *gin.Context) {
|
func (h *ProxyHandler) ExportData(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
selectedIDs, err := parseProxyIDs(c)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxies []service.Proxy
|
||||||
|
if len(selectedIDs) > 0 {
|
||||||
|
proxies, err = h.getProxiesByIDs(ctx, selectedIDs)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
protocol := c.Query("protocol")
|
protocol := c.Query("protocol")
|
||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
search := strings.TrimSpace(c.Query("search"))
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
@@ -21,11 +37,12 @@ func (h *ProxyHandler) ExportData(c *gin.Context) {
|
|||||||
search = search[:100]
|
search = search[:100]
|
||||||
}
|
}
|
||||||
|
|
||||||
proxies, err := h.listProxiesFiltered(ctx, protocol, status, search)
|
proxies, err = h.listProxiesFiltered(ctx, protocol, status, search)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dataProxies := make([]DataProxy, 0, len(proxies))
|
dataProxies := make([]DataProxy, 0, len(proxies))
|
||||||
for i := range proxies {
|
for i := range proxies {
|
||||||
@@ -168,6 +185,50 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
|
|||||||
response.Success(c, result)
|
response.Success(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ProxyHandler) getProxiesByIDs(ctx context.Context, ids []int64) ([]service.Proxy, error) {
|
||||||
|
out := make([]service.Proxy, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
proxy, err := h.adminService.GetProxy(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if proxy == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, *proxy)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProxyIDs(c *gin.Context) ([]int64, error) {
|
||||||
|
values := c.QueryArray("ids")
|
||||||
|
if len(values) == 0 {
|
||||||
|
raw := strings.TrimSpace(c.Query("ids"))
|
||||||
|
if raw != "" {
|
||||||
|
values = []string{raw}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int64, 0, len(values))
|
||||||
|
for _, item := range values {
|
||||||
|
for _, part := range strings.Split(item, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(part, 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid proxy id: %s", part)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ProxyHandler) listProxiesFiltered(ctx context.Context, protocol, status, search string) ([]service.Proxy, error) {
|
func (h *ProxyHandler) listProxiesFiltered(ctx context.Context, protocol, status, search string) ([]service.Proxy, error) {
|
||||||
page := 1
|
page := 1
|
||||||
pageSize := dataPageCap
|
pageSize := dataPageCap
|
||||||
|
|||||||
@@ -75,6 +75,45 @@ func TestProxyExportDataRespectsFilters(t *testing.T) {
|
|||||||
require.Equal(t, "https", resp.Data.Proxies[0].Protocol)
|
require.Equal(t, "https", resp.Data.Proxies[0].Protocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProxyExportDataWithSelectedIDs(t *testing.T) {
|
||||||
|
router, adminSvc := setupProxyDataRouter()
|
||||||
|
|
||||||
|
adminSvc.proxies = []service.Proxy{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Name: "proxy-a",
|
||||||
|
Protocol: "http",
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: 8080,
|
||||||
|
Username: "user",
|
||||||
|
Password: "pass",
|
||||||
|
Status: service.StatusActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Name: "proxy-b",
|
||||||
|
Protocol: "https",
|
||||||
|
Host: "10.0.0.2",
|
||||||
|
Port: 443,
|
||||||
|
Username: "u",
|
||||||
|
Password: "p",
|
||||||
|
Status: service.StatusDisabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/proxies/data?ids=2", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
|
var resp proxyDataResponse
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
require.Equal(t, 0, resp.Code)
|
||||||
|
require.Len(t, resp.Data.Proxies, 1)
|
||||||
|
require.Equal(t, "https", resp.Data.Proxies[0].Protocol)
|
||||||
|
require.Equal(t, "10.0.0.2", resp.Data.Proxies[0].Host)
|
||||||
|
}
|
||||||
|
|
||||||
func TestProxyImportDataReusesAndTriggersLatencyProbe(t *testing.T) {
|
func TestProxyImportDataReusesAndTriggersLatencyProbe(t *testing.T) {
|
||||||
router, adminSvc := setupProxyDataRouter()
|
router, adminSvc := setupProxyDataRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -210,14 +210,24 @@ export async function batchDelete(ids: number[]): Promise<{
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportData(filters?: {
|
export async function exportData(options?: {
|
||||||
|
ids?: number[]
|
||||||
|
filters?: {
|
||||||
protocol?: string
|
protocol?: string
|
||||||
status?: 'active' | 'inactive'
|
status?: 'active' | 'inactive'
|
||||||
search?: string
|
search?: string
|
||||||
|
}
|
||||||
}): Promise<AdminDataPayload> {
|
}): Promise<AdminDataPayload> {
|
||||||
const { data } = await apiClient.get<AdminDataPayload>('/admin/proxies/data', {
|
const params: Record<string, string> = {}
|
||||||
params: filters
|
if (options?.ids && options.ids.length > 0) {
|
||||||
})
|
params.ids = options.ids.join(',')
|
||||||
|
} else if (options?.filters) {
|
||||||
|
const { protocol, status, search } = options.filters
|
||||||
|
if (protocol) params.protocol = protocol
|
||||||
|
if (status) params.status = status
|
||||||
|
if (search) params.search = search
|
||||||
|
}
|
||||||
|
const { data } = await apiClient.get<AdminDataPayload>('/admin/proxies/data', { params })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<slot name="after"></slot>
|
<slot name="after"></slot>
|
||||||
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
|
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
|
||||||
|
<slot name="beforeCreate"></slot>
|
||||||
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
|
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
|
||||||
<slot name="afterCreate"></slot>
|
<slot name="afterCreate"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1903,6 +1903,7 @@ export default {
|
|||||||
editProxy: 'Edit Proxy',
|
editProxy: 'Edit Proxy',
|
||||||
deleteProxy: 'Delete Proxy',
|
deleteProxy: 'Delete Proxy',
|
||||||
dataImport: 'Import',
|
dataImport: 'Import',
|
||||||
|
dataExportSelected: 'Export Selected',
|
||||||
dataImportTitle: 'Import Proxies',
|
dataImportTitle: 'Import Proxies',
|
||||||
dataImportHint: 'Upload the exported proxy JSON file to import proxies in bulk.',
|
dataImportHint: 'Upload the exported proxy JSON file to import proxies in bulk.',
|
||||||
dataImportWarning: 'Import will create or reuse proxies, keep their status, and trigger latency checks after completion.',
|
dataImportWarning: 'Import will create or reuse proxies, keep their status, and trigger latency checks after completion.',
|
||||||
|
|||||||
@@ -2012,6 +2012,7 @@ export default {
|
|||||||
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
|
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
|
||||||
testProxy: '测试代理',
|
testProxy: '测试代理',
|
||||||
dataImport: '导入',
|
dataImport: '导入',
|
||||||
|
dataExportSelected: '导出选中',
|
||||||
dataImportTitle: '导入代理',
|
dataImportTitle: '导入代理',
|
||||||
dataImportHint: '上传代理导出的 JSON 文件以批量导入代理。',
|
dataImportHint: '上传代理导出的 JSON 文件以批量导入代理。',
|
||||||
dataImportWarning: '导入将创建或复用代理,保留状态并在完成后自动触发延迟检测。',
|
dataImportWarning: '导入将创建或复用代理,保留状态并在完成后自动触发延迟检测。',
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #afterCreate>
|
<template #beforeCreate>
|
||||||
<button @click="showImportData = true" class="btn btn-secondary">
|
<button @click="showImportData = true" class="btn btn-secondary">
|
||||||
{{ t('admin.accounts.dataImport') }}
|
{{ t('admin.accounts.dataImport') }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,47 +2,9 @@
|
|||||||
<AppLayout>
|
<AppLayout>
|
||||||
<TablePageLayout>
|
<TablePageLayout>
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<!-- Top Toolbar: Left (search + filters) / Right (actions) -->
|
<div class="space-y-3">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<!-- Row 1: Actions -->
|
||||||
<!-- Left: Fuzzy search + filters (wrap to multiple lines) -->
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<div class="flex flex-1 flex-wrap items-center gap-3">
|
|
||||||
<!-- Search -->
|
|
||||||
<div class="relative w-full sm:w-64">
|
|
||||||
<Icon
|
|
||||||
name="search"
|
|
||||||
size="md"
|
|
||||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
type="text"
|
|
||||||
:placeholder="t('admin.proxies.searchProxies')"
|
|
||||||
class="input pl-10"
|
|
||||||
@input="handleSearch"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
|
||||||
<div class="w-full sm:w-40">
|
|
||||||
<Select
|
|
||||||
v-model="filters.protocol"
|
|
||||||
:options="protocolOptions"
|
|
||||||
:placeholder="t('admin.proxies.allProtocols')"
|
|
||||||
@change="loadProxies"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="w-full sm:w-36">
|
|
||||||
<Select
|
|
||||||
v-model="filters.status"
|
|
||||||
:options="statusOptions"
|
|
||||||
:placeholder="t('admin.proxies.allStatus')"
|
|
||||||
@change="loadProxies"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Actions -->
|
|
||||||
<div class="ml-auto flex flex-wrap items-center justify-end gap-3">
|
|
||||||
<button
|
<button
|
||||||
@click="loadProxies"
|
@click="loadProxies"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@@ -73,13 +35,48 @@
|
|||||||
{{ t('admin.proxies.dataImport') }}
|
{{ t('admin.proxies.dataImport') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="showExportDataDialog = true" class="btn btn-secondary">
|
<button @click="showExportDataDialog = true" class="btn btn-secondary">
|
||||||
{{ t('admin.proxies.dataExport') }}
|
{{ selectedCount > 0 ? t('admin.proxies.dataExportSelected') : t('admin.proxies.dataExport') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||||
<Icon name="plus" size="md" class="mr-2" />
|
<Icon name="plus" size="md" class="mr-2" />
|
||||||
{{ t('admin.proxies.createProxy') }}
|
{{ t('admin.proxies.createProxy') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Search + Filters -->
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="relative w-full sm:w-64">
|
||||||
|
<Icon
|
||||||
|
name="search"
|
||||||
|
size="md"
|
||||||
|
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('admin.proxies.searchProxies')"
|
||||||
|
class="input pl-10"
|
||||||
|
@input="handleSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full sm:w-40">
|
||||||
|
<Select
|
||||||
|
v-model="filters.protocol"
|
||||||
|
:options="protocolOptions"
|
||||||
|
:placeholder="t('admin.proxies.allProtocols')"
|
||||||
|
@change="loadProxies"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-full sm:w-36">
|
||||||
|
<Select
|
||||||
|
v-model="filters.status"
|
||||||
|
:options="statusOptions"
|
||||||
|
:placeholder="t('admin.proxies.allStatus')"
|
||||||
|
@change="loadProxies"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1268,11 +1265,17 @@ const handleExportData = async () => {
|
|||||||
if (exportingData.value) return
|
if (exportingData.value) return
|
||||||
exportingData.value = true
|
exportingData.value = true
|
||||||
try {
|
try {
|
||||||
const dataPayload = await adminAPI.proxies.exportData({
|
const dataPayload = await adminAPI.proxies.exportData(
|
||||||
|
selectedCount.value > 0
|
||||||
|
? { ids: Array.from(selectedProxyIds.value) }
|
||||||
|
: {
|
||||||
|
filters: {
|
||||||
protocol: filters.protocol || undefined,
|
protocol: filters.protocol || undefined,
|
||||||
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
|
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
|
||||||
search: searchQuery.value || undefined
|
search: searchQuery.value || undefined
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
const timestamp = formatExportTimestamp()
|
const timestamp = formatExportTimestamp()
|
||||||
const filename = `sub2api-proxy-${timestamp}.json`
|
const filename = `sub2api-proxy-${timestamp}.json`
|
||||||
const blob = new Blob([JSON.stringify(dataPayload, null, 2)], { type: 'application/json' })
|
const blob = new Blob([JSON.stringify(dataPayload, null, 2)], { type: 'application/json' })
|
||||||
|
|||||||
Reference in New Issue
Block a user