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
ce9a247a
"backend/vscode:/vscode.git/clone" did not exist on "e4d74ae11dd494512ed7d08858429854034aa3f6"
Commit
ce9a247a
authored
Feb 05, 2026
by
LLLLLLiulei
Browse files
feat: add proxy import flow
parent
b4bd46d0
Changes
13
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/account_data.go
View file @
ce9a247a
...
...
@@ -469,6 +469,13 @@ func validateDataProxy(item DataProxy) error {
default
:
return
fmt
.
Errorf
(
"proxy protocol is invalid: %s"
,
item
.
Protocol
)
}
if
item
.
Status
!=
""
{
switch
item
.
Status
{
case
service
.
StatusActive
,
service
.
StatusDisabled
,
"inactive"
:
default
:
return
fmt
.
Errorf
(
"proxy status is invalid: %s"
,
item
.
Status
)
}
}
return
nil
}
...
...
backend/internal/handler/admin/admin_service_stub_test.go
View file @
ce9a247a
...
...
@@ -3,6 +3,7 @@ package admin
import
(
"context"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
...
...
@@ -18,6 +19,10 @@ type stubAdminService struct {
redeems
[]
service
.
RedeemCode
createdAccounts
[]
*
service
.
CreateAccountInput
createdProxies
[]
*
service
.
CreateProxyInput
updatedProxyIDs
[]
int64
updatedProxies
[]
*
service
.
UpdateProxyInput
testedProxyIDs
[]
int64
mu
sync
.
Mutex
}
func
newStubAdminService
()
*
stubAdminService
{
...
...
@@ -180,7 +185,9 @@ func (s *stubAdminService) GetAccountsByIDs(ctx context.Context, ids []int64) ([
}
func
(
s
*
stubAdminService
)
CreateAccount
(
ctx
context
.
Context
,
input
*
service
.
CreateAccountInput
)
(
*
service
.
Account
,
error
)
{
s
.
mu
.
Lock
()
s
.
createdAccounts
=
append
(
s
.
createdAccounts
,
input
)
s
.
mu
.
Unlock
()
account
:=
service
.
Account
{
ID
:
300
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
account
,
nil
}
...
...
@@ -257,12 +264,18 @@ func (s *stubAdminService) GetProxy(ctx context.Context, id int64) (*service.Pro
}
func
(
s
*
stubAdminService
)
CreateProxy
(
ctx
context
.
Context
,
input
*
service
.
CreateProxyInput
)
(
*
service
.
Proxy
,
error
)
{
s
.
mu
.
Lock
()
s
.
createdProxies
=
append
(
s
.
createdProxies
,
input
)
s
.
mu
.
Unlock
()
proxy
:=
service
.
Proxy
{
ID
:
400
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
proxy
,
nil
}
func
(
s
*
stubAdminService
)
UpdateProxy
(
ctx
context
.
Context
,
id
int64
,
input
*
service
.
UpdateProxyInput
)
(
*
service
.
Proxy
,
error
)
{
s
.
mu
.
Lock
()
s
.
updatedProxyIDs
=
append
(
s
.
updatedProxyIDs
,
id
)
s
.
updatedProxies
=
append
(
s
.
updatedProxies
,
input
)
s
.
mu
.
Unlock
()
proxy
:=
service
.
Proxy
{
ID
:
id
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
proxy
,
nil
}
...
...
@@ -284,6 +297,9 @@ func (s *stubAdminService) CheckProxyExists(ctx context.Context, host string, po
}
func
(
s
*
stubAdminService
)
TestProxy
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
ProxyTestResult
,
error
)
{
s
.
mu
.
Lock
()
s
.
testedProxyIDs
=
append
(
s
.
testedProxyIDs
,
id
)
s
.
mu
.
Unlock
()
return
&
service
.
ProxyTestResult
{
Success
:
true
,
Message
:
"ok"
},
nil
}
...
...
backend/internal/handler/admin/proxy_data.go
View file @
ce9a247a
...
...
@@ -54,6 +54,120 @@ func (h *ProxyHandler) ExportData(c *gin.Context) {
response
.
Success
(
c
,
payload
)
}
// ImportData imports proxy-only data for migration.
func
(
h
*
ProxyHandler
)
ImportData
(
c
*
gin
.
Context
)
{
type
ProxyImportRequest
struct
{
Data
DataPayload
`json:"data"`
}
var
req
ProxyImportRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
if
err
:=
validateDataHeader
(
req
.
Data
);
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
ctx
:=
c
.
Request
.
Context
()
result
:=
DataImportResult
{}
existingProxies
,
err
:=
h
.
listProxiesFiltered
(
ctx
,
""
,
""
,
""
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
proxyByKey
:=
make
(
map
[
string
]
service
.
Proxy
,
len
(
existingProxies
))
for
i
:=
range
existingProxies
{
p
:=
existingProxies
[
i
]
key
:=
buildProxyKey
(
p
.
Protocol
,
p
.
Host
,
p
.
Port
,
p
.
Username
,
p
.
Password
)
proxyByKey
[
key
]
=
p
}
latencyProbeIDs
:=
make
([]
int64
,
0
,
len
(
req
.
Data
.
Proxies
))
for
i
:=
range
req
.
Data
.
Proxies
{
item
:=
req
.
Data
.
Proxies
[
i
]
key
:=
item
.
ProxyKey
if
key
==
""
{
key
=
buildProxyKey
(
item
.
Protocol
,
item
.
Host
,
item
.
Port
,
item
.
Username
,
item
.
Password
)
}
if
err
:=
validateDataProxy
(
item
);
err
!=
nil
{
result
.
ProxyFailed
++
result
.
Errors
=
append
(
result
.
Errors
,
DataImportError
{
Kind
:
"proxy"
,
Name
:
item
.
Name
,
ProxyKey
:
key
,
Message
:
err
.
Error
(),
})
continue
}
if
existing
,
ok
:=
proxyByKey
[
key
];
ok
{
result
.
ProxyReused
++
if
item
.
Status
!=
""
&&
item
.
Status
!=
existing
.
Status
{
if
_
,
err
:=
h
.
adminService
.
UpdateProxy
(
ctx
,
existing
.
ID
,
&
service
.
UpdateProxyInput
{
Status
:
item
.
Status
});
err
!=
nil
{
result
.
Errors
=
append
(
result
.
Errors
,
DataImportError
{
Kind
:
"proxy"
,
Name
:
item
.
Name
,
ProxyKey
:
key
,
Message
:
"update status failed: "
+
err
.
Error
(),
})
}
}
latencyProbeIDs
=
append
(
latencyProbeIDs
,
existing
.
ID
)
continue
}
created
,
err
:=
h
.
adminService
.
CreateProxy
(
ctx
,
&
service
.
CreateProxyInput
{
Name
:
defaultProxyName
(
item
.
Name
),
Protocol
:
item
.
Protocol
,
Host
:
item
.
Host
,
Port
:
item
.
Port
,
Username
:
item
.
Username
,
Password
:
item
.
Password
,
})
if
err
!=
nil
{
result
.
ProxyFailed
++
result
.
Errors
=
append
(
result
.
Errors
,
DataImportError
{
Kind
:
"proxy"
,
Name
:
item
.
Name
,
ProxyKey
:
key
,
Message
:
err
.
Error
(),
})
continue
}
result
.
ProxyCreated
++
proxyByKey
[
key
]
=
*
created
if
item
.
Status
!=
""
&&
item
.
Status
!=
created
.
Status
{
if
_
,
err
:=
h
.
adminService
.
UpdateProxy
(
ctx
,
created
.
ID
,
&
service
.
UpdateProxyInput
{
Status
:
item
.
Status
});
err
!=
nil
{
result
.
Errors
=
append
(
result
.
Errors
,
DataImportError
{
Kind
:
"proxy"
,
Name
:
item
.
Name
,
ProxyKey
:
key
,
Message
:
"update status failed: "
+
err
.
Error
(),
})
}
}
latencyProbeIDs
=
append
(
latencyProbeIDs
,
created
.
ID
)
}
if
len
(
latencyProbeIDs
)
>
0
{
ids
:=
append
([]
int64
(
nil
),
latencyProbeIDs
...
)
go
func
()
{
for
_
,
id
:=
range
ids
{
_
,
_
=
h
.
adminService
.
TestProxy
(
context
.
Background
(),
id
)
}
}()
}
response
.
Success
(
c
,
result
)
}
func
(
h
*
ProxyHandler
)
listProxiesFiltered
(
ctx
context
.
Context
,
protocol
,
status
,
search
string
)
([]
service
.
Proxy
,
error
)
{
page
:=
1
pageSize
:=
dataPageCap
...
...
backend/internal/handler/admin/proxy_data_handler_test.go
View file @
ce9a247a
package
admin
import
(
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
...
...
@@ -16,6 +18,11 @@ type proxyDataResponse struct {
Data
DataPayload
`json:"data"`
}
type
proxyImportResponse
struct
{
Code
int
`json:"code"`
Data
DataImportResult
`json:"data"`
}
func
setupProxyDataRouter
()
(
*
gin
.
Engine
,
*
stubAdminService
)
{
gin
.
SetMode
(
gin
.
TestMode
)
router
:=
gin
.
New
()
...
...
@@ -23,6 +30,7 @@ func setupProxyDataRouter() (*gin.Engine, *stubAdminService) {
h
:=
NewProxyHandler
(
adminSvc
)
router
.
GET
(
"/api/v1/admin/proxies/data"
,
h
.
ExportData
)
router
.
POST
(
"/api/v1/admin/proxies/data"
,
h
.
ImportData
)
return
router
,
adminSvc
}
...
...
@@ -66,3 +74,74 @@ func TestProxyExportDataRespectsFilters(t *testing.T) {
require
.
Len
(
t
,
resp
.
Data
.
Accounts
,
0
)
require
.
Equal
(
t
,
"https"
,
resp
.
Data
.
Proxies
[
0
]
.
Protocol
)
}
func
TestProxyImportDataReusesAndTriggersLatencyProbe
(
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
,
},
}
payload
:=
map
[
string
]
any
{
"data"
:
map
[
string
]
any
{
"type"
:
dataType
,
"version"
:
dataVersion
,
"proxies"
:
[]
map
[
string
]
any
{
{
"proxy_key"
:
"http|127.0.0.1|8080|user|pass"
,
"name"
:
"proxy-a"
,
"protocol"
:
"http"
,
"host"
:
"127.0.0.1"
,
"port"
:
8080
,
"username"
:
"user"
,
"password"
:
"pass"
,
"status"
:
"inactive"
,
},
{
"proxy_key"
:
"https|10.0.0.2|443|u|p"
,
"name"
:
"proxy-b"
,
"protocol"
:
"https"
,
"host"
:
"10.0.0.2"
,
"port"
:
443
,
"username"
:
"u"
,
"password"
:
"p"
,
"status"
:
"active"
,
},
},
},
}
body
,
_
:=
json
.
Marshal
(
payload
)
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/proxies/data"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
var
resp
proxyImportResponse
require
.
NoError
(
t
,
json
.
Unmarshal
(
rec
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
Equal
(
t
,
1
,
resp
.
Data
.
ProxyCreated
)
require
.
Equal
(
t
,
1
,
resp
.
Data
.
ProxyReused
)
require
.
Equal
(
t
,
0
,
resp
.
Data
.
ProxyFailed
)
adminSvc
.
mu
.
Lock
()
updatedIDs
:=
append
([]
int64
(
nil
),
adminSvc
.
updatedProxyIDs
...
)
adminSvc
.
mu
.
Unlock
()
require
.
Contains
(
t
,
updatedIDs
,
int64
(
1
))
require
.
Eventually
(
t
,
func
()
bool
{
adminSvc
.
mu
.
Lock
()
defer
adminSvc
.
mu
.
Unlock
()
return
len
(
adminSvc
.
testedProxyIDs
)
==
2
},
time
.
Second
,
10
*
time
.
Millisecond
)
}
backend/internal/server/routes/admin.go
View file @
ce9a247a
...
...
@@ -281,6 +281,7 @@ func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
proxies
.
GET
(
""
,
h
.
Admin
.
Proxy
.
List
)
proxies
.
GET
(
"/all"
,
h
.
Admin
.
Proxy
.
GetAll
)
proxies
.
GET
(
"/data"
,
h
.
Admin
.
Proxy
.
ExportData
)
proxies
.
POST
(
"/data"
,
h
.
Admin
.
Proxy
.
ImportData
)
proxies
.
GET
(
"/:id"
,
h
.
Admin
.
Proxy
.
GetByID
)
proxies
.
POST
(
""
,
h
.
Admin
.
Proxy
.
Create
)
proxies
.
PUT
(
"/:id"
,
h
.
Admin
.
Proxy
.
Update
)
...
...
frontend/src/__tests__/integration/proxy-data-import.spec.ts
0 → 100644
View file @
ce9a247a
import
{
describe
,
it
,
expect
,
vi
,
beforeEach
}
from
'
vitest
'
import
{
mount
}
from
'
@vue/test-utils
'
import
ImportDataModal
from
'
@/components/admin/proxy/ImportDataModal.vue
'
const
showError
=
vi
.
fn
()
const
showSuccess
=
vi
.
fn
()
vi
.
mock
(
'
@/stores/app
'
,
()
=>
({
useAppStore
:
()
=>
({
showError
,
showSuccess
})
}))
vi
.
mock
(
'
@/api/admin
'
,
()
=>
({
adminAPI
:
{
proxies
:
{
importData
:
vi
.
fn
()
}
}
}))
vi
.
mock
(
'
vue-i18n
'
,
()
=>
({
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
key
})
}))
describe
(
'
Proxy ImportDataModal
'
,
()
=>
{
beforeEach
(()
=>
{
showError
.
mockReset
()
showSuccess
.
mockReset
()
})
it
(
'
未选择文件时提示错误
'
,
async
()
=>
{
const
wrapper
=
mount
(
ImportDataModal
,
{
props
:
{
show
:
true
},
global
:
{
stubs
:
{
BaseDialog
:
{
template
:
'
<div><slot /><slot name="footer" /></div>
'
}
}
}
})
await
wrapper
.
find
(
'
form
'
).
trigger
(
'
submit
'
)
expect
(
showError
).
toHaveBeenCalledWith
(
'
admin.proxies.dataImportSelectFile
'
)
})
it
(
'
无效 JSON 时提示解析失败
'
,
async
()
=>
{
const
wrapper
=
mount
(
ImportDataModal
,
{
props
:
{
show
:
true
},
global
:
{
stubs
:
{
BaseDialog
:
{
template
:
'
<div><slot /><slot name="footer" /></div>
'
}
}
}
})
const
input
=
wrapper
.
find
(
'
input[type="file"]
'
)
const
file
=
new
File
([
'
invalid json
'
],
'
data.json
'
,
{
type
:
'
application/json
'
})
Object
.
defineProperty
(
input
.
element
,
'
files
'
,
{
value
:
[
file
]
})
await
input
.
trigger
(
'
change
'
)
await
wrapper
.
find
(
'
form
'
).
trigger
(
'
submit
'
)
expect
(
showError
).
toHaveBeenCalledWith
(
'
admin.proxies.dataImportParseFailed
'
)
})
})
frontend/src/api/admin/proxies.ts
View file @
ce9a247a
...
...
@@ -10,7 +10,8 @@ import type {
CreateProxyRequest
,
UpdateProxyRequest
,
PaginatedResponse
,
AdminDataPayload
AdminDataPayload
,
AdminDataImportResult
}
from
'
@/types
'
/**
...
...
@@ -220,6 +221,13 @@ export async function exportData(filters?: {
return
data
}
export
async
function
importData
(
payload
:
{
data
:
AdminDataPayload
}):
Promise
<
AdminDataImportResult
>
{
const
{
data
}
=
await
apiClient
.
post
<
AdminDataImportResult
>
(
'
/admin/proxies/data
'
,
payload
)
return
data
}
export
const
proxiesAPI
=
{
list
,
getAll
,
...
...
@@ -234,7 +242,8 @@ export const proxiesAPI = {
getProxyAccounts
,
batchCreate
,
batchDelete
,
exportData
exportData
,
importData
}
export
default
proxiesAPI
frontend/src/components/admin/account/ImportDataModal.vue
View file @
ce9a247a
...
...
@@ -18,15 +18,26 @@
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.dataImportFile
'
)
}}
</label>
<div
class=
"flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
>
<div
class=
"min-w-0"
>
<div
class=
"truncate text-sm text-gray-700 dark:text-dark-200"
>
{{
fileName
||
t
(
'
admin.accounts.dataImportSelectFile
'
)
}}
</div>
<div
class=
"text-xs text-gray-500 dark:text-dark-400"
>
JSON (.json)
</div>
</div>
<button
type=
"button"
class=
"btn btn-secondary shrink-0"
@
click=
"openFilePicker"
>
{{
t
(
'
common.chooseFile
'
)
}}
</button>
</div>
<input
ref=
"fileInput"
type=
"file"
class=
"
input
"
class=
"
hidden
"
accept=
"application/json,.json"
@
change=
"handleFileChange"
/>
<p
v-if=
"fileName"
class=
"mt-2 text-xs text-gray-500 dark:text-dark-400"
>
{{
fileName
}}
</p>
</div>
<div
...
...
@@ -100,6 +111,7 @@ const importing = ref(false)
const
file
=
ref
<
File
|
null
>
(
null
)
const
result
=
ref
<
AdminDataImportResult
|
null
>
(
null
)
const
fileInput
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
fileName
=
computed
(()
=>
file
.
value
?.
name
||
''
)
const
errorItems
=
computed
(()
=>
result
.
value
?.
errors
||
[])
...
...
@@ -110,10 +122,17 @@ watch(
if
(
open
)
{
file
.
value
=
null
result
.
value
=
null
if
(
fileInput
.
value
)
{
fileInput
.
value
.
value
=
''
}
}
}
)
const
openFilePicker
=
()
=>
{
fileInput
.
value
?.
click
()
}
const
handleFileChange
=
(
event
:
Event
)
=>
{
const
target
=
event
.
target
as
HTMLInputElement
file
.
value
=
target
.
files
?.[
0
]
||
null
...
...
frontend/src/components/admin/proxy/ImportDataModal.vue
0 → 100644
View file @
ce9a247a
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.proxies.dataImportTitle')"
width=
"normal"
close-on-click-outside
@
close=
"handleClose"
>
<form
id=
"import-proxy-data-form"
class=
"space-y-4"
@
submit.prevent=
"handleImport"
>
<div
class=
"text-sm text-gray-600 dark:text-dark-300"
>
{{
t
(
'
admin.proxies.dataImportHint
'
)
}}
</div>
<div
class=
"rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
>
{{
t
(
'
admin.proxies.dataImportWarning
'
)
}}
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.proxies.dataImportFile
'
)
}}
</label>
<div
class=
"flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
>
<div
class=
"min-w-0"
>
<div
class=
"truncate text-sm text-gray-700 dark:text-dark-200"
>
{{
fileName
||
t
(
'
admin.proxies.dataImportSelectFile
'
)
}}
</div>
<div
class=
"text-xs text-gray-500 dark:text-dark-400"
>
JSON (.json)
</div>
</div>
<button
type=
"button"
class=
"btn btn-secondary shrink-0"
@
click=
"openFilePicker"
>
{{
t
(
'
common.chooseFile
'
)
}}
</button>
</div>
<input
ref=
"fileInput"
type=
"file"
class=
"hidden"
accept=
"application/json,.json"
@
change=
"handleFileChange"
/>
</div>
<div
v-if=
"result"
class=
"space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.proxies.dataImportResult
'
)
}}
</div>
<div
class=
"text-sm text-gray-700 dark:text-dark-300"
>
{{
t
(
'
admin.proxies.dataImportResultSummary
'
,
result
)
}}
</div>
<div
v-if=
"errorItems.length"
class=
"mt-2"
>
<div
class=
"text-sm font-medium text-red-600 dark:text-red-400"
>
{{
t
(
'
admin.proxies.dataImportErrors
'
)
}}
</div>
<div
class=
"mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
>
<div
v-for=
"(item, idx) in errorItems"
:key=
"idx"
class=
"whitespace-pre-wrap"
>
{{
item
.
kind
}}
{{
item
.
name
||
item
.
proxy_key
||
'
-
'
}}
—
{{
item
.
message
}}
</div>
</div>
</div>
</div>
</form>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
class=
"btn btn-secondary"
type=
"button"
:disabled=
"importing"
@
click=
"handleClose"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
class=
"btn btn-primary"
type=
"submit"
form=
"import-proxy-data-form"
:disabled=
"importing"
>
{{
importing
?
t
(
'
admin.proxies.dataImporting
'
)
:
t
(
'
admin.proxies.dataImportButton
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
,
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
type
{
AdminDataImportResult
}
from
'
@/types
'
interface
Props
{
show
:
boolean
}
interface
Emits
{
(
e
:
'
close
'
):
void
(
e
:
'
imported
'
):
void
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
<
Emits
>
()
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
importing
=
ref
(
false
)
const
file
=
ref
<
File
|
null
>
(
null
)
const
result
=
ref
<
AdminDataImportResult
|
null
>
(
null
)
const
fileInput
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
fileName
=
computed
(()
=>
file
.
value
?.
name
||
''
)
const
errorItems
=
computed
(()
=>
result
.
value
?.
errors
||
[])
watch
(
()
=>
props
.
show
,
(
open
)
=>
{
if
(
open
)
{
file
.
value
=
null
result
.
value
=
null
if
(
fileInput
.
value
)
{
fileInput
.
value
.
value
=
''
}
}
}
)
const
openFilePicker
=
()
=>
{
fileInput
.
value
?.
click
()
}
const
handleFileChange
=
(
event
:
Event
)
=>
{
const
target
=
event
.
target
as
HTMLInputElement
file
.
value
=
target
.
files
?.[
0
]
||
null
}
const
handleClose
=
()
=>
{
if
(
importing
.
value
)
return
emit
(
'
close
'
)
}
const
handleImport
=
async
()
=>
{
if
(
!
file
.
value
)
{
appStore
.
showError
(
t
(
'
admin.proxies.dataImportSelectFile
'
))
return
}
importing
.
value
=
true
try
{
const
text
=
await
file
.
value
.
text
()
const
dataPayload
=
JSON
.
parse
(
text
)
const
res
=
await
adminAPI
.
proxies
.
importData
({
data
:
dataPayload
})
result
.
value
=
res
const
msgParams
:
Record
<
string
,
unknown
>
=
{
proxy_created
:
res
.
proxy_created
,
proxy_reused
:
res
.
proxy_reused
,
proxy_failed
:
res
.
proxy_failed
}
if
(
res
.
proxy_failed
>
0
)
{
appStore
.
showError
(
t
(
'
admin.proxies.dataImportCompletedWithErrors
'
,
msgParams
))
}
else
{
appStore
.
showSuccess
(
t
(
'
admin.proxies.dataImportSuccess
'
,
msgParams
))
emit
(
'
imported
'
)
}
}
catch
(
error
:
any
)
{
if
(
error
instanceof
SyntaxError
)
{
appStore
.
showError
(
t
(
'
admin.proxies.dataImportParseFailed
'
))
}
else
{
appStore
.
showError
(
error
?.
message
||
t
(
'
admin.proxies.dataImportFailed
'
))
}
}
finally
{
importing
.
value
=
false
}
}
</
script
>
frontend/src/i18n/locales/en.ts
View file @
ce9a247a
...
...
@@ -165,6 +165,7 @@ export default {
selectedCount
:
'
({count} selected)
'
,
refresh
:
'
Refresh
'
,
settings
:
'
Settings
'
,
chooseFile
:
'
Choose File
'
,
notAvailable
:
'
N/A
'
,
now
:
'
Now
'
,
unknown
:
'
Unknown
'
,
...
...
@@ -1190,7 +1191,7 @@ export default {
syncFromCrs
:
'
Sync from CRS
'
,
dataExport
:
'
Export
'
,
dataExportSelected
:
'
Export Selected
'
,
dataExportIncludeProxies
:
'
Include proxies
(unchecked = no proxy linkage on import)
'
,
dataExportIncludeProxies
:
'
Include proxies
linked to the exported accounts
'
,
dataImport
:
'
Import
'
,
dataExportConfirmMessage
:
'
The exported data contains sensitive account and proxy information. Store it securely.
'
,
dataExportConfirm
:
'
Confirm Export
'
,
...
...
@@ -1198,7 +1199,7 @@ export default {
dataExportFailed
:
'
Failed to export data
'
,
dataImportTitle
:
'
Import Data
'
,
dataImportHint
:
'
Upload the exported JSON file to import accounts and proxies.
'
,
dataImportWarning
:
'
Import will create new accounts/proxies; groups must be bound manually. Ensure
no conflicts in the target instance
.
'
,
dataImportWarning
:
'
Import will create new accounts/proxies; groups must be bound manually. Ensure
existing data does not conflict
.
'
,
dataImportFile
:
'
Data file
'
,
dataImportButton
:
'
Start Import
'
,
dataImporting
:
'
Importing...
'
,
...
...
@@ -1901,6 +1902,21 @@ export default {
createProxy
:
'
Create Proxy
'
,
editProxy
:
'
Edit Proxy
'
,
deleteProxy
:
'
Delete Proxy
'
,
dataImport
:
'
Import
'
,
dataImportTitle
:
'
Import Proxies
'
,
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.
'
,
dataImportFile
:
'
Data File
'
,
dataImportButton
:
'
Start Import
'
,
dataImporting
:
'
Importing...
'
,
dataImportSelectFile
:
'
Please select a data file
'
,
dataImportParseFailed
:
'
Failed to parse data
'
,
dataImportFailed
:
'
Failed to import data
'
,
dataImportResult
:
'
Import Result
'
,
dataImportResultSummary
:
'
Created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}
'
,
dataImportErrors
:
'
Failure Details
'
,
dataImportSuccess
:
'
Import completed: created {proxy_created}, reused {proxy_reused}
'
,
dataImportCompletedWithErrors
:
'
Import completed with errors: failed {proxy_failed}
'
,
dataExport
:
'
Export
'
,
dataExportConfirmMessage
:
'
The exported data contains sensitive proxy information. Store it securely.
'
,
dataExportConfirm
:
'
Confirm Export
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
ce9a247a
...
...
@@ -162,6 +162,7 @@ export default {
selectedCount
:
'
(已选 {count} 个)
'
,
refresh
:
'
刷新
'
,
settings
:
'
设置
'
,
chooseFile
:
'
选择文件
'
,
notAvailable
:
'
不可用
'
,
now
:
'
现在
'
,
unknown
:
'
未知
'
,
...
...
@@ -1275,7 +1276,7 @@ export default {
syncFromCrs
:
'
从 CRS 同步
'
,
dataExport
:
'
导出
'
,
dataExportSelected
:
'
导出选中
'
,
dataExportIncludeProxies
:
'
导出代理(
取消后导入时不
关联代理)
'
,
dataExportIncludeProxies
:
'
导出代理(
导出账号
关联
的
代理)
'
,
dataImport
:
'
导入
'
,
dataExportConfirmMessage
:
'
导出的数据包含账号与代理的敏感信息,请妥善保存。
'
,
dataExportConfirm
:
'
确认导出
'
,
...
...
@@ -1283,7 +1284,7 @@ export default {
dataExportFailed
:
'
数据导出失败
'
,
dataImportTitle
:
'
导入数据
'
,
dataImportHint
:
'
上传导出的 JSON 文件以批量导入账号与代理。
'
,
dataImportWarning
:
'
导入将创建新账号与代理,分组需手工绑定;请确认
目标实例
已有数据不会冲突。
'
,
dataImportWarning
:
'
导入将创建新账号与代理,分组需手工绑定;请确认已有数据不会冲突。
'
,
dataImportFile
:
'
数据文件
'
,
dataImportButton
:
'
开始导入
'
,
dataImporting
:
'
导入中...
'
,
...
...
@@ -2010,6 +2011,21 @@ export default {
deleteProxy
:
'
删除代理
'
,
deleteConfirmMessage
:
"
确定要删除代理 '{name}' 吗?
"
,
testProxy
:
'
测试代理
'
,
dataImport
:
'
导入
'
,
dataImportTitle
:
'
导入代理
'
,
dataImportHint
:
'
上传代理导出的 JSON 文件以批量导入代理。
'
,
dataImportWarning
:
'
导入将创建或复用代理,保留状态并在完成后自动触发延迟检测。
'
,
dataImportFile
:
'
数据文件
'
,
dataImportButton
:
'
开始导入
'
,
dataImporting
:
'
导入中...
'
,
dataImportSelectFile
:
'
请选择数据文件
'
,
dataImportParseFailed
:
'
数据解析失败
'
,
dataImportFailed
:
'
数据导入失败
'
,
dataImportResult
:
'
导入结果
'
,
dataImportResultSummary
:
'
创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed}
'
,
dataImportErrors
:
'
失败详情
'
,
dataImportSuccess
:
'
导入完成:创建 {proxy_created},复用 {proxy_reused}
'
,
dataImportCompletedWithErrors
:
'
导入完成但有错误:失败 {proxy_failed}
'
,
dataExport
:
'
导出
'
,
dataExportConfirmMessage
:
'
导出的数据包含代理的敏感信息,请妥善保存。
'
,
dataExportConfirm
:
'
确认导出
'
,
...
...
frontend/src/views/admin/AccountsView.vue
View file @
ce9a247a
...
...
@@ -118,6 +118,15 @@
default
-
sort
-
order
=
"
asc
"
:
sort
-
storage
-
key
=
"
ACCOUNT_SORT_STORAGE_KEY
"
>
<
template
#
header
-
select
>
<
input
type
=
"
checkbox
"
class
=
"
h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
:
checked
=
"
allVisibleSelected
"
@
click
.
stop
@
change
=
"
toggleSelectAllVisible($event)
"
/>
<
/template
>
<
template
#
cell
-
select
=
"
{ row
}
"
>
<
input
type
=
"
checkbox
"
:
checked
=
"
selIds.includes(row.id)
"
@
change
=
"
toggleSel(row.id)
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
<
/template
>
...
...
@@ -551,6 +560,21 @@ const openMenu = (a: Account, e: MouseEvent) => {
menu
.
show
=
true
}
const
toggleSel
=
(
id
:
number
)
=>
{
const
i
=
selIds
.
value
.
indexOf
(
id
);
if
(
i
===
-
1
)
selIds
.
value
.
push
(
id
);
else
selIds
.
value
.
splice
(
i
,
1
)
}
const
allVisibleSelected
=
computed
(()
=>
{
if
(
accounts
.
value
.
length
===
0
)
return
false
return
accounts
.
value
.
every
(
account
=>
selIds
.
value
.
includes
(
account
.
id
))
}
)
const
toggleSelectAllVisible
=
(
event
:
Event
)
=>
{
const
target
=
event
.
target
as
HTMLInputElement
if
(
target
.
checked
)
{
const
next
=
new
Set
(
selIds
.
value
)
accounts
.
value
.
forEach
(
account
=>
next
.
add
(
account
.
id
))
selIds
.
value
=
Array
.
from
(
next
)
return
}
const
visibleIds
=
new
Set
(
accounts
.
value
.
map
(
account
=>
account
.
id
))
selIds
.
value
=
selIds
.
value
.
filter
(
id
=>
!
visibleIds
.
has
(
id
))
}
const
selectPage
=
()
=>
{
selIds
.
value
=
[...
new
Set
([...
selIds
.
value
,
...
accounts
.
value
.
map
(
a
=>
a
.
id
)])]
}
const
handleBulkDelete
=
async
()
=>
{
if
(
!
confirm
(
t
(
'
common.confirm
'
)))
return
;
try
{
await
Promise
.
all
(
selIds
.
value
.
map
(
id
=>
adminAPI
.
accounts
.
delete
(
id
)));
selIds
.
value
=
[];
reload
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to bulk delete accounts:
'
,
error
)
}
}
const
updateSchedulableInList
=
(
accountIds
:
number
[],
schedulable
:
boolean
)
=>
{
...
...
frontend/src/views/admin/ProxiesView.vue
View file @
ce9a247a
...
...
@@ -69,6 +69,9 @@
<Icon
name=
"trash"
size=
"md"
class=
"mr-2"
/>
{{
t
(
'
admin.proxies.batchDeleteAction
'
)
}}
</button>
<button
@
click=
"showImportData = true"
class=
"btn btn-secondary"
>
{{
t
(
'
admin.proxies.dataImport
'
)
}}
</button>
<button
@
click=
"showExportDataDialog = true"
class=
"btn btn-secondary"
>
{{
t
(
'
admin.proxies.dataExport
'
)
}}
</button>
...
...
@@ -619,6 +622,12 @@
@
cancel
=
"
showExportDataDialog = false
"
/>
<
ImportDataModal
:
show
=
"
showImportData
"
@
close
=
"
showImportData = false
"
@
imported
=
"
handleDataImported
"
/>
<!--
Proxy
Accounts
Dialog
-->
<
BaseDialog
:
show
=
"
showAccountsModal
"
...
...
@@ -680,6 +689,7 @@ import Pagination from '@/components/common/Pagination.vue'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
ImportDataModal
from
'
@/components/admin/proxy/ImportDataModal.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
PlatformTypeBadge
from
'
@/components/common/PlatformTypeBadge.vue
'
...
...
@@ -743,6 +753,7 @@ const pagination = reactive({
const
showCreateModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showImportData
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showBatchDeleteDialog
=
ref
(
false
)
const
showExportDataDialog
=
ref
(
false
)
...
...
@@ -902,6 +913,11 @@ const closeCreateModal = () => {
batchParseResult
.
proxies
=
[]
}
const
handleDataImported
=
()
=>
{
showImportData
.
value
=
false
loadProxies
()
}
// Parse proxy URL: protocol://user:pass@host:port or protocol://host:port
const
parseProxyUrl
=
(
line
:
string
...
...
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