Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
0c660f83
Commit
0c660f83
authored
Feb 05, 2026
by
LLLLLLiulei
Browse files
feat: refine proxy export and toolbar layout
parent
ce9a247a
Changes
9
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/admin_service_stub_test.go
View file @
0c660f83
...
@@ -259,6 +259,12 @@ func (s *stubAdminService) GetAllProxiesWithAccountCount(ctx context.Context) ([
...
@@ -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
}
}
...
...
backend/internal/handler/admin/proxy_data.go
View file @
0c660f83
...
@@ -2,6 +2,8 @@ package admin
...
@@ -2,6 +2,8 @@ package admin
import
(
import
(
"context"
"context"
"fmt"
"strconv"
"strings"
"strings"
"time"
"time"
...
@@ -14,6 +16,20 @@ import (
...
@@ -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) {
...
@@ -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) {
...
@@ -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
...
...
backend/internal/handler/admin/proxy_data_handler_test.go
View file @
0c660f83
...
@@ -75,6 +75,45 @@ func TestProxyExportDataRespectsFilters(t *testing.T) {
...
@@ -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
()
...
...
frontend/src/api/admin/proxies.ts
View file @
0c660f83
...
@@ -210,14 +210,24 @@ export async function batchDelete(ids: number[]): Promise<{
...
@@ -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
}
}
...
...
frontend/src/components/admin/account/AccountTableActions.vue
View file @
0c660f83
...
@@ -6,6 +6,7 @@
...
@@ -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>
...
...
frontend/src/i18n/locales/en.ts
View file @
0c660f83
...
@@ -1903,6 +1903,7 @@ export default {
...
@@ -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.
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
0c660f83
...
@@ -2012,6 +2012,7 @@ export default {
...
@@ -2012,6 +2012,7 @@ export default {
deleteConfirmMessage
:
"
确定要删除代理 '{name}' 吗?
"
,
deleteConfirmMessage
:
"
确定要删除代理 '{name}' 吗?
"
,
testProxy
:
'
测试代理
'
,
testProxy
:
'
测试代理
'
,
dataImport
:
'
导入
'
,
dataImport
:
'
导入
'
,
dataExportSelected
:
'
导出选中
'
,
dataImportTitle
:
'
导入代理
'
,
dataImportTitle
:
'
导入代理
'
,
dataImportHint
:
'
上传代理导出的 JSON 文件以批量导入代理。
'
,
dataImportHint
:
'
上传代理导出的 JSON 文件以批量导入代理。
'
,
dataImportWarning
:
'
导入将创建或复用代理,保留状态并在完成后自动触发延迟检测。
'
,
dataImportWarning
:
'
导入将创建或复用代理,保留状态并在完成后自动触发延迟检测。
'
,
...
...
frontend/src/views/admin/AccountsView.vue
View file @
0c660f83
...
@@ -96,7 +96,7 @@
...
@@ -96,7 +96,7 @@
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/template
>
<
/template
>
<
template
#
after
Create
>
<
template
#
before
Create
>
<
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
>
...
...
frontend/src/views/admin/ProxiesView.vue
View file @
0c660f83
...
@@ -2,47 +2,9 @@
...
@@ -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 @@
...
@@ -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 () => {
...
@@ -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
'
}
)
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment